Compare commits

..
Author SHA1 Message Date
EthanHealy01 a53cce80b6 V2 Navbar Styling 2025-07-21 16:48:24 +01:00
Anthony Stirling b8d582a1e3 Update PR-Auto to include ethan 2025-07-20 21:45:43 +01:00
Anthony Stirling 0549c5b191 testing and docker replacements (#3968)
This PR restructures testing scripts and Docker configurations to use centralized compose files, introduces new Docker Compose variants with integrated frontend services, and updates related CI workflows.

Migrate test scripts to reference testing/compose files and streamline test flows with forced rebuilds and direct curl checks.
Add ultra-lite, security, and security-with-login compose files under testing/compose, each defining both backend and frontend services.
Rename and adjust frontend imports and update CI workflows to build and validate the frontend separately.
2025-07-18 14:19:36 +01:00
Anthony Stirling 0742364a03 V2 frontend license checker (#3944)
# Added scripts for checking the licenses of dependencies similar to the backend app
2025-07-18 13:50:40 +01:00
Anthony Stirling 9af09c6ac2 Merge pull request #3965 from Stirling-Tools/V2-gha
V2 testing and docker replacements
2025-07-17 14:29:09 +01:00
Anthony Stirling 117d906be3 test new doc 2025-07-17 00:40:30 +01:00
Anthony Stirling 33549c2d67 fix for deploy 2025-07-17 00:34:08 +01:00
Anthony Stirling a35956d927 V2 2025-07-16 23:42:38 +01:00
Anthony Stirling 3fd52ed564 testing and docker replacements 2025-07-16 23:09:26 +01:00
Anthony Stirling 2f977ff250 Merge branch 'main' into V2 2025-07-16 21:25:37 +01:00
Reece BrowneandAnthony Stirling 922bbc9076 Stirling 2.0 (#3928)
# Description of Changes

<!--

File context for managing files between tools and views
Optimisation for large files
Updated Split to work with new file system and match Matts stepped
design closer

-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2025-07-16 17:53:50 +01:00
Anthony Stirling 584e2ecee7 PR extra 2025-07-16 15:20:02 +01:00
Anthony Stirling 80e425926e PR auto deploy 2025-07-16 15:14:03 +01:00
Anthony Stirling 58d53c7a6b test (#3963)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-07-16 14:49:34 +01:00
Anthony Stirling 89cda87490 re-delete files 2025-07-11 15:18:03 +01:00
Anthony Stirling af8e065e87 Merge remote-tracking branch 'origin/main' into feature/react-overhaul 2025-07-11 15:14:11 +01:00
Anthony Stirlinganda 21210850ec V2 docker support react version2 (#3930)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: a <a>
2025-07-11 14:16:34 +01:00
ConnorYoh 50d745225c Merge pull request #3833 from Stirling-Tools/feature/react-backend-config-access
Feature/react backend config access
2025-06-30 09:28:36 +01:00
Connor Yoh f446fcd50b Remove excess hidden, premium key and redundant code 2025-06-27 18:29:04 +01:00
ConnorYoh 618485576d Merge pull request #3834 from Stirling-Tools/pixeebot/feature/react-backend-config-access
Hardening suggestions for Stirling-PDF / feature/react-backend-config-access
2025-06-27 18:25:27 +01:00
pixeebot[bot] e75213a206 Switch order of literals to prevent NullPointerException 2025-06-27 17:17:11 +00:00
Connor Yoh 248a14c571 Added config controller to be used by react frontend 2025-06-27 18:00:35 +01:00
Connor Yoh 62fb5025a9 Whitespace fix 2025-06-27 17:00:25 +01:00
ConnorYoh 3444aa6525 Merge pull request #3832 from Stirling-Tools/react-prop-merges
React prop merges
2025-06-27 16:20:20 +01:00
Connor Yoh 5786421616 Merge branch 'main' into react-prop-merges 2025-06-27 16:15:11 +01:00
Connor Yoh 0090d9f483 Remove accidental introduction of propriatary folder structure 2025-06-27 16:07:03 +01:00
ConnorYoh 29916d85b1 Merge pull request #3811 from reecebrowne/Stirling-2.0
Stirling 2.0
2025-06-25 16:07:22 +01:00
Reece ffe5b9577b Page editor fixes and improvements and restructuring 2025-06-24 23:31:21 +01:00
Reece 2f90220b7b File handling fixes 2025-06-24 20:25:03 +01:00
ConnorYoh bfc679edc5 Merge pull request #3663 from reecebrowne/Stirling-2.0
Stirling 2.0
2025-06-24 15:04:28 +01:00
Reece 27dd11ba8a Replace old components with moved versions from Stirling-2.0 2025-06-24 12:55:15 +01:00
Reece a51960b199 Resolve conflicts using files from Stirling-2.0 2025-06-24 12:51:39 +01:00
Reece 16e56e3208 Resolve merge conflicts by using versions from Stirling-2.0 2025-06-24 12:20:31 +01:00
Reece 25e9db2570 Clean up 2025-06-20 23:00:26 +01:00
Reece cbc5616a39 Translations for file management 2025-06-20 21:46:37 +01:00
Reece 215bb86a8e Bug fixing, file management changes 2025-06-20 20:22:18 +01:00
Reece 3ebf75ae6f File editor groundwork and refactor work 2025-06-20 17:51:24 +01:00
Reece 9c410865f9 Big refactor 2025-06-19 22:41:05 +01:00
Reece 868969192b Refactor 2025-06-19 19:47:44 +01:00
Reece d981968e0f Page editor redesign 2025-06-18 18:12:15 +01:00
Reece ae508730c3 Added quick access bar consolodated sidebars 2025-06-18 14:16:39 +01:00
Reece bf6a74210f Remove repeated tailwind imports causing issues 2025-06-18 13:36:24 +01:00
Reece e7995a0256 Page editor updates 2025-06-16 19:57:50 +01:00
Reece 7fc850b138 Update Page editor styling 2025-06-16 15:11:00 +01:00
Reece ac3da9b7c2 Developer guide updates and claude.md 2025-06-14 22:03:46 +01:00
Reece 5298cb1607 Ground work for page editor 2025-06-10 11:19:54 +01:00
Reece 41efd80dd5 RAINBOW MODE!!!! 2025-06-09 18:38:06 +01:00
Reece 342345cbb9 Fix viewer positioning error 2025-06-09 18:37:47 +01:00
Reece 6bf41a4cbe Remove Claude.md from gitignore 2025-06-09 10:53:32 +01:00
Reece 1e0949ff2b Css fixes, Tailwind forward 2025-06-08 13:45:45 +01:00
Reece b1584151ba Css overhaul 2025-06-06 17:20:06 +01:00
Reece 0c76bf2c8b Homepage refactor 2025-06-05 21:59:18 +01:00
Reece Browne b5d84db3c8 Stirling 2.0 (#3645)
# Description of Changes

Please provide a summary of the changes, including:

Vite fixes
Indexxdb

Closes #(issue_number)

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing)
for more details.
2025-06-05 11:12:39 +01:00
Reece 7dd79aa2a1 Fix url params and fix vite 2025-06-04 23:02:53 +01:00
Reece 61eac67dc1 Claude.md in gitignore 2025-06-04 19:06:07 +01:00
Reece 16f150a203 IndexxedDb refactored 2025-06-04 19:04:56 +01:00
Reece a3c4f1a305 IndexedDb integration 2025-06-04 19:04:18 +01:00
Reece 0650a6a877 Remove License file 2025-06-03 13:19:16 +01:00
Reece 09f05ac8d0 React translations 2025-05-29 17:26:32 +01:00
Reece 5f862f55d9 Restore theme toggle 2025-05-28 22:18:19 +01:00
Reece 15761ae743 Remove CRA and use Vite instead 2025-05-28 21:43:02 +01:00
Reece d216811317 - Viewer overhaul
-Dark mode toggle
-URL params improvements
-app.js set up fix
- UI clean up
2025-05-27 19:28:03 +01:00
Reece 41c82b15da TSX rewrite and query strings initial set up 2025-05-21 21:47:44 +01:00
Reece 5f7a4e1664 Merge branch 'Stirling-2.0' of https://github.com/reecebrowne/Stirling-PDF into Stirling-2.0 2025-05-19 13:14:58 +01:00
Reece Browne b5e7995554 File management groundwork 2025-05-15 23:12:39 +01:00
Reece Browne f789533c83 Mantine overhaul 2025-05-15 20:07:33 +01:00
Reece Browne d669964975 Basic homepage with compress and split 2025-05-13 23:32:54 +01:00
Reece Browne b567f4b110 Initial react set up 2025-05-09 20:01:09 +01:00
333 changed files with 151918 additions and 5461 deletions
+2 -1
View File
@@ -5,7 +5,8 @@
"Bash(mkdir:*)",
"Bash(./gradlew:*)",
"Bash(grep:*)",
"Bash(cat:*)"
"Bash(cat:*)",
"Bash(find:*)"
],
"deny": []
}
+1 -1
View File
@@ -1,2 +1,2 @@
# All PRs to V1 must be approved by Frooodle
* @Frooodle @reecebrowne @Ludy87 @DarioGii @ConnorYoh @EthanHealy01
* @Frooodle @reecebrowne @Ludy87 @DarioGii @ConnorYoh
-29
View File
@@ -1,29 +0,0 @@
build: &build
- build.gradle
- app/(common|core|proprietary)/build.gradle
app: &app
- app/(common|core|proprietary)/src/main/java/**
openapi: &openapi
- build.gradle
- app/(common|core|proprietary)/build.gradle
- app/(common|core|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)*'
- exampleYmlFiles/**
- gradle/**
- libs/**
- testing/**
- build.gradle
- Dockerfile
- Dockerfile.fat
- Dockerfile.ultra-lite
- gradle.properties
- gradlew
- gradlew.bat
- launch4jConfig.xml
- settings.gradle
+1 -2
View File
@@ -7,7 +7,6 @@
"sbplat",
"reecebrowne",
"DarioGii",
"ConnorYoh",
"EthanHealy01"
"ConnorYoh"
]
}
+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'
+500
View File
@@ -0,0 +1,500 @@
name: Auto PR V2 Deployment
on:
pull_request:
types: [opened, synchronize, reopened, closed]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
check-pr:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
outputs:
should_deploy: ${{ steps.check-conditions.outputs.should_deploy }}
pr_number: ${{ github.event.number }}
pr_repository: ${{ steps.get-pr-info.outputs.repository }}
pr_ref: ${{ steps.get-pr-info.outputs.ref }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
- name: Check deployment conditions
id: check-conditions
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
run: |
echo "PR Title: $PR_TITLE"
echo "PR Author: $PR_AUTHOR"
echo "PR Branch: $PR_BRANCH"
echo "PR Base Branch: ${{ github.event.pull_request.base.ref }}"
# Define authorized users
authorized_users=(
"Frooodle"
"sf298"
"Ludy87"
"LaserKaspar"
"sbplat"
"reecebrowne"
"DarioGii"
"ConnorYoh"
"EthanHealy01"
)
# Check if author is in the authorized list
is_authorized=false
for user in "${authorized_users[@]}"; do
if [[ "$PR_AUTHOR" == "$user" ]]; then
is_authorized=true
break
fi
done
# If PR is targeting V2 and user is authorized, deploy unconditionally
PR_BASE_BRANCH="${{ github.event.pull_request.base.ref }}"
if [[ "$PR_BASE_BRANCH" == "V2" && "$is_authorized" == "true" ]]; then
echo "✅ Deployment forced: PR targets V2 and author is authorized."
echo "should_deploy=true" >> $GITHUB_OUTPUT
exit 0
fi
# Otherwise, continue with original keyword checks
has_v2_keyword=false
if [[ "$PR_TITLE" =~ [Vv]2 ]] || [[ "$PR_TITLE" =~ [Vv]ersion.?2 ]] || [[ "$PR_TITLE" =~ [Vv]ersion.?[Tt]wo ]]; then
has_v2_keyword=true
fi
has_branch_keyword=false
if [[ "$PR_BRANCH" =~ [Vv]2 ]] || [[ "$PR_BRANCH" =~ [Rr]eact ]]; then
has_branch_keyword=true
fi
if [[ "$is_authorized" == "true" && ( "$has_v2_keyword" == "true" || "$has_branch_keyword" == "true" ) ]]; then
echo "✅ Deployment conditions met"
echo "should_deploy=true" >> $GITHUB_OUTPUT
else
echo "❌ Deployment conditions not met"
echo " - Authorized user: $is_authorized"
echo " - Has V2 keyword in title: $has_v2_keyword"
echo " - Has V2/React keyword in branch: $has_branch_keyword"
echo "should_deploy=false" >> $GITHUB_OUTPUT
fi
- name: Get PR repository and ref
id: get-pr-info
if: steps.check-conditions.outputs.should_deploy == 'true'
run: |
# For forks, use the full repository name, for internal PRs use the current repo
if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
repository="${{ github.event.pull_request.head.repo.full_name }}"
else
repository="${{ github.repository }}"
fi
echo "repository=$repository" >> $GITHUB_OUTPUT
echo "ref=${{ github.event.pull_request.head.ref }}" >> $GITHUB_OUTPUT
deploy-v2-pr:
needs: check-pr
runs-on: ubuntu-latest
if: needs.check-pr.outputs.should_deploy == 'true'
# Concurrency control - only one deployment per PR at a time
concurrency:
group: v2-deploy-pr-${{ needs.check-pr.outputs.pr_number }}
cancel-in-progress: true
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
- name: Checkout main repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: ${{ github.repository }}
ref: main
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
id: setup-bot
uses: ./.github/actions/setup-bot
continue-on-error: true
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Add deployment started comment
id: deployment-started
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = ${{ needs.check-pr.outputs.pr_number }};
// Delete previous V2 deployment comments to avoid clutter
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
per_page: 100
});
const v2Comments = comments.filter(comment =>
comment.body.includes('🚀 **Auto-deploying V2 version**') ||
comment.body.includes('## 🚀 V2 Auto-Deployment Complete!') ||
comment.body.includes('❌ **V2 Auto-deployment failed**')
);
for (const comment of v2Comments) {
console.log(`Deleting old V2 comment: ${comment.id}`);
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id
});
}
// Create new deployment started comment
const { data: newComment } = await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment triggered by V2/version2 keywords in the PR title or V2/React keywords in the branch name._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.`
});
return newComment.id;
- 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: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 # Fetch full history for commit hash detection
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Get version number
id: versionNumber
run: |
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Get commit hashes for frontend and backend
id: commit-hashes
run: |
# Get last commit that touched the frontend folder, docker/frontend, or docker/compose
FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "")
if [ -z "$FRONTEND_HASH" ]; then
FRONTEND_HASH="no-frontend-changes"
fi
# Get last commit that touched backend code, docker/backend, or docker/compose
BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
if [ -z "$BACKEND_HASH" ]; then
BACKEND_HASH="no-backend-changes"
fi
echo "Frontend hash: $FRONTEND_HASH"
echo "Backend hash: $BACKEND_HASH"
echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
# Short hashes for tags
if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
else
echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
fi
if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
echo "backend_short=no-backend" >> $GITHUB_OUTPUT
else
echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT
fi
- name: Check if frontend image exists
id: check-frontend
run: |
if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Frontend image already exists, skipping build"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Frontend image needs to be built"
fi
- name: Check if backend image exists
id: check-backend
run: |
if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Backend image already exists, skipping build"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Backend image needs to be built"
fi
- name: Build and push V2 frontend image
if: steps.check-frontend.outputs.exists == 'false'
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./docker/frontend/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Build and push V2 backend image
if: steps.check-backend.outputs.exists == 'false'
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./docker/backend/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
build-args: VERSION_TAG=v2-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 V2 to VPS
id: deploy
run: |
# Use same port strategy as regular PRs - just the PR number
V2_PORT=${{ needs.check-pr.outputs.pr_number }}
BACKEND_PORT=$((V2_PORT + 10000)) # Backend on higher port to avoid conflicts
# Create docker-compose for V2 with separate frontend and backend
cat > docker-compose.yml << EOF
version: '3.3'
services:
stirling-pdf-v2-backend:
container_name: stirling-pdf-v2-backend-pr-${{ needs.check-pr.outputs.pr_number }}
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
ports:
- "${BACKEND_PORT}:8080" # Backend API port
volumes:
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/data:/usr/share/tessdata:rw
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/config:/configs:rw
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false"
SYSTEM_DEFAULTLOCALE: en-GB
UI_APPNAME: "Stirling-PDF V2 PR#${{ needs.check-pr.outputs.pr_number }}"
UI_HOMEDESCRIPTION: "V2 PR#${{ needs.check-pr.outputs.pr_number }} - Frontend/Backend Split Architecture"
UI_APPNAMENAVBAR: "V2 PR#${{ needs.check-pr.outputs.pr_number }}"
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
restart: on-failure:5
stirling-pdf-v2-frontend:
container_name: stirling-pdf-v2-frontend-pr-${{ needs.check-pr.outputs.pr_number }}
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
ports:
- "${V2_PORT}:80" # Frontend port (same as regular PRs)
environment:
VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:${BACKEND_PORT}"
depends_on:
- stirling-pdf-v2-backend
restart: on-failure:5
EOF
# Deploy to VPS
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose-v2.yml
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
# Create V2 PR-specific directories
mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs}
# Move docker-compose file to correct location
mv /tmp/docker-compose-v2.yml /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/docker-compose.yml
# Stop any existing container and clean up
cd /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}
docker-compose down --remove-orphans 2>/dev/null || true
# Start the new container
docker-compose pull
docker-compose up -d
# Clean up unused Docker resources to save space
docker system prune -af --volumes
# Clean up old backend/frontend images (older than 2 weeks)
docker image prune -af --filter "until=336h" --filter "label!=keep=true"
ENDSSH
# Set port for output
echo "v2_port=${V2_PORT}" >> $GITHUB_OUTPUT
- name: Post V2 deployment URL to PR
if: success()
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = ${{ needs.check-pr.outputs.pr_number }};
const v2Port = ${{ steps.deploy.outputs.v2_port }};
// Delete the "deploying..." comment since we're posting the final result
const deploymentStartedId = ${{ steps.deployment-started.outputs.result }};
if (deploymentStartedId) {
console.log(`Deleting deployment started comment: ${deploymentStartedId}`);
try {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: deploymentStartedId
});
} catch (error) {
console.log(`Could not delete deployment started comment: ${error.message}`);
}
}
const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${v2Port}`;
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
`Your V2 PR with the new frontend/backend split architecture has been deployed!\n\n` +
`🔗 **V2 Test URL:** [${deploymentUrl}](${deploymentUrl})\n\n` +
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
`🔄 **Auto-deployed** because PR title or branch name contains V2/version2/React keywords.`;
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: commentBody
});
cleanup-v2-deployment:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
permissions:
contents: read
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: Clean up V2 deployment comments
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = ${{ github.event.pull_request.number }};
// Find and delete V2 deployment comments
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber
});
const v2Comments = comments.filter(c =>
c.body?.includes("## 🚀 V2 Auto-Deployment Complete!") &&
c.user?.type === "Bot"
);
for (const comment of v2Comments) {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id
});
console.log(`Deleted V2 deployment comment (ID: ${comment.id})`);
}
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key
- name: Cleanup V2 deployment
run: |
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
if [ -d "/stirling/V2-PR-${{ github.event.pull_request.number }}" ]; then
echo "Found V2 PR directory, proceeding with cleanup..."
# Stop and remove V2 containers
cd /stirling/V2-PR-${{ github.event.pull_request.number }}
docker-compose down || true
# Go back to root before removal
cd /
# Remove V2 PR-specific directories
rm -rf /stirling/V2-PR-${{ github.event.pull_request.number }}
# Clean up V2 containers by name (in case compose cleanup missed them)
docker rm -f stirling-pdf-v2-frontend-pr-${{ github.event.pull_request.number }} || true
docker rm -f stirling-pdf-v2-backend-pr-${{ github.event.pull_request.number }} || true
echo "V2 cleanup completed"
else
echo "V2 PR directory not found, nothing to clean up"
fi
# Clean up old unused images (older than 2 weeks) but keep recent ones for reuse
docker image prune -af --filter "until=336h" --filter "label!=keep=true"
# Note: We don't remove the commit-based images since they can be reused across PRs
# Only remove PR-specific containers and directories
ENDSSH
- name: Cleanup temporary files
if: always()
run: |
rm -f ../private.key
continue-on-error: true
@@ -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
+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
+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
+54 -125
View File
@@ -1,46 +1,15 @@
name: Build and Test Workflow
name: Build repo
on:
workflow_dispatch:
# push:
# branches: ["main"]
push:
branches: ["main", "V2", "V2-gha"]
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
branches: ["main", "V2", "V2-gha"]
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 +25,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
@@ -69,11 +38,6 @@ jobs:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
gradle-version: 8.14
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
run: ./gradlew clean build
env:
@@ -124,17 +88,14 @@ jobs:
if-no-files-found: warn
check-generateOpenApiDocs:
if: needs.files-changed.outputs.openapi == 'true'
needs: [files-changed, 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
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 17
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
@@ -142,25 +103,62 @@ jobs:
java-version: "17"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
- name: Generate OpenAPI documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
- name: Upload OpenAPI Documentation
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]
frontend-validation:
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: Set up Node.js
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: |
cd frontend
npm ci
- name: Build frontend
run: |
cd frontend
npm run build
- name: Run frontend tests (if available)
run: |
cd frontend
npm test --passWithNoTests --watchAll=false || true
- name: Upload frontend build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: frontend-build
path: frontend/dist/
retention-days: 3
check-licence:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -171,7 +169,7 @@ jobs:
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
distribution: "adopt"
- name: check the licenses for compatibility
run: ./gradlew clean checkLicense
@@ -186,8 +184,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,7 +202,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
@@ -217,7 +213,7 @@ jobs:
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
distribution: "adopt"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
@@ -232,7 +228,6 @@ jobs:
with:
python-version: "3.12"
cache: 'pip' # caching pip dependencies
cache-dependency-path: ./testing/cucumber/requirements.txt
- name: Pip requirements
run: |
@@ -244,69 +239,3 @@ jobs:
chmod +x ./testing/test.sh
chmod +x ./testing/test_disabledEndpoints.sh
./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]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "Dockerfile.fat"]
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 17
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
gradle-version: 8.14
- name: Build application
run: ./gradlew clean build
env:
DISABLE_ADDITIONAL_FEATURES: true
STIRLING_PDF_DESKTOP_UI: false
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Build ${{ matrix.docker-rev }}
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./${{ matrix.docker-rev }}
push: false
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Upload Reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: reports-docker-${{ matrix.docker-rev }}
path: |
build/reports/tests/
build/test-results/
build/reports/problems/
retention-days: 3
if-no-files-found: warn
+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
@@ -0,0 +1,217 @@
name: Frontend License Report Workflow
on:
push:
branches:
- V2
paths:
- "frontend/package.json"
- "frontend/package-lock.json"
- "frontend/scripts/generate-licenses.js"
pull_request:
branches:
- V2
paths:
- "frontend/package.json"
- "frontend/package-lock.json"
- "frontend/scripts/generate-licenses.js"
permissions:
contents: read
jobs:
generate-frontend-license-report:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Check out code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Setup GitHub App Bot
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Node.js
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Generate frontend license report
working-directory: frontend
run: npm run generate-licenses
- name: Check for license warnings
run: |
if [ -f "frontend/src/assets/license-warnings.json" ]; then
echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
else
echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
fi
# PR Event: Check licenses and comment on PR
- name: Delete previous license check comments
if: github.event_name == 'pull_request'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = context.issue.number;
// Get all comments on the PR
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
per_page: 100
});
// Filter for license check comments
const licenseComments = comments.filter(comment =>
comment.body.includes('## ✅ Frontend License Check Passed') ||
comment.body.includes('## ❌ Frontend License Check Failed')
);
// Delete old license check comments
for (const comment of licenseComments) {
console.log(`Deleting old license check comment: ${comment.id}`);
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id
});
}
- name: Comment on PR - License Check Results
if: github.event_name == 'pull_request'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = context.issue.number;
const hasWarnings = process.env.LICENSE_WARNINGS_EXIST === 'true';
let commentBody;
if (hasWarnings) {
// Read warnings file to get specific issues
const fs = require('fs');
let warningDetails = '';
try {
const warnings = JSON.parse(fs.readFileSync('frontend/src/assets/license-warnings.json', 'utf8'));
warningDetails = warnings.warnings.map(w => `- ${w.message}`).join('\n');
} catch (e) {
warningDetails = 'Unable to read warning details';
}
commentBody = `## ❌ Frontend License Check Failed
The frontend license check has detected compatibility warnings that require review:
${warningDetails}
**Action Required:** Please review these licenses to ensure they are acceptable for your use case before merging.
_This check will fail the PR until license issues are resolved._`;
} else {
commentBody = `## ✅ Frontend License Check Passed
All frontend licenses have been validated and no compatibility warnings were detected.
The frontend license report has been updated successfully.`;
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: commentBody
});
- name: Fail workflow if license warnings exist (PR only)
if: github.event_name == 'pull_request' && env.LICENSE_WARNINGS_EXIST == 'true'
run: |
echo "❌ License warnings detected. Failing the workflow."
exit 1
# Push Event: Commit license files and create PR
- name: Commit changes (Push only)
if: github.event_name == 'push'
run: |
git add frontend/src/assets/3rdPartyLicenses.json
# Note: Do NOT commit license-warnings.json - it's only for PR review
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
- name: Prepare PR body (Push only)
if: github.event_name == 'push'
run: |
PR_BODY="Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
This PR updates the frontend license report based on changes to package.json dependencies."
if [ "${{ env.LICENSE_WARNINGS_EXIST }}" = "true" ]; then
PR_BODY="$PR_BODY
## ⚠️ License Compatibility Warnings
The following licenses may require review for corporate compatibility:
$(cat frontend/src/assets/license-warnings.json | jq -r '.warnings[].message')
Please review these licenses to ensure they are acceptable for your use case."
fi
echo "PR_BODY<<EOF" >> $GITHUB_ENV
echo "$PR_BODY" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Create Pull Request (Push only)
id: cpr
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: "Update Frontend 3rd Party Licenses"
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: update-frontend-3rd-party-licenses
base: V2
title: "Update Frontend 3rd Party Licenses"
body: ${{ env.PR_BODY }}
labels: Licenses,github-actions,frontend
draft: false
delete-branch: true
sign-commits: true
- name: Enable Pull Request Automerge (Push only)
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'false'
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
env:
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
- name: Add review required label (Push only)
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'true'
run: gh pr edit "${{ steps.cpr.outputs.pull-request-number }}" --add-label "license-review-required"
env:
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
+2 -14
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
@@ -51,7 +39,7 @@ jobs:
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
distribution: "adopt"
- 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
+7 -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
@@ -60,7 +60,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
@@ -110,7 +110,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 +148,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
@@ -238,7 +238,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 +252,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 +301,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 -14
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
@@ -38,7 +37,6 @@ jobs:
with:
python-version: 3.12
cache: 'pip' # caching pip dependencies
cache-dependency-path: ./.github/scripts/requirements_pre_commit.txt
- name: Run Pre-Commit Hooks
run: |
@@ -47,15 +45,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 .
+2 -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
@@ -54,7 +42,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"
+4 -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
@@ -83,7 +83,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 +95,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 +161,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
+1 -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
+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
+3 -25
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
@@ -37,11 +25,6 @@ jobs:
java-version: '17'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
gradle-version: 8.14
- name: Build with Gradle
run: ./gradlew clean build
env:
@@ -122,17 +105,12 @@ 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
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
cache: 'npm'
- name: Run TestDriver.ai
uses: testdriverai/action@f0d0f45fdd684db628baa843fe9313f3ca3a8aa8 #1.1.3
with:
@@ -156,7 +134,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
+7 -4
View File
@@ -27,6 +27,7 @@ clientWebUI/
!cucumber/exampleFiles/
!cucumber/exampleFiles/example_html.zip
exampleYmlFiles/stirling/
stirling/
/testing/file_snapshots
SwaggerDoc.json
@@ -124,10 +125,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
@@ -196,6 +197,8 @@ id_ed25519.pub
.pytest_cache
.ipynb_checkpoints
**/jcef-bundle/
# node_modules
+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]
+160
View File
@@ -0,0 +1,160 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Common Development Commands
### Build and Test
- **Build project**: `./gradlew clean build`
- **Run locally**: `./gradlew bootRun`
- **Full test suite**: `./test.sh` (builds all Docker variants and runs comprehensive tests)
- **Code formatting**: `./gradlew spotlessApply` (runs automatically before compilation)
### Docker Development
- **Build ultra-lite**: `docker build -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite .`
- **Build standard**: `docker build -t stirlingtools/stirling-pdf:latest -f ./Dockerfile .`
- **Build fat version**: `docker build -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .`
- **Example compose files**: Located in `exampleYmlFiles/` directory
### Security Mode Development
Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security features during development. This is required for testing the full version locally.
### Frontend Development
- **Frontend dev server**: `cd frontend && npm run dev` (requires backend on localhost:8080)
- **Tech Stack**: Vite + React + TypeScript + Mantine UI + TailwindCSS
- **Proxy Configuration**: Vite proxies `/api/*` calls to backend (localhost:8080)
- **Build Process**: DO NOT run build scripts manually - builds are handled by CI/CD pipelines
- **Package Installation**: DO NOT run npm install commands - package management handled separately
- **Deployment Options**:
- **Desktop App**: `npm run tauri-build` (native desktop application)
- **Web Server**: `npm run build` then serve dist/ folder
- **Development**: `npm run tauri-dev` for desktop dev mode
#### Multi-Tool Workflow Architecture
Frontend designed for **stateful document processing**:
- Users upload PDFs once, then chain tools (split → merge → compress → view)
- File state and processing results persist across tool switches
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
#### FileContext - Central State Management
**Location**: `src/contexts/FileContext.tsx`
- **Active files**: Currently loaded PDFs and their variants
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
- **IndexedDB persistence**: File storage with thumbnail caching
- **Preview system**: Tools can preview results (e.g., Split → Viewer → back to Split) without context pollution
**Critical**: All file operations go through FileContext. Don't bypass with direct file handling.
#### Processing Services
- **enhancedPDFProcessingService**: Background PDF parsing and manipulation
- **thumbnailGenerationService**: Web Worker-based with main-thread fallback
- **fileStorage**: IndexedDB with LRU cache management
#### Memory Management Strategy
**Why manual cleanup exists**: Large PDFs (up to 100GB+) through multiple tools accumulate:
- PDF.js documents that need explicit .destroy() calls
- Blob URLs from tool outputs that need revocation
- Web Workers that need termination
Without cleanup: browser crashes with memory leaks.
#### Tool Development
- **Pattern**: Follow `src/tools/Split.tsx` as reference implementation
- **File Access**: Tools receive `selectedFiles` prop (computed from activeFiles based on user selection)
- **File Selection**: Users select files in FileEditor (tool mode) → stored as IDs → computed to File objects for tools
- **Integration**: All files are part of FileContext ecosystem - automatic memory management and operation tracking
- **Parameters**: Tool parameter handling patterns still being standardized
- **Preview Integration**: Tools can implement preview functionality (see Split tool's thumbnail preview)
## Architecture Overview
### Project Structure
- **Backend**: Spring Boot application with Thymeleaf templating
- **Frontend**: React-based SPA in `/frontend` directory (Thymeleaf templates fully replaced)
- **File Storage**: IndexedDB for client-side file persistence and thumbnails
- **Internationalization**: JSON-based translations (converted from backend .properties)
- **PDF Processing**: PDFBox for core PDF operations, LibreOffice for conversions, PDF.js for client-side rendering
- **Security**: Spring Security with optional authentication (controlled by `DOCKER_ENABLE_SECURITY`)
- **Configuration**: YAML-based configuration with environment variable overrides
### Controller Architecture
- **API Controllers** (`src/main/java/.../controller/api/`): REST endpoints for PDF operations
- Organized by function: converters, security, misc, pipeline
- Follow pattern: `@RestController` + `@RequestMapping("/api/v1/...")`
- **Web Controllers** (`src/main/java/.../controller/web/`): Serve Thymeleaf templates
- Pattern: `@Controller` + return template names
### Key Components
- **SPDFApplication.java**: Main application class with desktop UI and browser launching logic
- **ConfigInitializer**: Handles runtime configuration and settings files
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
- **Security Layer**: Authentication, authorization, and user management (when enabled)
### Component Architecture
- **React Components**: Located in `frontend/src/components/` and `frontend/src/tools/`
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
- **Internationalization**:
- Backend: `messages_*.properties` files
- Frontend: JSON files in `frontend/public/locales/` (converted from .properties)
- Conversion Script: `scripts/convert_properties_to_json.py`
### Configuration Modes
- **Ultra-lite**: Basic PDF operations only
- **Standard**: Full feature set
- **Fat**: Pre-downloaded dependencies for air-gapped environments
- **Security Mode**: Adds authentication, user management, and enterprise features
### Testing Strategy
- **Integration Tests**: Cucumber tests in `testing/cucumber/`
- **Docker Testing**: `test.sh` validates all Docker variants
- **Manual Testing**: No unit tests currently - relies on UI and API testing
## Development Workflow
1. **Local Development**:
- Backend: `./gradlew bootRun` (runs on localhost:8080)
- Frontend: `cd frontend && npm run dev` (runs on localhost:5173, proxies to backend)
2. **Docker Testing**: Use `./test.sh` before submitting PRs
3. **Code Style**: Spotless enforces Google Java Format automatically
4. **Translations**:
- Backend: Use helper scripts in `/scripts` for multi-language updates
- Frontend: Update JSON files in `frontend/public/locales/` or use conversion script
5. **Documentation**: API docs auto-generated and available at `/swagger-ui/index.html`
## Frontend Architecture Status
- **Core Status**: React SPA architecture complete with multi-tool workflow support
- **State Management**: FileContext handles all file operations and tool navigation
- **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+)
- **Tool Integration**: Standardized tool interface - see `src/tools/Split.tsx` as reference
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
## Important Notes
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21
- **Lombok**: Used extensively - ensure IDE plugin is installed
- **Desktop Mode**: Set `STIRLING_PDF_DESKTOP_UI=true` for desktop application mode
- **File Persistence**:
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
- **Tool Development**: New tools should follow Split tool pattern (`src/tools/Split.tsx`)
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation)
## Communication Style
- Be direct and to the point
- No apologies or conversational filler
- Answer questions directly without preamble
- Explain reasoning concisely when asked
- Avoid unnecessary elaboration
## Decision Making
- Ask clarifying questions before making assumptions
- Stop and ask when uncertain about project-specific details
- Confirm approach before making structural changes
- Request guidance on preferences (cross-platform vs specific tools, etc.)
- Verify understanding of requirements before proceeding
+692
View File
@@ -0,0 +1,692 @@
# Stirling-PDF Developer Guide
## 1. Introduction
Stirling-PDF is a robust, locally hosted, web-based PDF manipulation tool. **Stirling 2.0** represents a complete frontend rewrite, replacing the legacy Thymeleaf-based UI with a modern React SPA (Single Page Application).
This guide focuses on developing for Stirling 2.0, including both the React frontend and Spring Boot backend development workflows.
## 2. Project Overview
**Stirling 2.0** is built using:
**Backend:**
- Spring Boot (Java 17+, JDK 21 recommended)
- PDFBox for core PDF operations
- LibreOffice for document conversions
- qpdf for PDF optimization
- Spring Security (optional, controlled by `DOCKER_ENABLE_SECURITY`)
- Lombok for reducing boilerplate code
**Frontend (React SPA):**
- React + TypeScript
- Vite for build tooling and development server
- Mantine UI component library
- TailwindCSS for styling
- PDF.js for client-side PDF rendering
- PDF-LIB.js for client-side PDF manipulation
- IndexedDB for client-side file storage and thumbnails
- i18next for internationalization
**Infrastructure:**
- Docker for containerization
- Gradle for build management
**Legacy (reference only during development):**
- Thymeleaf templates (being completely replaced in 2.0)
## 3. Development Environment Setup
### Prerequisites
- Docker
- Git
- Java JDK 17 or later (JDK 21 recommended)
- Node.js 18+ and npm (required for frontend development)
- Gradle 7.0 or later (Included within the repo)
### Setup Steps
1. Clone the repository:
```bash
git clone https://github.com/Stirling-Tools/Stirling-PDF.git
cd Stirling-PDF
```
2. Install Docker and JDK17 if not already installed.
3. Install a recommended Java IDE such as Eclipse, IntelliJ, or VSCode
1. Only VSCode
1. Open VS Code.
2. When prompted, install the recommended extensions.
3. Alternatively, open the command palette (`Ctrl + Shift + P` or `Cmd + Shift + P` on macOS) and run:
```sh
Extensions: Show Recommended Extensions
```
4. Install the required extensions from the list.
4. Lombok Setup
Stirling-PDF uses Lombok to reduce boilerplate code. Some IDEs, like Eclipse, don't support Lombok out of the box. To set up Lombok in your development environment:
Visit the [Lombok website](https://projectlombok.org/setup/) for installation instructions specific to your IDE.
5. Add environment variable
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step.
5. **Frontend Setup (Required for Stirling 2.0)**
Navigate to the frontend directory and install dependencies using npm.
## 4. Stirling 2.0 Development Workflow
### Frontend Development (React)
The frontend is a React SPA that runs independently during development:
1. **Start the backend**: Run the Spring Boot application (serves API endpoints on localhost:8080)
2. **Start the frontend dev server**: Navigate to the frontend directory and run the development server (serves UI on localhost:5173)
3. **Development flow**: The Vite dev server automatically proxies API calls to the backend
### File Storage Architecture
Stirling 2.0 uses client-side file storage:
- **IndexedDB**: Stores files locally in the browser with automatic thumbnail generation
- **PDF.js**: Handles client-side PDF rendering and processing
- **URL Parameters**: Support for deep linking and tool state persistence
### Legacy Code Reference
The existing Thymeleaf templates remain in the codebase during development as reference material but will be completely removed for the 2.0 release.
## 5. Project Structure
```bash
Stirling-PDF/
├── .github/ # GitHub-specific files (workflows, issue templates)
├── configs/ # Configuration files used by stirling at runtime (generated at runtime)
├── frontend/ # React SPA frontend (Stirling 2.0)
│ ├── src/
│ │ ├── components/ # React components
│ │ ├── tools/ # Tool-specific React components
│ │ ├── hooks/ # Custom React hooks
│ │ ├── services/ # API and utility services
│ │ ├── types/ # TypeScript type definitions
│ │ └── utils/ # Utility functions
│ ├── public/
│ │ └── locales/ # Internationalization files (JSON)
│ ├── package.json # Frontend dependencies
│ └── vite.config.ts # Vite configuration
├── customFiles/ # Custom static files and templates (generated at runtime used to replace existing files)
├── docs/ # Documentation files
├── exampleYmlFiles/ # Example YAML configuration files
├── images/ # Image assets
├── pipeline/ # Pipeline-related files (generated at runtime)
├── scripts/ # Utility scripts
├── src/ # Source code
│ ├── main/
│ │ ├── java/
│ │ │ └── stirling/
│ │ │ └── software/
│ │ │ └── SPDF/
│ │ │ ├── config/
│ │ │ ├── controller/
│ │ │ ├── model/
│ │ │ ├── repository/
│ │ │ ├── service/
│ │ │ └── utils/
│ │ └── resources/
│ │ ├── static/ # Legacy static assets (reference only)
│ │ │ ├── css/
│ │ │ ├── js/
│ │ │ └── pdfjs/
│ │ └── templates/ # Legacy Thymeleaf templates (reference only)
│ └── test/
├── testing/ # Cucumber and integration tests
│ └── cucumber/ # Cucumber test files
├── build.gradle # Gradle build configuration
├── Dockerfile # Main Dockerfile
├── Dockerfile.ultra-lite # Dockerfile for ultra-lite version
├── Dockerfile.fat # Dockerfile for fat version
├── docker-compose.yml # Docker Compose configuration
└── test.sh # Test script to deploy all docker versions and run cuke tests
```
## 6. Docker-based Development
Stirling-PDF offers several Docker versions:
- Full: All features included
- Ultra-Lite: Basic PDF operations only
- Fat: Includes additional libraries and fonts predownloaded
### Example Docker Compose Files
Stirling-PDF provides several example Docker Compose files in the `exampleYmlFiles` directory, such as:
- `docker-compose-latest.yml`: Latest version without login and security features
- `docker-compose-latest-security.yml`: Latest version with login and security features enabled
- `docker-compose-latest-fat-security.yml`: Fat version with login and security features enabled
These files provide pre-configured setups for different scenarios. For example, here's a snippet from `docker-compose-latest-security.yml`:
```yaml
services:
stirling-pdf:
container_name: Stirling-PDF-Security
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest
deploy:
resources:
limits:
memory: 4G
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -q 'Please sign in'"]
interval: 5s
timeout: 10s
retries: 16
ports:
- "8080:8080"
volumes:
- ./stirling/latest/data:/usr/share/tessdata:rw
- ./stirling/latest/config:/configs:rw
- ./stirling/latest/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "false"
SECURITY_ENABLELOGIN: "true"
PUID: 1002
PGID: 1002
UMASK: "022"
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: Stirling-PDF
UI_HOMEDESCRIPTION: Demo site for Stirling-PDF Latest with Security
UI_APPNAMENAVBAR: Stirling-PDF Latest
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "true"
SHOW_SURVEY: "true"
restart: on-failure:5
```
To use these example files, copy the desired file to your project root and rename it to `docker-compose.yml`, or specify the file explicitly when running Docker Compose:
```bash
docker-compose -f exampleYmlFiles/docker-compose-latest-security.yml up
```
### Building Docker Images
Stirling-PDF uses different Docker images for various configurations. The build process is controlled by environment variables and uses specific Dockerfile variants. Here's how to build the Docker images:
1. Set the security environment variable:
```bash
export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds
```
2. Build the project with Gradle:
```bash
./gradlew clean build
```
3. Build the Docker images:
For the latest version:
```bash
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest -f ./Dockerfile .
```
For the ultra-lite version:
```bash
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite .
```
For the fat version (with login and security features enabled):
```bash
export DISABLE_ADDITIONAL_FEATURES=false
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .
```
Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. however to improve build times these can often be removed depending on your usecase
## 7. Testing
### Comprehensive Testing Script
Stirling-PDF provides a `test.sh` script in the root directory. This script builds all versions of Stirling-PDF, checks that each version works, and runs Cucumber tests. It's recommended to run this script before submitting a final pull request.
To run the test script:
```bash
./test.sh
```
This script performs the following actions:
1. Builds all Docker images (full, ultra-lite, fat).
2. Runs each version to ensure it starts correctly.
3. Executes Cucumber tests against the main version and ensures feature compatibility. In the event these tests fail, your PR will not be merged.
Note: The `test.sh` script will run automatically when you raise a PR. However, it's recommended to run it locally first to save resources and catch any issues early.
### Full Testing with Docker
1. Build and run the Docker container per the above instructions:
2. Access the application at `http://localhost:8080` and manually test all features developed.
### Frontend Development Testing (Stirling 2.0)
For React frontend development:
1. Start the backend: Run the Spring Boot application to serve API endpoints on localhost:8080
2. Start the frontend dev server: Navigate to the frontend directory and run the development server on localhost:5173
3. The Vite dev server automatically proxies API calls to the backend
4. Test React components, UI interactions, and IndexedDB file operations using browser developer tools
### Local Testing (Java and UI Components)
For quick iterations and development of Java backend, JavaScript, and UI components, you can run and test Stirling-PDF locally without Docker. This approach allows you to work on and verify changes to:
- Java backend logic
- RESTful API endpoints
- JavaScript functionality
- User interface components and styling
- Thymeleaf templates
To run Stirling-PDF locally:
1. Compile and run the project using built-in IDE methods or by running:
```bash
./gradlew bootRun
```
2. Access the application at `http://localhost:8080` in your web browser.
3. Manually test the features you're working on through the UI.
4. For API changes, use tools like Postman or curl to test endpoints directly.
Important notes:
- Local testing doesn't include features that depend on external tools like qpdf, LibreOffice, or Python scripts.
- There are currently no automated unit tests. All testing is done manually through the UI or API calls. (You are welcome to add JUnits!)
- Always verify your changes in the full Docker environment before submitting pull requests, as some integrations and features will only work in the complete setup.
## 8. Contributing
1. Fork the repository on GitHub.
2. Create a new branch for your feature or bug fix.
3. Make your changes and commit them with clear, descriptive messages and ensure any documentation is updated related to your changes.
4. Test your changes thoroughly in the Docker environment.
5. Run the `test.sh` script to ensure all versions build correctly and pass the Cucumber tests:
```bash
./test.sh
```
6. Push your changes to your fork.
7. Submit a pull request to the main repository.
8. See additional [contributing guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md).
When you raise a PR:
- The `test.sh` script will run automatically against your PR.
- The PR checks will verify versioning and dependency updates.
- Documentation will be automatically updated for dependency changes.
- Security issues will be checked using Snyk and PixeeBot.
Address any issues that arise from these checks before finalizing your pull request.
## 9. API Documentation
API documentation is available at `/swagger-ui/index.html` when running the application. You can also view the latest API documentation [here](https://app.swaggerhub.com/apis-docs/Stirling-Tools/Stirling-PDF/).
## 10. Customization
Stirling-PDF can be customized through environment variables or a `settings.yml` file. Key customization options include:
- Application name and branding
- Security settings
- UI customization
- Endpoint management
When using Docker, pass environment variables using the `-e` flag or in your `docker-compose.yml` file.
Example:
```bash
docker run -p 8080:8080 -e APP_NAME="My PDF Tool" stirling-pdf:full
```
Refer to the main README for a full list of customization options.
## 11. Language Translations
For managing language translations that affect multiple files, Stirling-PDF provides a helper script:
```bash
/scripts/replace_translation_line.sh
```
This script helps you make consistent replacements across language files.
When contributing translations:
1. Use the helper script for multi-file changes.
2. Ensure all language files are updated consistently.
3. The PR checks will verify consistency in language file updates.
Remember to test your changes thoroughly to ensure they don't break any existing functionality.
## Code examples
### React Component Development (Stirling 2.0)
For Stirling 2.0, new features are built as React components instead of Thymeleaf templates:
#### Creating a New Tool Component
1. **Create the React Component:**
```typescript
// frontend/src/tools/NewTool.tsx
import { useState } from 'react';
import { Button, FileInput, Container } from '@mantine/core';
interface NewToolProps {
params: Record<string, any>;
updateParams: (updates: Record<string, any>) => void;
}
export default function NewTool({ params, updateParams }: NewToolProps) {
const [files, setFiles] = useState<File[]>([]);
const handleProcess = async () => {
// Process files using API or client-side logic
};
return (
<Container>
<FileInput
multiple
accept="application/pdf"
onChange={setFiles}
/>
<Button onClick={handleProcess}>Process</Button>
</Container>
);
}
```
2. **Add API Integration:**
```typescript
// Use existing API endpoints or create new ones
const response = await fetch('/api/v1/new-tool', {
method: 'POST',
body: formData
});
```
3. **Register in Tool Picker:**
Update the tool picker component to include the new tool with proper routing and URL parameter support.
### Legacy Reference: Overview of Thymeleaf
Thymeleaf is a server-side Java HTML template engine. It is used in Stirling-PDF to render dynamic web pages. Thymeleaf integrates heavily with Spring Boot.
### Thymeleaf overview
In Stirling-PDF, Thymeleaf is used to create HTML templates that are rendered on the server side. These templates are located in the `stirling-pdf/src/main/resources/templates` directory. Thymeleaf templates use a combination of HTML and special Thymeleaf attributes to dynamically generate content.
Some examples of this are:
```html
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
```
or
```html
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
```
Where it uses the `th:block`, `th:` indicating it's a special Thymeleaf element to be used server-side in generating the HTML, and block being the actual element type.
In this case, we are inserting the `navbar` entry within the `fragments/navbar.html` fragment into the `th:block` element.
They can be more complex, such as:
```html
<th:block th:insert="~{fragments/common :: head(title=#{pageExtracter.title}, header=#{pageExtracter.header})}"></th:block>
```
Which is the same as above but passes the parameters title and header into the fragment `common.html` to be used in its HTML generation.
Thymeleaf can also be used to loop through objects or pass things from the Java side into the HTML side.
```java
@GetMapping
public String newFeaturePage(Model model) {
model.addAttribute("exampleData", exampleData);
return "new-feature";
}
```
In the above example, if exampleData is a list of plain java objects of class Person and within it, you had id, name, age, etc. You can reference it like so
```html
<tbody>
<!-- Use th:each to iterate over the list -->
<tr th:each="person : ${exampleData}">
<td th:text="${person.id}"></td>
<td th:text="${person.name}"></td>
<td th:text="${person.age}"></td>
<td th:text="${person.email}"></td>
</tr>
</tbody>
```
This would generate n entries of tr for each person in exampleData
### Adding a New Feature to the Backend (API)
1. **Create a New Controller:**
- Create a new Java class in the `stirling-pdf/src/main/java/stirling/software/SPDF/controller/api` directory.
- Annotate the class with `@RestController` and `@RequestMapping` to define the API endpoint.
- Ensure to add API documentation annotations like `@Tag(name = "General", description = "General APIs")` and `@Operation(summary = "Crops a PDF document", description = "This operation takes an input PDF file and crops it according to the given coordinates. Input:PDF Output:PDF Type:SISO")`.
```java
package stirling.software.SPDF.controller.api;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@RestController
@RequestMapping("/api/v1/new-feature")
@Tag(name = "General", description = "General APIs")
public class NewFeatureController {
@GetMapping
@Operation(summary = "New Feature", description = "This is a new feature endpoint.")
public String newFeature() {
return "NewFeatureResponse"; // This refers to the NewFeatureResponse.html template presenting the user with the generated html from that file when they navigate to /api/v1/new-feature
}
}
```
2. **Define the Service Layer:** (Not required but often useful)
- Create a new service class in the `stirling-pdf/src/main/java/stirling/software/SPDF/service` directory.
- Implement the business logic for the new feature.
```java
package stirling.software.SPDF.service;
import org.springframework.stereotype.Service;
@Service
public class NewFeatureService {
public String getNewFeatureData() {
// Implement business logic here
return "New Feature Data";
}
}
```
2b. **Integrate the Service with the Controller:**
- Autowire the service class in the controller and use it to handle the API request.
```java
package stirling.software.SPDF.controller.api;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import stirling.software.SPDF.service.NewFeatureService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@RestController
@RequestMapping("/api/v1/new-feature")
@Tag(name = "General", description = "General APIs")
public class NewFeatureController {
@Autowired
private NewFeatureService newFeatureService;
@GetMapping
@Operation(summary = "New Feature", description = "This is a new feature endpoint.")
public String newFeature() {
return newFeatureService.getNewFeatureData();
}
}
```
### Adding a New Feature to the Frontend (UI)
1. **Create a New Thymeleaf Template:**
- Create a new HTML file in the `stirling-pdf/src/main/resources/templates` directory.
- Use Thymeleaf attributes to dynamically generate content.
- Use `extract-page.html` as a base example for the HTML template, which is useful to ensure importing of the general layout, navbar, and footer.
```html
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title=#{newFeature.title}, header=#{newFeature.header})}"></th:block>
</head>
<body>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon organize">upload</span>
<span class="tool-header-text" th:text="#{newFeature.header}"></span>
</div>
<form th:action="@{'/api/v1/new-feature'}" method="post" enctype="multipart/form-data">
<div th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='application/pdf')}"></div>
<input type="hidden" id="customMode" name="customMode" value="">
<div class="mb-3">
<label for="featureInput" th:text="#{newFeature.prompt}"></label>
<input type="text" class="form-control" id="featureInput" name="featureInput" th:placeholder="#{newFeature.placeholder}" required>
</div>
<button type="submit" id="submitBtn" class="btn btn-primary" th:text="#{newFeature.submit}"></button>
</form>
</div>
</div>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>
```
2. **Create a New Controller for the UI:**
- Create a new Java class in the `stirling-pdf/src/main/java/stirling/software/SPDF/controller/ui` directory.
- Annotate the class with `@Controller` and `@RequestMapping` to define the UI endpoint.
```java
package stirling.software.SPDF.controller.ui;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import stirling.software.SPDF.service.NewFeatureService;
@Controller
@RequestMapping("/new-feature")
public class NewFeatureUIController {
@Autowired
private NewFeatureService newFeatureService;
@GetMapping
public String newFeaturePage(Model model) {
model.addAttribute("newFeatureData", newFeatureService.getNewFeatureData());
return "new-feature";
}
}
```
3. **Update the Navigation Bar:**
- Add a link to the new feature page in the navigation bar.
- Update the `stirling-pdf/src/main/resources/templates/fragments/navbar.html` file.
```html
<li class="nav-item">
<a class="nav-link" th:href="@{'/new-feature'}">New Feature</a>
</li>
```
## Adding New Translations to Existing Language Files in Stirling-PDF
When adding a new feature or modifying existing ones in Stirling-PDF, you'll need to add new translation entries to the existing language files. Here's a step-by-step guide:
### 1. Locate Existing Language Files
Find the existing `messages.properties` files in the `stirling-pdf/src/main/resources` directory. You'll see files like:
- `messages.properties` (default, usually English)
- `messages_en_GB.properties`
- `messages_fr_FR.properties`
- `messages_de_DE.properties`
- etc.
### 2. Add New Translation Entries
Open each of these files and add your new translation entries. For example, if you're adding a new feature called "PDF Splitter",
Use descriptive, hierarchical keys (e.g., `feature.element.description`)
you might add:
```properties
pdfSplitter.title=PDF Splitter
pdfSplitter.description=Split your PDF into multiple documents
pdfSplitter.button.split=Split PDF
pdfSplitter.input.pages=Enter page numbers to split
```
Add these entries to the default GB language file and any others you wish, translating the values as appropriate for each language.
### 3. Use Translations in Thymeleaf Templates
In your Thymeleaf templates, use the `#{key}` syntax to reference the new translations:
```html
<h1 th:text="#{pdfSplitter.title}">PDF Splitter</h1>
<p th:text="#{pdfSplitter.description}">Split your PDF into multiple documents</p>
<input type="text" th:placeholder="#{pdfSplitter.input.pages}">
<button th:text="#{pdfSplitter.button.split}">Split PDF</button>
```
Remember, never hard-code text in your templates or Java code. Always use translation keys to ensure proper localization.
-60
View File
@@ -1,60 +0,0 @@
# dockerfile.dev
# Basisimage: Gradle mit JDK 17 (Debian-basiert)
FROM gradle:8.14-jdk17
# Als Root-Benutzer arbeiten, um benötigte Pakete zu installieren
USER root
# Set GRADLE_HOME und füge Gradle zum PATH hinzu
ENV GRADLE_HOME=/opt/gradle
ENV PATH="$GRADLE_HOME/bin:$PATH"
# Update und Installation zusätzlicher Pakete (Debian/Ubuntu-basiert)
RUN apt-get update && apt-get install -y \
sudo \
libreoffice \
poppler-utils \
qpdf \
# settings.yml | tessdataDir: /usr/share/tesseract-ocr/5/tessdata
tesseract-ocr \
tesseract-ocr-eng \
fonts-terminus fonts-dejavu fonts-font-awesome fonts-noto fonts-noto-core fonts-noto-cjk fonts-noto-extra fonts-liberation fonts-linuxlibertine fonts-urw-base35 \
python3-uno \
python3-venv \
# ss -tln
iproute2 \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Setze die Environment Variable für setuptools
ENV SETUPTOOLS_USE_DISTUTILS=local \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# 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 --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
ENV PATH="/opt/venv/bin:$PATH"
COPY . /workspace
RUN mkdir -p /tmp/stirling-pdf \
&& fc-cache -f -v \
&& adduser --disabled-password --gecos '' devuser \
&& chown -R devuser:devuser /home/devuser /workspace /tmp/stirling-pdf
RUN echo "devuser ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/devuser \
&& chmod 0440 /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
# Wechsel zum NichtRoot Benutzer
USER devuser
+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) | ![70%](https://geps.dev/progress/70) |
| 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) |
+2 -14
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'
@@ -38,7 +26,7 @@ dependencies {
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
api 'org.snakeyaml:snakeyaml-engine:2.10'
api 'org.snakeyaml:snakeyaml-engine:2.9'
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9"
api 'jakarta.mail:jakarta.mail-api:2.1.3'
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.3'
@@ -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/**/*
+4 -16
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,10 +43,10 @@ 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'
implementation 'io.micrometer:micrometer-core:1.15.1'
implementation 'com.google.zxing:core:3.5.3'
implementation "org.commonmark:commonmark:$commonmarkVersion" // https://mvnrepository.com/artifact/org.commonmark/commonmark
implementation "org.commonmark:commonmark-ext-gfm-tables:$commonmarkVersion"
@@ -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);
@@ -1,7 +1,6 @@
package stirling.software.SPDF.controller.api.converters;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.URLConnection;
@@ -88,7 +87,7 @@ public class ConvertImgPDFController {
// returns bytes for image
boolean singleImage = "single".equals(singleOrMultiple);
String filename =
Filenames.toSimpleFileName(new File(file.getOriginalFilename()).getName())
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
result =
@@ -117,14 +116,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 +231,7 @@ public class ConvertImgPDFController {
PdfUtils.imageToPdf(file, fitOption, autoRotate, colorType, pdfDocumentFactory);
return WebResponseUtils.bytesToWebResponse(
bytes,
new File(file[0].getOriginalFilename()).getName().replaceFirst("[.][^.]+$", "")
+ "_converted.pdf");
file[0].getOriginalFilename().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");
@@ -0,0 +1,129 @@
package stirling.software.SPDF.controller.api.misc;
import java.util.HashMap;
import java.util.Map;
import org.springframework.context.ApplicationContext;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
@RestController
@Tag(name = "Config", description = "Configuration APIs")
@RequestMapping("/api/v1/config")
@RequiredArgsConstructor
@Hidden
public class ConfigController {
private final ApplicationProperties applicationProperties;
private final ApplicationContext applicationContext;
private final EndpointConfiguration endpointConfiguration;
@GetMapping("/app-config")
public ResponseEntity<Map<String, Object>> getAppConfig() {
Map<String, Object> configData = new HashMap<>();
try {
// Get AppConfig bean
AppConfig appConfig = applicationContext.getBean(AppConfig.class);
// Extract key configuration values from AppConfig
configData.put("baseUrl", appConfig.getBaseUrl());
configData.put("contextPath", appConfig.getContextPath());
configData.put("serverPort", appConfig.getServerPort());
// Extract values from ApplicationProperties
configData.put("appName", applicationProperties.getUi().getAppName());
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
configData.put("homeDescription", applicationProperties.getUi().getHomeDescription());
configData.put("languages", applicationProperties.getUi().getLanguages());
// Security settings
configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin());
// System settings
configData.put(
"enableAlphaFunctionality",
applicationProperties.getSystem().getEnableAlphaFunctionality());
configData.put(
"enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
// Premium/Enterprise settings
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
// Legal settings
configData.put(
"termsAndConditions", applicationProperties.getLegal().getTermsAndConditions());
configData.put("privacyPolicy", applicationProperties.getLegal().getPrivacyPolicy());
configData.put("cookiePolicy", applicationProperties.getLegal().getCookiePolicy());
configData.put("impressum", applicationProperties.getLegal().getImpressum());
configData.put(
"accessibilityStatement",
applicationProperties.getLegal().getAccessibilityStatement());
// Try to get EEAppConfig values if available
try {
if (applicationContext.containsBean("runningProOrHigher")) {
configData.put(
"runningProOrHigher",
applicationContext.getBean("runningProOrHigher", Boolean.class));
}
if (applicationContext.containsBean("runningEE")) {
configData.put(
"runningEE", applicationContext.getBean("runningEE", Boolean.class));
}
if (applicationContext.containsBean("license")) {
configData.put("license", applicationContext.getBean("license", String.class));
}
if (applicationContext.containsBean("GoogleDriveEnabled")) {
configData.put(
"GoogleDriveEnabled",
applicationContext.getBean("GoogleDriveEnabled", Boolean.class));
}
if (applicationContext.containsBean("SSOAutoLogin")) {
configData.put(
"SSOAutoLogin",
applicationContext.getBean("SSOAutoLogin", Boolean.class));
}
} catch (Exception e) {
// EE features not available, continue without them
}
return ResponseEntity.ok(configData);
} catch (Exception e) {
// Return basic config if there are any issues
configData.put("error", "Unable to retrieve full configuration");
return ResponseEntity.ok(configData);
}
}
@GetMapping("/endpoint-enabled")
public ResponseEntity<Boolean> isEndpointEnabled(@RequestParam String endpoint) {
boolean enabled = endpointConfiguration.isEndpointEnabled(endpoint);
return ResponseEntity.ok(enabled);
}
@GetMapping("/endpoints-enabled")
public ResponseEntity<Map<String, Boolean>> areEndpointsEnabled(
@RequestParam String endpoints) {
Map<String, Boolean> result = new HashMap<>();
String[] endpointArray = endpoints.split(",");
for (String endpoint : endpointArray) {
String trimmedEndpoint = endpoint.trim();
result.put(trimmedEndpoint, endpointConfiguration.isEndpointEnabled(trimmedEndpoint));
}
return ResponseEntity.ok(result);
}
}
@@ -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",
@@ -33,7 +33,7 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.misc.ScannerEffectRequest;
import stirling.software.SPDF.model.api.misc.FakeScanRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.WebResponseUtils;
@@ -42,7 +42,7 @@ import stirling.software.common.util.WebResponseUtils;
@Tag(name = "Misc", description = "Miscellaneous PDF APIs")
@RequiredArgsConstructor
@Slf4j
public class ScannerEffectController {
public class FakeScanController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private static final Random RANDOM = new Random();
@@ -52,12 +52,12 @@ public class ScannerEffectController {
private static final int MAX_IMAGE_HEIGHT = 8192;
private static final long MAX_IMAGE_PIXELS = 16_777_216; // 4096x4096
@PostMapping(value = "/scanner-effect", consumes = "multipart/form-data")
@PostMapping(value = "/fake-scan", consumes = "multipart/form-data")
@Operation(
summary = "Apply scanner effect to PDF",
summary = "Convert PDF to look like a scanned document",
description =
"Applies various effects to simulate a scanned document, including rotation, noise, and edge softening. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> scannerEffect(@Valid @ModelAttribute ScannerEffectRequest request)
"Applies various effects to make a PDF look like it was scanned, including rotation, noise, and edge softening. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> fakeScan(@Valid @ModelAttribute FakeScanRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
@@ -80,7 +80,7 @@ public class ScannerEffectController {
float noise = request.getNoise();
boolean yellowish = request.isYellowish();
int resolution = request.getResolution();
ScannerEffectRequest.Colorspace colorspace = request.getColorspace();
FakeScanRequest.Colorspace colorspace = request.getColorspace();
try (PDDocument document = pdfDocumentFactory.load(file)) {
PDDocument outputDocument = new PDDocument();
@@ -130,7 +130,7 @@ public class ScannerEffectController {
// 1. Convert to grayscale or keep color
BufferedImage processed;
if (colorspace == ScannerEffectRequest.Colorspace.grayscale) {
if (colorspace == FakeScanRequest.Colorspace.grayscale) {
processed =
new BufferedImage(
image.getWidth(),
@@ -316,7 +316,7 @@ public class ScannerEffectController {
String outputFilename =
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "")
+ "_scanner_effect.pdf";
+ "_scanned.pdf";
return WebResponseUtils.bytesToWebResponse(
outputStream.toByteArray(), outputFilename, MediaType.APPLICATION_PDF);
@@ -7,7 +7,6 @@ import java.awt.print.Printable;
import java.awt.print.PrinterException;
import java.awt.print.PrinterJob;
import java.io.IOException;
import java.nio.file.Paths;
import java.util.Arrays;
import javax.imageio.ImageIO;
@@ -46,11 +45,6 @@ public class PrintFileController {
public ResponseEntity<String> printFile(@ModelAttribute PrintFileRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
String originalFilename = file.getOriginalFilename();
if (originalFilename != null
&& (originalFilename.contains("..") || Paths.get(originalFilename).isAbsolute())) {
throw new IOException("Invalid file path detected: " + originalFilename);
}
String printerName = request.getPrinterName();
String contentType = file.getContentType();
try {
@@ -62,26 +62,9 @@ public class StampController {
public ResponseEntity<byte[]> addStamp(@ModelAttribute AddStampRequest request)
throws IOException, Exception {
MultipartFile pdfFile = request.getFileInput();
String pdfFileName = pdfFile.getOriginalFilename();
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 alphabet = request.getAlphabet();
float fontSize = request.getFontSize();
float rotation = request.getRotation();
@@ -108,13 +108,7 @@ 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);
}
// List outputFileTypes = apiDocService.getExtensionTypes(true, operation);
String url = getBaseUrl() + operation;
List<Resource> newOutputFiles = new ArrayList<>();
if (!isMultiInputOperation) {
@@ -140,7 +134,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;
@@ -333,11 +327,6 @@ public class PipelineProcessor {
}
List<Resource> outputFiles = new ArrayList<>();
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());
}
Path path = Paths.get(file.getAbsolutePath());
// debug statement
log.info("Reading file: " + path);
@@ -74,21 +74,9 @@ public class WatermarkController {
public ResponseEntity<byte[]> addWatermark(@ModelAttribute AddWatermarkRequest request)
throws IOException, Exception {
MultipartFile pdfFile = request.getFileInput();
String pdfFileName = pdfFile.getOriginalFilename();
if (pdfFileName != null && (pdfFileName.contains("..") || pdfFileName.startsWith("/"))) {
throw new SecurityException("Invalid file path in pdfFile");
}
String watermarkType = request.getWatermarkType();
String watermarkText = request.getWatermarkText();
MultipartFile watermarkImage = request.getWatermarkImage();
if (watermarkImage != null) {
String watermarkImageFileName = watermarkImage.getOriginalFilename();
if (watermarkImageFileName != null
&& (watermarkImageFileName.contains("..")
|| watermarkImageFileName.startsWith("/"))) {
throw new SecurityException("Invalid file path in watermarkImage");
}
}
String alphabet = request.getAlphabet();
float fontSize = request.getFontSize();
float rotation = request.getRotation();
@@ -70,11 +70,11 @@ public class OtherWebController {
return "misc/add-page-numbers";
}
@GetMapping("/scanner-effect")
@GetMapping("/fake-scan")
@Hidden
public String scannerEffectForm(Model model) {
model.addAttribute("currentPage", "scanner-effect");
return "misc/scanner-effect";
public String fakeScanForm(Model model) {
model.addAttribute("currentPage", "fake-scan");
return "misc/fake-scan";
}
@GetMapping("/extract-images")
@@ -0,0 +1,18 @@
package stirling.software.SPDF.controller.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class ReactRoutingController {
@GetMapping("/{path:^(?!api|static|robots\\.txt|favicon\\.ico)[^\\.]*$}")
public String forwardRootPaths() {
return "forward:/index.html";
}
@GetMapping("/{path:^(?!api|static)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public String forwardNestedPaths() {
return "forward:/index.html";
}
}
@@ -11,7 +11,7 @@ import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode
public class ScannerEffectRequest {
public class FakeScanRequest {
public enum Quality {
low,
medium,
@@ -1811,41 +1811,41 @@ cookieBanner.preferencesModal.necessary.description=These cookies are essential
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=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
#fakeScan
fakeScan.title=Fake Scan
fakeScan.header=Fake Scan
fakeScan.description=Create a PDF that looks like it was scanned
fakeScan.selectPDF=Select PDF:
fakeScan.quality=Scan Quality
fakeScan.quality.low=Low
fakeScan.quality.medium=Medium
fakeScan.quality.high=High
fakeScan.rotation=Rotation Angle
fakeScan.rotation.none=None
fakeScan.rotation.slight=Slight
fakeScan.rotation.moderate=Moderate
fakeScan.rotation.severe=Severe
fakeScan.submit=Create Fake Scan
#home.scannerEffect
home.scannerEffect.title=Scanner Effect
home.scannerEffect.desc=Create a PDF that looks like it was scanned
scannerEffect.tags=scan,simulate,realistic,convert
#home.fakeScan
home.fakeScan.title=Fake Scan
home.fakeScan.desc=Create a PDF that looks like it was scanned
fakeScan.tags=scan,simulate,realistic,convert
# ScannerEffect advanced settings (frontend)
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)
# FakeScan advanced settings (frontend)
fakeScan.advancedSettings=Enable Advanced Scan Settings
fakeScan.colorspace=Colorspace
fakeScan.colorspace.grayscale=Grayscale
fakeScan.colorspace.color=Color
fakeScan.border=Border (px)
fakeScan.rotate=Base Rotation (degrees)
fakeScan.rotateVariance=Rotation Variance (degrees)
fakeScan.brightness=Brightness
fakeScan.contrast=Contrast
fakeScan.blur=Blur
fakeScan.noise=Noise
fakeScan.yellowish=Yellowish (simulate old paper)
fakeScan.resolution=Resolution (DPI)
# Table of Contents Feature
@@ -1811,41 +1811,41 @@ cookieBanner.preferencesModal.necessary.description=These cookies are essential
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=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
#fakeScan
fakeScan.title=Fake Scan
fakeScan.header=Fake Scan
fakeScan.description=Create a PDF that looks like it was scanned
fakeScan.selectPDF=Select PDF:
fakeScan.quality=Scan Quality
fakeScan.quality.low=Low
fakeScan.quality.medium=Medium
fakeScan.quality.high=High
fakeScan.rotation=Rotation Angle
fakeScan.rotation.none=None
fakeScan.rotation.slight=Slight
fakeScan.rotation.moderate=Moderate
fakeScan.rotation.severe=Severe
fakeScan.submit=Create Fake Scan
#home.scannerEffect
home.scannerEffect.title=Scanner Effect
home.scannerEffect.desc=Create a PDF that looks like it was scanned
scannerEffect.tags=scan,simulate,realistic,convert
#home.fakeScan
home.fakeScan.title=Fake Scan
home.fakeScan.desc=Create a PDF that looks like it was scanned
fakeScan.tags=scan,simulate,realistic,convert
# ScannerEffect advanced settings (frontend)
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)
# FakeScan advanced settings (frontend)
fakeScan.advancedSettings=Enable Advanced Scan Settings
fakeScan.colorspace=Colorspace
fakeScan.colorspace.grayscale=Grayscale
fakeScan.colorspace.color=Color
fakeScan.border=Border (px)
fakeScan.rotate=Base Rotation (degrees)
fakeScan.rotateVariance=Rotation Variance (degrees)
fakeScan.brightness=Brightness
fakeScan.contrast=Contrast
fakeScan.blur=Blur
fakeScan.noise=Noise
fakeScan.yellowish=Yellowish (simulate old paper)
fakeScan.resolution=Resolution (DPI)
# Table of Contents Feature
@@ -1811,41 +1811,41 @@ cookieBanner.preferencesModal.necessary.description=These cookies are essential
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=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
#fakeScan
fakeScan.title=Fake Scan
fakeScan.header=Fake Scan
fakeScan.description=Create a PDF that looks like it was scanned
fakeScan.selectPDF=Select PDF:
fakeScan.quality=Scan Quality
fakeScan.quality.low=Low
fakeScan.quality.medium=Medium
fakeScan.quality.high=High
fakeScan.rotation=Rotation Angle
fakeScan.rotation.none=None
fakeScan.rotation.slight=Slight
fakeScan.rotation.moderate=Moderate
fakeScan.rotation.severe=Severe
fakeScan.submit=Create Fake Scan
#home.scannerEffect
home.scannerEffect.title=Scanner Effect
home.scannerEffect.desc=Create a PDF that looks like it was scanned
scannerEffect.tags=scan,simulate,realistic,convert
#home.fakeScan
home.fakeScan.title=Fake Scan
home.fakeScan.desc=Create a PDF that looks like it was scanned
fakeScan.tags=scan,simulate,realistic,convert
# ScannerEffect advanced settings (frontend)
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)
# FakeScan advanced settings (frontend)
fakeScan.advancedSettings=Enable Advanced Scan Settings
fakeScan.colorspace=Colorspace
fakeScan.colorspace.grayscale=Grayscale
fakeScan.colorspace.color=Color
fakeScan.border=Border (px)
fakeScan.rotate=Base Rotation (degrees)
fakeScan.rotateVariance=Rotation Variance (degrees)
fakeScan.brightness=Brightness
fakeScan.contrast=Contrast
fakeScan.blur=Blur
fakeScan.noise=Noise
fakeScan.yellowish=Yellowish (simulate old paper)
fakeScan.resolution=Resolution (DPI)
# Table of Contents Feature
@@ -1811,41 +1811,41 @@ cookieBanner.preferencesModal.necessary.description=These cookies are essential
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=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
#fakeScan
fakeScan.title=Fake Scan
fakeScan.header=Fake Scan
fakeScan.description=Create a PDF that looks like it was scanned
fakeScan.selectPDF=Select PDF:
fakeScan.quality=Scan Quality
fakeScan.quality.low=Low
fakeScan.quality.medium=Medium
fakeScan.quality.high=High
fakeScan.rotation=Rotation Angle
fakeScan.rotation.none=None
fakeScan.rotation.slight=Slight
fakeScan.rotation.moderate=Moderate
fakeScan.rotation.severe=Severe
fakeScan.submit=Create Fake Scan
#home.scannerEffect
home.scannerEffect.title=Scanner Effect
home.scannerEffect.desc=Create a PDF that looks like it was scanned
scannerEffect.tags=scan,simulate,realistic,convert
#home.fakeScan
home.fakeScan.title=Fake Scan
home.fakeScan.desc=Create a PDF that looks like it was scanned
fakeScan.tags=scan,simulate,realistic,convert
# ScannerEffect advanced settings (frontend)
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)
# FakeScan advanced settings (frontend)
fakeScan.advancedSettings=Enable Advanced Scan Settings
fakeScan.colorspace=Colorspace
fakeScan.colorspace.grayscale=Grayscale
fakeScan.colorspace.color=Color
fakeScan.border=Border (px)
fakeScan.rotate=Base Rotation (degrees)
fakeScan.rotateVariance=Rotation Variance (degrees)
fakeScan.brightness=Brightness
fakeScan.contrast=Contrast
fakeScan.blur=Blur
fakeScan.noise=Noise
fakeScan.yellowish=Yellowish (simulate old paper)
fakeScan.resolution=Resolution (DPI)
# Table of Contents Feature
@@ -1811,41 +1811,41 @@ cookieBanner.preferencesModal.necessary.description=These cookies are essential
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=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
#fakeScan
fakeScan.title=Fake Scan
fakeScan.header=Fake Scan
fakeScan.description=Create a PDF that looks like it was scanned
fakeScan.selectPDF=Select PDF:
fakeScan.quality=Scan Quality
fakeScan.quality.low=Low
fakeScan.quality.medium=Medium
fakeScan.quality.high=High
fakeScan.rotation=Rotation Angle
fakeScan.rotation.none=None
fakeScan.rotation.slight=Slight
fakeScan.rotation.moderate=Moderate
fakeScan.rotation.severe=Severe
fakeScan.submit=Create Fake Scan
#home.scannerEffect
home.scannerEffect.title=Scanner Effect
home.scannerEffect.desc=Create a PDF that looks like it was scanned
scannerEffect.tags=scan,simulate,realistic,convert
#home.fakeScan
home.fakeScan.title=Fake Scan
home.fakeScan.desc=Create a PDF that looks like it was scanned
fakeScan.tags=scan,simulate,realistic,convert
# ScannerEffect advanced settings (frontend)
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)
# FakeScan advanced settings (frontend)
fakeScan.advancedSettings=Enable Advanced Scan Settings
fakeScan.colorspace=Colorspace
fakeScan.colorspace.grayscale=Grayscale
fakeScan.colorspace.color=Color
fakeScan.border=Border (px)
fakeScan.rotate=Base Rotation (degrees)
fakeScan.rotateVariance=Rotation Variance (degrees)
fakeScan.brightness=Brightness
fakeScan.contrast=Contrast
fakeScan.blur=Blur
fakeScan.noise=Noise
fakeScan.yellowish=Yellowish (simulate old paper)
fakeScan.resolution=Resolution (DPI)
# Table of Contents Feature
@@ -1811,41 +1811,41 @@ cookieBanner.preferencesModal.necessary.description=These cookies are essential
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=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
#fakeScan
fakeScan.title=Fake Scan
fakeScan.header=Fake Scan
fakeScan.description=Create a PDF that looks like it was scanned
fakeScan.selectPDF=Select PDF:
fakeScan.quality=Scan Quality
fakeScan.quality.low=Low
fakeScan.quality.medium=Medium
fakeScan.quality.high=High
fakeScan.rotation=Rotation Angle
fakeScan.rotation.none=None
fakeScan.rotation.slight=Slight
fakeScan.rotation.moderate=Moderate
fakeScan.rotation.severe=Severe
fakeScan.submit=Create Fake Scan
#home.scannerEffect
home.scannerEffect.title=Scanner Effect
home.scannerEffect.desc=Create a PDF that looks like it was scanned
scannerEffect.tags=scan,simulate,realistic,convert
#home.fakeScan
home.fakeScan.title=Fake Scan
home.fakeScan.desc=Create a PDF that looks like it was scanned
fakeScan.tags=scan,simulate,realistic,convert
# ScannerEffect advanced settings (frontend)
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)
# FakeScan advanced settings (frontend)
fakeScan.advancedSettings=Enable Advanced Scan Settings
fakeScan.colorspace=Colorspace
fakeScan.colorspace.grayscale=Grayscale
fakeScan.colorspace.color=Color
fakeScan.border=Border (px)
fakeScan.rotate=Base Rotation (degrees)
fakeScan.rotateVariance=Rotation Variance (degrees)
fakeScan.brightness=Brightness
fakeScan.contrast=Contrast
fakeScan.blur=Blur
fakeScan.noise=Noise
fakeScan.yellowish=Yellowish (simulate old paper)
fakeScan.resolution=Resolution (DPI)
# Table of Contents Feature
@@ -1811,41 +1811,41 @@ cookieBanner.preferencesModal.necessary.description=These cookies are essential
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=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
#fakeScan
fakeScan.title=Fake Scan
fakeScan.header=Fake Scan
fakeScan.description=Create a PDF that looks like it was scanned
fakeScan.selectPDF=Select PDF:
fakeScan.quality=Scan Quality
fakeScan.quality.low=Low
fakeScan.quality.medium=Medium
fakeScan.quality.high=High
fakeScan.rotation=Rotation Angle
fakeScan.rotation.none=None
fakeScan.rotation.slight=Slight
fakeScan.rotation.moderate=Moderate
fakeScan.rotation.severe=Severe
fakeScan.submit=Create Fake Scan
#home.scannerEffect
home.scannerEffect.title=Scanner Effect
home.scannerEffect.desc=Create a PDF that looks like it was scanned
scannerEffect.tags=scan,simulate,realistic,convert
#home.fakeScan
home.fakeScan.title=Fake Scan
home.fakeScan.desc=Create a PDF that looks like it was scanned
fakeScan.tags=scan,simulate,realistic,convert
# ScannerEffect advanced settings (frontend)
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)
# FakeScan advanced settings (frontend)
fakeScan.advancedSettings=Enable Advanced Scan Settings
fakeScan.colorspace=Colorspace
fakeScan.colorspace.grayscale=Grayscale
fakeScan.colorspace.color=Color
fakeScan.border=Border (px)
fakeScan.rotate=Base Rotation (degrees)
fakeScan.rotateVariance=Rotation Variance (degrees)
fakeScan.brightness=Brightness
fakeScan.contrast=Contrast
fakeScan.blur=Blur
fakeScan.noise=Noise
fakeScan.yellowish=Yellowish (simulate old paper)
fakeScan.resolution=Resolution (DPI)
# Table of Contents Feature
@@ -1811,41 +1811,41 @@ cookieBanner.preferencesModal.necessary.description=Diese Cookies sind für das
cookieBanner.preferencesModal.analytics.title=Analyse
cookieBanner.preferencesModal.analytics.description=Diese Cookies helfen uns zu verstehen, wie unsere Tools genutzt werden, damit wir uns darauf konzentrieren können, die Funktionen zu entwickeln, die unserer Community am meisten am Herzen liegen. Seien Sie beruhigt Stirling PDF kann und wird niemals den Inhalt der Dokumente verfolgen, mit denen Sie arbeiten.
#scannerEffect
scannerEffect.title=Scanner-Effect-PDF
scannerEffect.header=Scanner-Effect-PDF
scannerEffect.description=Erstellen Sie ein PDF, das so aussieht, als wäre es gescannt worden
scannerEffect.selectPDF=Wählen Sie PDF:
scannerEffect.quality=Scan-Qualität
scannerEffect.quality.low=Niedrig
scannerEffect.quality.medium=Medium
scannerEffect.quality.high=Hoch
scannerEffect.rotation=Rotationswinkel
scannerEffect.rotation.none=Keiner
scannerEffect.rotation.slight=Leicht
scannerEffect.rotation.moderate=Mäßig
scannerEffect.rotation.severe=Schwer
scannerEffect.submit=Erstellen Sie einen Scanner-Effect
#fakeScan
fakeScan.title=Fake-Scan-PDF
fakeScan.header=Fake-Scan-PDF
fakeScan.description=Erstellen Sie ein PDF, das so aussieht, als wäre es gescannt worden
fakeScan.selectPDF=Wählen Sie PDF:
fakeScan.quality=Scan-Qualität
fakeScan.quality.low=Niedrig
fakeScan.quality.medium=Medium
fakeScan.quality.high=Hoch
fakeScan.rotation=Rotationswinkel
fakeScan.rotation.none=Keiner
fakeScan.rotation.slight=Leicht
fakeScan.rotation.moderate=Mäßig
fakeScan.rotation.severe=Schwer
fakeScan.submit=Erstellen Sie einen Fake-Scan
#home.scannerEffect
home.scannerEffect.title=Scanner-Effect-PDF
home.scannerEffect.desc=Erstellen Sie ein PDF, das so aussieht, als wäre es gescannt worden
scannerEffect.tags=scannen,simulieren,realistisch,konvertieren,fake,scan,pdf
#home.fakeScan
home.fakeScan.title=Fake-Scan-PDF
home.fakeScan.desc=Erstellen Sie ein PDF, das so aussieht, als wäre es gescannt worden
fakeScan.tags=scannen,simulieren,realistisch,konvertieren,fake,scan,pdf
# ScannerEffect advanced settings (frontend)
scannerEffect.advancedSettings=Aktivieren Sie erweiterte Scaneinstellungen
scannerEffect.colorspace=Farbraum
scannerEffect.colorspace.grayscale=Graustufen
scannerEffect.colorspace.color=Farbe
scannerEffect.border=Grenze (PX)
scannerEffect.rotate=Grundrotation (Grad)
scannerEffect.rotateVariance=Rotationsvarianz (Grad)
scannerEffect.brightness=Helligkeit
scannerEffect.contrast=Kontrast
scannerEffect.blur=Verwischen
scannerEffect.noise=Rauschen
scannerEffect.yellowish=Gelblich (simulieren Sie altes Papier)
scannerEffect.resolution=Auflösung (DPI)
# FakeScan advanced settings (frontend)
fakeScan.advancedSettings=Aktivieren Sie erweiterte Scaneinstellungen
fakeScan.colorspace=Farbraum
fakeScan.colorspace.grayscale=Graustufen
fakeScan.colorspace.color=Farbe
fakeScan.border=Grenze (PX)
fakeScan.rotate=Grundrotation (Grad)
fakeScan.rotateVariance=Rotationsvarianz (Grad)
fakeScan.brightness=Helligkeit
fakeScan.contrast=Kontrast
fakeScan.blur=Verwischen
fakeScan.noise=Rauschen
fakeScan.yellowish=Gelblich (simulieren Sie altes Papier)
fakeScan.resolution=Auflösung (DPI)
# Table of Contents Feature
@@ -1811,41 +1811,41 @@ cookieBanner.preferencesModal.necessary.description=These cookies are essential
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=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
#fakeScan
fakeScan.title=Fake Scan
fakeScan.header=Fake Scan
fakeScan.description=Create a PDF that looks like it was scanned
fakeScan.selectPDF=Select PDF:
fakeScan.quality=Scan Quality
fakeScan.quality.low=Low
fakeScan.quality.medium=Medium
fakeScan.quality.high=High
fakeScan.rotation=Rotation Angle
fakeScan.rotation.none=None
fakeScan.rotation.slight=Slight
fakeScan.rotation.moderate=Moderate
fakeScan.rotation.severe=Severe
fakeScan.submit=Create Fake Scan
#home.scannerEffect
home.scannerEffect.title=Scanner Effect
home.scannerEffect.desc=Create a PDF that looks like it was scanned
scannerEffect.tags=scan,simulate,realistic,convert
#home.fakeScan
home.fakeScan.title=Fake Scan
home.fakeScan.desc=Create a PDF that looks like it was scanned
fakeScan.tags=scan,simulate,realistic,convert
# ScannerEffect advanced settings (frontend)
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)
# FakeScan advanced settings (frontend)
fakeScan.advancedSettings=Enable Advanced Scan Settings
fakeScan.colorspace=Colorspace
fakeScan.colorspace.grayscale=Grayscale
fakeScan.colorspace.color=Color
fakeScan.border=Border (px)
fakeScan.rotate=Base Rotation (degrees)
fakeScan.rotateVariance=Rotation Variance (degrees)
fakeScan.brightness=Brightness
fakeScan.contrast=Contrast
fakeScan.blur=Blur
fakeScan.noise=Noise
fakeScan.yellowish=Yellowish (simulate old paper)
fakeScan.resolution=Resolution (DPI)
# Table of Contents Feature
@@ -1811,41 +1811,105 @@ cookieBanner.preferencesModal.necessary.description=These cookies are essential
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=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
####################
# React Frontend #
####################
# Common UI elements
removeMetadata.submit=Remove Metadata
sidebar.toggle=Toggle Sidebar
theme.toggle=Toggle Theme
view.viewer=Viewer
view.pageEditor=Page Editor
view.fileManager=File Manager
#home.scannerEffect
home.scannerEffect.title=Scanner Effect
home.scannerEffect.desc=Create a PDF that looks like it was scanned
scannerEffect.tags=scan,simulate,realistic,convert
# File management
fileManager.dragDrop=Drag & Drop files here
fileManager.clickToUpload=Click to upload files
fileManager.selectedFiles=Selected Files
fileManager.clearAll=Clear All
fileManager.storage=Storage
fileManager.filesStored=files stored
fileManager.storageError=Storage error occurred
fileManager.storageLow=Storage is running low. Consider removing old files.
fileManager.uploadError=Failed to upload some files.
fileManager.supportMessage=Powered by browser database storage for unlimited capacity
# ScannerEffect advanced settings (frontend)
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)
# Page Editor
pageEditor.title=Page Editor
pageEditor.save=Save Changes
pageEditor.noPdfLoaded=No PDF loaded. Please upload a PDF to edit.
pageEditor.rotatedLeft=Rotated left:
pageEditor.rotatedRight=Rotated right:
pageEditor.deleted=Deleted:
pageEditor.movedLeft=Moved left:
pageEditor.movedRight=Moved right:
pageEditor.splitAt=Split at:
pageEditor.insertedPageBreak=Inserted page break at:
pageEditor.addFileNotImplemented=Add file not implemented in demo
pageEditor.closePdf=Close PDF
# Viewer
viewer.noPdfLoaded=No PDF loaded. Click to upload a PDF.
viewer.choosePdf=Choose PDF
viewer.noPagesToDisplay=No pages to display.
viewer.singlePageView=Single Page View
viewer.dualPageView=Dual Page View
viewer.hideSidebars=Hide Sidebars
viewer.showSidebars=Show Sidebars
viewer.zoomOut=Zoom out
viewer.zoomIn=Zoom in
# Tool Picker
toolPicker.searchPlaceholder=Search tools...
toolPicker.noToolsFound=No tools found
pageEditor.reset=Reset Changes
pageEditor.zoomIn=Zoom In
pageEditor.zoomOut=Zoom Out
pageEditor.fitToWidth=Fit to Width
pageEditor.actualSize=Actual Size
# Viewer
viewer.previousPage=Previous Page
viewer.nextPage=Next Page
viewer.pageNavigation=Page Navigation
viewer.currentPage=Current Page
viewer.totalPages=Total Pages
#fakeScan
fakeScan.title=Fake Scan
fakeScan.header=Fake Scan
fakeScan.description=Create a PDF that looks like it was scanned
fakeScan.selectPDF=Select PDF:
fakeScan.quality=Scan Quality
fakeScan.quality.low=Low
fakeScan.quality.medium=Medium
fakeScan.quality.high=High
fakeScan.rotation=Rotation Angle
fakeScan.rotation.none=None
fakeScan.rotation.slight=Slight
fakeScan.rotation.moderate=Moderate
fakeScan.rotation.severe=Severe
fakeScan.submit=Create Fake Scan
#home.fakeScan
home.fakeScan.title=Fake Scan
home.fakeScan.desc=Create a PDF that looks like it was scanned
fakeScan.tags=scan,simulate,realistic,convert
# FakeScan advanced settings (frontend)
fakeScan.advancedSettings=Enable Advanced Scan Settings
fakeScan.colorspace=Colorspace
fakeScan.colorspace.grayscale=Grayscale
fakeScan.colorspace.color=Color
fakeScan.border=Border (px)
fakeScan.rotate=Base Rotation (degrees)
fakeScan.rotateVariance=Rotation Variance (degrees)
fakeScan.brightness=Brightness
fakeScan.contrast=Contrast
fakeScan.blur=Blur
fakeScan.noise=Noise
fakeScan.yellowish=Yellowish (simulate old paper)
fakeScan.resolution=Resolution (DPI)
# Table of Contents Feature
@@ -1811,41 +1811,41 @@ cookieBanner.preferencesModal.necessary.description=These cookies are essential
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=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
#fakeScan
fakeScan.title=Fake Scan
fakeScan.header=Fake Scan
fakeScan.description=Create a PDF that looks like it was scanned
fakeScan.selectPDF=Select PDF:
fakeScan.quality=Scan Quality
fakeScan.quality.low=Low
fakeScan.quality.medium=Medium
fakeScan.quality.high=High
fakeScan.rotation=Rotation Angle
fakeScan.rotation.none=None
fakeScan.rotation.slight=Slight
fakeScan.rotation.moderate=Moderate
fakeScan.rotation.severe=Severe
fakeScan.submit=Create Fake Scan
#home.scannerEffect
home.scannerEffect.title=Scanner Effect
home.scannerEffect.desc=Create a PDF that looks like it was scanned
scannerEffect.tags=scan,simulate,realistic,convert
#home.fakeScan
home.fakeScan.title=Fake Scan
home.fakeScan.desc=Create a PDF that looks like it was scanned
fakeScan.tags=scan,simulate,realistic,convert
# ScannerEffect advanced settings (frontend)
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)
# FakeScan advanced settings (frontend)
fakeScan.advancedSettings=Enable Advanced Scan Settings
fakeScan.colorspace=Colorspace
fakeScan.colorspace.grayscale=Grayscale
fakeScan.colorspace.color=Color
fakeScan.border=Border (px)
fakeScan.rotate=Base Rotation (degrees)
fakeScan.rotateVariance=Rotation Variance (degrees)
fakeScan.brightness=Brightness
fakeScan.contrast=Contrast
fakeScan.blur=Blur
fakeScan.noise=Noise
fakeScan.yellowish=Yellowish (simulate old paper)
fakeScan.resolution=Resolution (DPI)
# Table of Contents Feature
@@ -1811,41 +1811,41 @@ cookieBanner.preferencesModal.necessary.description=Estas cookies son esenciales
cookieBanner.preferencesModal.analytics.title=Análisis
cookieBanner.preferencesModal.analytics.description=Estas cookies nos ayudan a entender cómo se están utilizando nuestras herramientas, para que podamos centrarnos en desarrollar las funciones que nuestra comunidad valora más. Tenga la seguridad de que Stirling PDF no puede y nunca podrá rastrear el contenido de los documentos con los que trabaja.
#scannerEffect
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
#fakeScan
fakeScan.title=Fake Scan
fakeScan.header=Fake Scan
fakeScan.description=Create a PDF that looks like it was scanned
fakeScan.selectPDF=Select PDF:
fakeScan.quality=Scan Quality
fakeScan.quality.low=Low
fakeScan.quality.medium=Medium
fakeScan.quality.high=High
fakeScan.rotation=Rotation Angle
fakeScan.rotation.none=None
fakeScan.rotation.slight=Slight
fakeScan.rotation.moderate=Moderate
fakeScan.rotation.severe=Severe
fakeScan.submit=Create Fake Scan
#home.scannerEffect
home.scannerEffect.title=Scanner Effect
home.scannerEffect.desc=Create a PDF that looks like it was scanned
scannerEffect.tags=scan,simulate,realistic,convert
#home.fakeScan
home.fakeScan.title=Fake Scan
home.fakeScan.desc=Create a PDF that looks like it was scanned
fakeScan.tags=scan,simulate,realistic,convert
# ScannerEffect advanced settings (frontend)
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)
# FakeScan advanced settings (frontend)
fakeScan.advancedSettings=Enable Advanced Scan Settings
fakeScan.colorspace=Colorspace
fakeScan.colorspace.grayscale=Grayscale
fakeScan.colorspace.color=Color
fakeScan.border=Border (px)
fakeScan.rotate=Base Rotation (degrees)
fakeScan.rotateVariance=Rotation Variance (degrees)
fakeScan.brightness=Brightness
fakeScan.contrast=Contrast
fakeScan.blur=Blur
fakeScan.noise=Noise
fakeScan.yellowish=Yellowish (simulate old paper)
fakeScan.resolution=Resolution (DPI)
# Table of Contents Feature
@@ -1811,41 +1811,41 @@ cookieBanner.preferencesModal.necessary.description=These cookies are essential
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=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
#fakeScan
fakeScan.title=Fake Scan
fakeScan.header=Fake Scan
fakeScan.description=Create a PDF that looks like it was scanned
fakeScan.selectPDF=Select PDF:
fakeScan.quality=Scan Quality
fakeScan.quality.low=Low
fakeScan.quality.medium=Medium
fakeScan.quality.high=High
fakeScan.rotation=Rotation Angle
fakeScan.rotation.none=None
fakeScan.rotation.slight=Slight
fakeScan.rotation.moderate=Moderate
fakeScan.rotation.severe=Severe
fakeScan.submit=Create Fake Scan
#home.scannerEffect
home.scannerEffect.title=Scanner Effect
home.scannerEffect.desc=Create a PDF that looks like it was scanned
scannerEffect.tags=scan,simulate,realistic,convert
#home.fakeScan
home.fakeScan.title=Fake Scan
home.fakeScan.desc=Create a PDF that looks like it was scanned
fakeScan.tags=scan,simulate,realistic,convert
# ScannerEffect advanced settings (frontend)
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)
# FakeScan advanced settings (frontend)
fakeScan.advancedSettings=Enable Advanced Scan Settings
fakeScan.colorspace=Colorspace
fakeScan.colorspace.grayscale=Grayscale
fakeScan.colorspace.color=Color
fakeScan.border=Border (px)
fakeScan.rotate=Base Rotation (degrees)
fakeScan.rotateVariance=Rotation Variance (degrees)
fakeScan.brightness=Brightness
fakeScan.contrast=Contrast
fakeScan.blur=Blur
fakeScan.noise=Noise
fakeScan.yellowish=Yellowish (simulate old paper)
fakeScan.resolution=Resolution (DPI)
# Table of Contents Feature
@@ -1811,41 +1811,41 @@ cookieBanner.preferencesModal.necessary.description=These cookies are essential
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=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
#fakeScan
fakeScan.title=Fake Scan
fakeScan.header=Fake Scan
fakeScan.description=Create a PDF that looks like it was scanned
fakeScan.selectPDF=Select PDF:
fakeScan.quality=Scan Quality
fakeScan.quality.low=Low
fakeScan.quality.medium=Medium
fakeScan.quality.high=High
fakeScan.rotation=Rotation Angle
fakeScan.rotation.none=None
fakeScan.rotation.slight=Slight
fakeScan.rotation.moderate=Moderate
fakeScan.rotation.severe=Severe
fakeScan.submit=Create Fake Scan
#home.scannerEffect
home.scannerEffect.title=Scanner Effect
home.scannerEffect.desc=Create a PDF that looks like it was scanned
scannerEffect.tags=scan,simulate,realistic,convert
#home.fakeScan
home.fakeScan.title=Fake Scan
home.fakeScan.desc=Create a PDF that looks like it was scanned
fakeScan.tags=scan,simulate,realistic,convert
# ScannerEffect advanced settings (frontend)
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)
# FakeScan advanced settings (frontend)
fakeScan.advancedSettings=Enable Advanced Scan Settings
fakeScan.colorspace=Colorspace
fakeScan.colorspace.grayscale=Grayscale
fakeScan.colorspace.color=Color
fakeScan.border=Border (px)
fakeScan.rotate=Base Rotation (degrees)
fakeScan.rotateVariance=Rotation Variance (degrees)
fakeScan.brightness=Brightness
fakeScan.contrast=Contrast
fakeScan.blur=Blur
fakeScan.noise=Noise
fakeScan.yellowish=Yellowish (simulate old paper)
fakeScan.resolution=Resolution (DPI)
# Table of Contents Feature
@@ -1811,41 +1811,41 @@ cookieBanner.preferencesModal.necessary.description=Ces cookies sont essentiels
cookieBanner.preferencesModal.analytics.title=Analyse
cookieBanner.preferencesModal.analytics.description=Ces cookies nous aident à comprendre comment nos outils sont utilisés, afin que nous puissions nous concentrer sur les fonctionnalités les plus appréciées par notre communauté. Soyez rassuré — Stirling PDF ne peut pas et ne suivra jamais le contenu des documents que vous utilisez.
#scannerEffect
scannerEffect.title=Fausse numérisation
scannerEffect.header=Fausse numérisation
scannerEffect.description=Créer un PDF qui ressemble à une numérisation
scannerEffect.selectPDF=Sélectionner un PDF :
scannerEffect.quality=Qualité de numérisation
scannerEffect.quality.low=Faible
scannerEffect.quality.medium=Moyenne
scannerEffect.quality.high=Élevée
scannerEffect.rotation=Angle de rotation
scannerEffect.rotation.none=Aucun
scannerEffect.rotation.slight=Léger
scannerEffect.rotation.moderate=Modéré
scannerEffect.rotation.severe=Sévère
scannerEffect.submit=Créer une fausse numérisation
#fakeScan
fakeScan.title=Fausse numérisation
fakeScan.header=Fausse numérisation
fakeScan.description=Créer un PDF qui ressemble à une numérisation
fakeScan.selectPDF=Sélectionner un PDF :
fakeScan.quality=Qualité de numérisation
fakeScan.quality.low=Faible
fakeScan.quality.medium=Moyenne
fakeScan.quality.high=Élevée
fakeScan.rotation=Angle de rotation
fakeScan.rotation.none=Aucun
fakeScan.rotation.slight=Léger
fakeScan.rotation.moderate=Modéré
fakeScan.rotation.severe=Sévère
fakeScan.submit=Créer une fausse numérisation
#home.scannerEffect
home.scannerEffect.title=Fausse numérisation
home.scannerEffect.desc=Créer un PDF qui ressemble à une numérisation
scannerEffect.tags=numérisation,simuler,réaliste,convertir,scan,simulate,realistic,convert
#home.fakeScan
home.fakeScan.title=Fausse numérisation
home.fakeScan.desc=Créer un PDF qui ressemble à une numérisation
fakeScan.tags=numérisation,simuler,réaliste,convertir,scan,simulate,realistic,convert
# ScannerEffect advanced settings (frontend)
scannerEffect.advancedSettings=Activer les paramètres de numérisation avancés
scannerEffect.colorspace=Espace colorimétrique
scannerEffect.colorspace.grayscale=Niveaux de gris
scannerEffect.colorspace.color=Couleur
scannerEffect.border=Bordure (px)
scannerEffect.rotate=Rotation de base (degrés)
scannerEffect.rotateVariance=Variance de rotation (degrés)
scannerEffect.brightness=Luminosité
scannerEffect.contrast=Contraste
scannerEffect.blur=Flou
scannerEffect.noise=Bruit
scannerEffect.yellowish=Jaunâtre (simuler du vieux papier)
scannerEffect.resolution=Résolution (DPI)
# FakeScan advanced settings (frontend)
fakeScan.advancedSettings=Activer les paramètres de numérisation avancés
fakeScan.colorspace=Espace colorimétrique
fakeScan.colorspace.grayscale=Niveaux de gris
fakeScan.colorspace.color=Couleur
fakeScan.border=Bordure (px)
fakeScan.rotate=Rotation de base (degrés)
fakeScan.rotateVariance=Variance de rotation (degrés)
fakeScan.brightness=Luminosité
fakeScan.contrast=Contraste
fakeScan.blur=Flou
fakeScan.noise=Bruit
fakeScan.yellowish=Jaunâtre (simuler du vieux papier)
fakeScan.resolution=Résolution (DPI)
# Table of Contents Feature
@@ -1811,41 +1811,41 @@ cookieBanner.preferencesModal.necessary.description=These cookies are essential
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=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
#fakeScan
fakeScan.title=Fake Scan
fakeScan.header=Fake Scan
fakeScan.description=Create a PDF that looks like it was scanned
fakeScan.selectPDF=Select PDF:
fakeScan.quality=Scan Quality
fakeScan.quality.low=Low
fakeScan.quality.medium=Medium
fakeScan.quality.high=High
fakeScan.rotation=Rotation Angle
fakeScan.rotation.none=None
fakeScan.rotation.slight=Slight
fakeScan.rotation.moderate=Moderate
fakeScan.rotation.severe=Severe
fakeScan.submit=Create Fake Scan
#home.scannerEffect
home.scannerEffect.title=Scanner Effect
home.scannerEffect.desc=Create a PDF that looks like it was scanned
scannerEffect.tags=scan,simulate,realistic,convert
#home.fakeScan
home.fakeScan.title=Fake Scan
home.fakeScan.desc=Create a PDF that looks like it was scanned
fakeScan.tags=scan,simulate,realistic,convert
# ScannerEffect advanced settings (frontend)
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)
# FakeScan advanced settings (frontend)
fakeScan.advancedSettings=Enable Advanced Scan Settings
fakeScan.colorspace=Colorspace
fakeScan.colorspace.grayscale=Grayscale
fakeScan.colorspace.color=Color
fakeScan.border=Border (px)
fakeScan.rotate=Base Rotation (degrees)
fakeScan.rotateVariance=Rotation Variance (degrees)
fakeScan.brightness=Brightness
fakeScan.contrast=Contrast
fakeScan.blur=Blur
fakeScan.noise=Noise
fakeScan.yellowish=Yellowish (simulate old paper)
fakeScan.resolution=Resolution (DPI)
# Table of Contents Feature
@@ -1811,41 +1811,41 @@ cookieBanner.preferencesModal.necessary.description=These cookies are essential
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=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
#fakeScan
fakeScan.title=Fake Scan
fakeScan.header=Fake Scan
fakeScan.description=Create a PDF that looks like it was scanned
fakeScan.selectPDF=Select PDF:
fakeScan.quality=Scan Quality
fakeScan.quality.low=Low
fakeScan.quality.medium=Medium
fakeScan.quality.high=High
fakeScan.rotation=Rotation Angle
fakeScan.rotation.none=None
fakeScan.rotation.slight=Slight
fakeScan.rotation.moderate=Moderate
fakeScan.rotation.severe=Severe
fakeScan.submit=Create Fake Scan
#home.scannerEffect
home.scannerEffect.title=Scanner Effect
home.scannerEffect.desc=Create a PDF that looks like it was scanned
scannerEffect.tags=scan,simulate,realistic,convert
#home.fakeScan
home.fakeScan.title=Fake Scan
home.fakeScan.desc=Create a PDF that looks like it was scanned
fakeScan.tags=scan,simulate,realistic,convert
# ScannerEffect advanced settings (frontend)
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)
# FakeScan advanced settings (frontend)
fakeScan.advancedSettings=Enable Advanced Scan Settings
fakeScan.colorspace=Colorspace
fakeScan.colorspace.grayscale=Grayscale
fakeScan.colorspace.color=Color
fakeScan.border=Border (px)
fakeScan.rotate=Base Rotation (degrees)
fakeScan.rotateVariance=Rotation Variance (degrees)
fakeScan.brightness=Brightness
fakeScan.contrast=Contrast
fakeScan.blur=Blur
fakeScan.noise=Noise
fakeScan.yellowish=Yellowish (simulate old paper)
fakeScan.resolution=Resolution (DPI)
# Table of Contents Feature
@@ -1811,41 +1811,41 @@ cookieBanner.preferencesModal.necessary.description=These cookies are essential
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=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
#fakeScan
fakeScan.title=Fake Scan
fakeScan.header=Fake Scan
fakeScan.description=Create a PDF that looks like it was scanned
fakeScan.selectPDF=Select PDF:
fakeScan.quality=Scan Quality
fakeScan.quality.low=Low
fakeScan.quality.medium=Medium
fakeScan.quality.high=High
fakeScan.rotation=Rotation Angle
fakeScan.rotation.none=None
fakeScan.rotation.slight=Slight
fakeScan.rotation.moderate=Moderate
fakeScan.rotation.severe=Severe
fakeScan.submit=Create Fake Scan
#home.scannerEffect
home.scannerEffect.title=Scanner Effect
home.scannerEffect.desc=Create a PDF that looks like it was scanned
scannerEffect.tags=scan,simulate,realistic,convert
#home.fakeScan
home.fakeScan.title=Fake Scan
home.fakeScan.desc=Create a PDF that looks like it was scanned
fakeScan.tags=scan,simulate,realistic,convert
# ScannerEffect advanced settings (frontend)
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)
# FakeScan advanced settings (frontend)
fakeScan.advancedSettings=Enable Advanced Scan Settings
fakeScan.colorspace=Colorspace
fakeScan.colorspace.grayscale=Grayscale
fakeScan.colorspace.color=Color
fakeScan.border=Border (px)
fakeScan.rotate=Base Rotation (degrees)
fakeScan.rotateVariance=Rotation Variance (degrees)
fakeScan.brightness=Brightness
fakeScan.contrast=Contrast
fakeScan.blur=Blur
fakeScan.noise=Noise
fakeScan.yellowish=Yellowish (simulate old paper)
fakeScan.resolution=Resolution (DPI)
# Table of Contents Feature
@@ -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
@@ -1811,41 +1811,41 @@ cookieBanner.preferencesModal.necessary.description=Ezek a sütik elengedhetetle
cookieBanner.preferencesModal.analytics.title=Adatelemzések
cookieBanner.preferencesModal.analytics.description=Ezek a sütik segítenek megérteni, hogyan használják eszközeinket, így a közösségünk által leginkább értékelt funkciókra összpontosíthatunk. Nyugodt lehet-a Stirling PDF nem képes és soha nem is fog nyomon követni az Ön által használt dokumentumok tartalmát.
#scannerEffect
scannerEffect.title=Látszat szkennelés
scannerEffect.header=Látszat szkennelés
scannerEffect.description=Olyan PDF létrehozása, amely szkenneltnek tűnik
scannerEffect.selectPDF=PDF kiválasztása:
scannerEffect.quality=Szkennelési minőség
scannerEffect.quality.low=Alacsony
scannerEffect.quality.medium=Közepes
scannerEffect.quality.high=Magas
scannerEffect.rotation=Forgatási szög
scannerEffect.rotation.none=Nincs
scannerEffect.rotation.slight=Enyhe
scannerEffect.rotation.moderate=Mérsékelt
scannerEffect.rotation.severe=Erős
scannerEffect.submit=Látszat szkennelés létrehozása
#fakeScan
fakeScan.title=Látszat szkennelés
fakeScan.header=Látszat szkennelés
fakeScan.description=Olyan PDF létrehozása, amely szkenneltnek tűnik
fakeScan.selectPDF=PDF kiválasztása:
fakeScan.quality=Szkennelési minőség
fakeScan.quality.low=Alacsony
fakeScan.quality.medium=Közepes
fakeScan.quality.high=Magas
fakeScan.rotation=Forgatási szög
fakeScan.rotation.none=Nincs
fakeScan.rotation.slight=Enyhe
fakeScan.rotation.moderate=Mérsékelt
fakeScan.rotation.severe=Erős
fakeScan.submit=Látszat szkennelés létrehozása
#home.scannerEffect
home.scannerEffect.title=Látszat szkennelés
home.scannerEffect.desc=Olyan PDF létrehozása, amely szkenneltnek tűnik
scannerEffect.tags=szkennelés,szimuláció,valósághű,konvertálás
#home.fakeScan
home.fakeScan.title=Látszat szkennelés
home.fakeScan.desc=Olyan PDF létrehozása, amely szkenneltnek tűnik
fakeScan.tags=szkennelés,szimuláció,valósághű,konvertálás
# ScannerEffect advanced settings (frontend)
scannerEffect.advancedSettings=Haladó szkennelési beállítások engedélyezése
scannerEffect.colorspace=Színtér
scannerEffect.colorspace.grayscale=Szürkeárnyalatos
scannerEffect.colorspace.color=Színes
scannerEffect.border=Keret (px)
scannerEffect.rotate=Alapforgatás (fok)
scannerEffect.rotateVariance=Forgatási változó (fok)
scannerEffect.brightness=Fényerő
scannerEffect.contrast=Kontraszt
scannerEffect.blur=Elmosás
scannerEffect.noise=Zaj
scannerEffect.yellowish=Sárgás (régi papír szimulálása)
scannerEffect.resolution=Felbontás (DPI)
# FakeScan advanced settings (frontend)
fakeScan.advancedSettings=Haladó szkennelési beállítások engedélyezése
fakeScan.colorspace=Színtér
fakeScan.colorspace.grayscale=Szürkeárnyalatos
fakeScan.colorspace.color=Színes
fakeScan.border=Keret (px)
fakeScan.rotate=Alapforgatás (fok)
fakeScan.rotateVariance=Forgatási változó (fok)
fakeScan.brightness=Fényerő
fakeScan.contrast=Kontraszt
fakeScan.blur=Elmosás
fakeScan.noise=Zaj
fakeScan.yellowish=Sárgás (régi papír szimulálása)
fakeScan.resolution=Felbontás (DPI)
# Table of Contents Feature
@@ -1811,41 +1811,41 @@ cookieBanner.preferencesModal.necessary.description=These cookies are essential
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=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
#fakeScan
fakeScan.title=Fake Scan
fakeScan.header=Fake Scan
fakeScan.description=Create a PDF that looks like it was scanned
fakeScan.selectPDF=Select PDF:
fakeScan.quality=Scan Quality
fakeScan.quality.low=Low
fakeScan.quality.medium=Medium
fakeScan.quality.high=High
fakeScan.rotation=Rotation Angle
fakeScan.rotation.none=None
fakeScan.rotation.slight=Slight
fakeScan.rotation.moderate=Moderate
fakeScan.rotation.severe=Severe
fakeScan.submit=Create Fake Scan
#home.scannerEffect
home.scannerEffect.title=Scanner Effect
home.scannerEffect.desc=Create a PDF that looks like it was scanned
scannerEffect.tags=scan,simulate,realistic,convert
#home.fakeScan
home.fakeScan.title=Fake Scan
home.fakeScan.desc=Create a PDF that looks like it was scanned
fakeScan.tags=scan,simulate,realistic,convert
# ScannerEffect advanced settings (frontend)
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)
# FakeScan advanced settings (frontend)
fakeScan.advancedSettings=Enable Advanced Scan Settings
fakeScan.colorspace=Colorspace
fakeScan.colorspace.grayscale=Grayscale
fakeScan.colorspace.color=Color
fakeScan.border=Border (px)
fakeScan.rotate=Base Rotation (degrees)
fakeScan.rotateVariance=Rotation Variance (degrees)
fakeScan.brightness=Brightness
fakeScan.contrast=Contrast
fakeScan.blur=Blur
fakeScan.noise=Noise
fakeScan.yellowish=Yellowish (simulate old paper)
fakeScan.resolution=Resolution (DPI)
# Table of Contents Feature
@@ -1811,41 +1811,41 @@ cookieBanner.preferencesModal.necessary.description=Questi cookie sono essenzial
cookieBanner.preferencesModal.analytics.title=Analytics
cookieBanner.preferencesModal.analytics.description=Questi cookie ci aiutano a capire come vengono utilizzati i nostri strumenti, così possiamo concentrarci sullo sviluppo delle funzionalità che la nostra community apprezza di più. Non preoccuparti: Stirling PDF non può e non traccerà mai il contenuto dei documenti con cui lavori.
#scannerEffect
scannerEffect.title=Falsa scansione
scannerEffect.header=Falsa scansione
scannerEffect.description=Crea un PDF che sembra scansionato
scannerEffect.selectPDF=Seleziona PDF:
scannerEffect.quality=Qualità di scansione
scannerEffect.quality.low=Bassa
scannerEffect.quality.medium=Media
scannerEffect.quality.high=Alta
scannerEffect.rotation=Angolo di rotazione
scannerEffect.rotation.none=Nessuno
scannerEffect.rotation.slight=Lieve
scannerEffect.rotation.moderate=Moderato
scannerEffect.rotation.severe=Severo
scannerEffect.submit=Crea una falsa scansione
#fakeScan
fakeScan.title=Falsa scansione
fakeScan.header=Falsa scansione
fakeScan.description=Crea un PDF che sembra scansionato
fakeScan.selectPDF=Seleziona PDF:
fakeScan.quality=Qualità di scansione
fakeScan.quality.low=Bassa
fakeScan.quality.medium=Media
fakeScan.quality.high=Alta
fakeScan.rotation=Angolo di rotazione
fakeScan.rotation.none=Nessuno
fakeScan.rotation.slight=Lieve
fakeScan.rotation.moderate=Moderato
fakeScan.rotation.severe=Severo
fakeScan.submit=Crea una falsa scansione
#home.scannerEffect
home.scannerEffect.title=Falsa scansione
home.scannerEffect.desc=Crea un PDF che sembra scansionato
scannerEffect.tags=scansiona, simula, realistico, converti
#home.fakeScan
home.fakeScan.title=Falsa scansione
home.fakeScan.desc=Crea un PDF che sembra scansionato
fakeScan.tags=scansiona, simula, realistico, converti
# ScannerEffect advanced settings (frontend)
scannerEffect.advancedSettings=Abilita impostazioni di scansione avanzate
scannerEffect.colorspace=Spazio colore
scannerEffect.colorspace.grayscale=Scala di grigi
scannerEffect.colorspace.color=Colore
scannerEffect.border=Bordo (px)
scannerEffect.rotate=Rotazione di base (gradi)
scannerEffect.rotateVariance=Varianza di rotazione (gradi)
scannerEffect.brightness=Luminosità
scannerEffect.contrast=Contrasto
scannerEffect.blur=Sfocatura
scannerEffect.noise=Rumore
scannerEffect.yellowish=Giallastro (simula carta vecchia)
scannerEffect.resolution=Risoluzione (DPI)
# FakeScan advanced settings (frontend)
fakeScan.advancedSettings=Abilita impostazioni di scansione avanzate
fakeScan.colorspace=Spazio colore
fakeScan.colorspace.grayscale=Scala di grigi
fakeScan.colorspace.color=Colore
fakeScan.border=Bordo (px)
fakeScan.rotate=Rotazione di base (gradi)
fakeScan.rotateVariance=Varianza di rotazione (gradi)
fakeScan.brightness=Luminosità
fakeScan.contrast=Contrasto
fakeScan.blur=Sfocatura
fakeScan.noise=Rumore
fakeScan.yellowish=Giallastro (simula carta vecchia)
fakeScan.resolution=Risoluzione (DPI)
# Table of Contents Feature
@@ -1811,41 +1811,41 @@ cookieBanner.preferencesModal.necessary.description=これらのCookieはウェ
cookieBanner.preferencesModal.analytics.title=分析
cookieBanner.preferencesModal.analytics.description=これらのCookieはツールがどのように使用されているかを把握するのに役立ちます。これによりコミュニティが最も重視する機能の開発に集中することができます。ご安心ください。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=フェイクスキャンの生成
#fakeScan
fakeScan.title=フェイクスキャン
fakeScan.header=フェイクスキャン
fakeScan.description=スキャンされたように見えるPDFを作成します
fakeScan.selectPDF=PDFを選択:
fakeScan.quality=スキャン品質
fakeScan.quality.low=
fakeScan.quality.medium=
fakeScan.quality.high=
fakeScan.rotation=回転角度
fakeScan.rotation.none=なし
fakeScan.rotation.slight=微少
fakeScan.rotation.moderate=適度
fakeScan.rotation.severe=多大
fakeScan.submit=フェイクスキャンの生成
#home.scannerEffect
home.scannerEffect.title=フェイクスキャン
home.scannerEffect.desc=スキャンされたように見えるPDFを作成します
scannerEffect.tags=scan,simulate,realistic,convert
#home.fakeScan
home.fakeScan.title=フェイクスキャン
home.fakeScan.desc=スキャンされたように見えるPDFを作成します
fakeScan.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)
# FakeScan advanced settings (frontend)
fakeScan.advancedSettings=高度なスキャン設定を有効にする
fakeScan.colorspace=色空間
fakeScan.colorspace.grayscale=グレースケール
fakeScan.colorspace.color=カラー
fakeScan.border=縁 (px)
fakeScan.rotate=ベースの回転(度)
fakeScan.rotateVariance=回転分散(度)
fakeScan.brightness=輝度
fakeScan.contrast=コントラスト
fakeScan.blur=ぼかし
fakeScan.noise=ノイズ
fakeScan.yellowish=黄色がかった(古い紙をシミュレート)
fakeScan.resolution=解像度 (DPI)
# Table of Contents Feature

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