Compare commits

..
Author SHA1 Message Date
Anthony Stirling fd11b5c89a Delete .github/workflows directory 2025-09-22 13:59:59 +01:00
Anthony Stirling 9753221e31 rootless init 2025-05-20 21:37:03 +01:00
1826 changed files with 10293 additions and 264988 deletions
-22
View File
@@ -1,22 +0,0 @@
{
"permissions": {
"allow": [
"Bash(chmod:*)",
"Bash(mkdir:*)",
"Bash(./gradlew:*)",
"Bash(grep:*)",
"Bash(cat:*)",
"Bash(find:*)",
"Bash(npm test)",
"Bash(npm test:*)",
"Bash(ls:*)",
"Bash(npx tsc:*)",
"Bash(node:*)",
"Bash(npm run dev:*)",
"Bash(sed:*)",
"Bash(npm run typecheck:*)"
],
"deny": [],
"defaultMode": "acceptEdits"
}
}
+2 -15
View File
@@ -49,7 +49,7 @@
"java.configuration.updateBuildConfiguration": "interactive",
"java.format.enabled": true,
"java.format.settings.profile": "GoogleStyle",
"java.format.settings.google.version": "1.28.0",
"java.format.settings.google.version": "1.26.0",
"java.format.settings.google.extra": "--aosp --skip-sorting-imports --skip-javadoc-formatting",
"java.saveActions.cleanup": true,
"java.cleanup.actions": [
@@ -79,17 +79,9 @@
".venv*/",
".vscode/",
"bin/",
"app/core/bin/",
"app/common/bin/",
"app/proprietary/bin/",
"build/",
"app/core/build/",
"app/common/build/",
"app/proprietary/build/",
"configs/",
"app/core/configs/",
"customFiles/",
"app/core/customFiles/",
"docs/",
"exampleYmlFiles",
"gradle/",
@@ -101,9 +93,6 @@
".git-blame-ignore-revs",
".gitattributes",
".gitignore",
"app/core/.gitignore",
"app/common/.gitignore",
"app/proprietary/.gitignore",
".pre-commit-config.yaml"
],
"java.signatureHelp.enabled": true,
@@ -130,9 +119,7 @@
"EditorConfig.EditorConfig", // EditorConfig support for maintaining consistent coding styles
"ms-azuretools.vscode-docker", // Docker extension for Visual Studio Code
"charliermarsh.ruff", // Ruff extension for Ruff language support
"github.vscode-github-actions", // GitHub Actions extension for Visual Studio Code
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
"redhat.vscode-yaml" // YAML extension for Visual Studio Code
"github.vscode-github-actions" // GitHub Actions extension for Visual Studio Code
]
}
},
+2 -17
View File
@@ -1,7 +1,6 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 4
end_of_line = lf
@@ -24,23 +23,9 @@ indent_size = 2
insert_final_newline = false
trim_trailing_whitespace = false
[{*.js,*.jsx,*.mjs,*.ts,*.tsx}]
[*.js]
indent_size = 2
[*.css]
# CSS files typically use an indent size of 2 spaces for better readability and alignment with community standards.
indent_size = 2
[*.{yml,yaml}]
# YAML files use an indent size of 2 spaces to maintain consistency with common YAML formatting practices.
indent_size = 2
[*.yaml]
insert_final_newline = false
trim_trailing_whitespace = false
[*.json]
# JSON files use an indent size of 2 spaces, which is the standard for JSON formatting.
indent_size = 2
[*.jsonc]
# JSONC (JSON with comments) files also follow the standard JSON formatting with an indent size of 2 spaces.
indent_size = 2
-4
View File
@@ -1,9 +1,5 @@
# Formatting
5f771b785130154ed47952635b7acef371ffe0ec
7fa5e130d99227c2202ebddfdd91348176ec0c7b
14d4fbb2a36195eedb034785e5a5ff6a47f268c6
ee8030c1c4148062cde15c49c67d04ef03930c55
fcd41924f5f261febfa9d9a92994671f3ebc97d6
# Normalize files
55d4fda01b2f39f5b7d7b4fda5214bd7ff0fd5dd
+7 -7
View File
@@ -1,10 +1,10 @@
* text=auto eol=lf
# Ignore all JavaScript files in a directory
app/core/src/main/resources/static/pdfjs/* linguist-vendored
app/core/src/main/resources/static/pdfjs/** linguist-vendored
app/core/src/main/resources/static/pdfjs-legacy/* linguist-vendored
app/core/src/main/resources/static/pdfjs-legacy/** linguist-vendored
app/core/src/main/resources/static/css/bootstrap-icons.css linguist-vendored
app/core/src/main/resources/static/css/bootstrap.min.css linguist-vendored
app/core/src/main/resources/static/css/fonts/* linguist-vendored
src/main/resources/static/pdfjs/* linguist-vendored
src/main/resources/static/pdfjs/** linguist-vendored
src/main/resources/static/pdfjs-legacy/* linguist-vendored
src/main/resources/static/pdfjs-legacy/** linguist-vendored
src/main/resources/static/css/bootstrap-icons.css linguist-vendored
src/main/resources/static/css/bootstrap.min.css linguist-vendored
src/main/resources/static/css/fonts/* linguist-vendored
+2 -21
View File
@@ -1,21 +1,2 @@
# All PRs must be approved by Frooodle or Ludy87
* @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
# Backend
/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
#V1 frontend
/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
/app/core/src/main/resources/templates/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
#V2 frontend
/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle
#V2 docker
/docker/backend/** @Frooodle @Ludy87 @DarioGii @Ludy87
/docker/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
/docker/compose/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
#GHA (All users)
/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
# All PRs to V1 must be approved by Frooodle
* @Frooodle @reecebrowne @Ludy87 @DarioGii @ConnorYoh
-33
View File
@@ -1,33 +0,0 @@
name: 'Setup GitHub App Bot'
description: 'Generates a GitHub App Token and configures Git for a bot'
inputs:
app-id:
description: 'GitHub App ID'
required: True
private-key:
description: 'GitHub App Private Key'
required: True
outputs:
token:
description: 'Generated GitHub App Token'
value: ${{ steps.generate-token.outputs.token }}
committer:
description: 'Committer string for Git'
value: "${{ steps.generate-token.outputs.app-slug }}[bot] <${{ steps.generate-token.outputs.app-slug }}[bot]@users.noreply.github.com>"
app-slug:
description: 'GitHub App slug'
value: ${{ steps.generate-token.outputs.app-slug }}
runs:
using: 'composite'
steps:
- name: Generate a GitHub App Token
id: generate-token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
with:
app-id: ${{ inputs.app-id }}
private-key: ${{ inputs.private-key }}
- name: Configure Git
run: |
git config --global user.name "${{ steps.generate-token.outputs.app-slug }}[bot]"
git config --global user.email "${{ steps.generate-token.outputs.app-slug }}[bot]@users.noreply.github.com"
shell: bash
-31
View File
@@ -1,31 +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
- frontend/**
- docker/**
-13
View File
@@ -1,13 +0,0 @@
{
"repo_devs": [
"Frooodle",
"sf298",
"Ludy87",
"LaserKaspar",
"sbplat",
"reecebrowne",
"DarioGii",
"ConnorYoh",
"EthanHealy01"
]
}
-13
View File
@@ -1,13 +0,0 @@
You are a professional software engineer specializing in reviewing pull request titles.
Your job is to analyze a git diff and an existing PR title, then evaluate and improve the PR title.
You must:
- Always return valid JSON
- Only return the JSON response (no Markdown, no formatting)
- Use one of these conventional commit types at the beginning of the title: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test
- Use lowercase only, no emojis, no trailing period
- Ensure the title is between 5 and 72 printable ASCII characters
- Never let spelling or grammar errors affect the rating
- If the PR title is rated 6 or higher and only contains spelling or grammar mistakes, correct it - do not rephrase it
- If the PR title is rated below 6, generate a new, better title based on the diff
-162
View File
@@ -1,162 +0,0 @@
version: 1
labels:
- label: "Bugfix"
title: '^fix(\([^)]*\))?:|^fix:.*'
- label: "enhancement"
title: '^feat(\([^)]*\))?:|^feat:.*'
- label: "build"
title: '^build(\([^)]*\))?:|^build:.*'
- label: "chore"
title: '^chore(\([^)]*\))?:|^chore:.*'
- label: "ci"
title: '^ci(\([^)]*\))?:|^ci:.*'
- label: "ci"
title: '^.*\(ci\):.*'
- label: "perf"
title: '^perf(\([^)]*\))?:|^perf:.*'
- label: "refactor"
title: '^refactor(\([^)]*\))?:|^refactor:.*'
- label: "revert"
title: '^revert(\([^)]*\))?:|^revert:.*'
- label: "style"
title: '^style(\([^)]*\))?:|^style:.*'
- label: "Documentation"
title: '^docs(\([^)]*\))?:|^docs:.*'
- label: "Documentation"
title: '^.*\(docs\):.*'
- label: "dependencies"
title: '^deps(\([^)]*\))?:|^deps:.*'
- label: "dependencies"
title: '^.*\(deps\):.*'
- label: 'API'
title: '.*openapi.*|.*swagger.*|.*api.*'
- label: 'v2'
base-branch: 'V2'
- label: 'Translation'
files:
- 'app/core/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}.properties'
- 'scripts/ignore_translation.toml'
- 'app/core/src/main/resources/templates/fragments/languages.html'
- '.github/scripts/check_language_properties.py'
- label: 'Front End'
files:
- 'app/core/src/main/resources/templates/.*'
- 'app/proprietary/src/main/resources/templates/.*'
- 'app/core/src/main/resources/static/.*'
- 'app/proprietary/src/main/resources/static/.*'
- 'app/core/src/main/java/stirling/software/SPDF/controller/web/.*'
- 'app/core/src/main/java/stirling/software/SPDF/UI/.*'
- 'app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/.*'
- 'frontend/**'
- label: 'Java'
files:
- 'app/common/src/main/java/.*.java'
- 'app/proprietary/src/main/java/.*.java'
- 'app/core/src/main/java/.*.java'
- label: 'Back End'
files:
- 'app/core/src/main/java/stirling/software/SPDF/config/.*'
- 'app/core/src/main/java/stirling/software/SPDF/controller/.*'
- '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/**'
- 'application.properties'
- label: 'Security'
files:
- 'app/proprietary/src/main/java/stirling/software/proprietary/security/.*'
- 'scripts/download-security-jar.sh'
- '.github/workflows/dependency-review.yml'
- '.github/workflows/scorecards.yml'
- label: 'API'
files:
- 'app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java'
- 'app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java'
- 'app/core/src/main/java/stirling/software/SPDF/controller/api/.*'
- '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'
- '.github/workflows/swagger.yml'
- label: 'Documentation'
files:
- '.*.md'
- 'scripts/counter_translation.py'
- 'scripts/ignore_translation.toml'
- label: 'Docker'
files:
- '.github/workflows/build.yml'
- '.github/workflows/push-docker.yml'
- 'Dockerfile'
- 'Dockerfile.fat'
- 'Dockerfile.ultra-lite'
- 'exampleYmlFiles/.*.yml'
- 'scripts/download-security-jar.sh'
- 'scripts/init.sh'
- 'scripts/init-without-ocr.sh'
- 'scripts/installFonts.sh'
- 'test.sh'
- 'test2.sh'
- 'docker/**'
- label: 'Devtools'
files:
- '.devcontainer/.*'
- 'Dockerfile.dev'
- '.vscode/.*'
- '.editorconfig'
- '.pre-commit-config'
- '.github/workflows/pre_commit.yml'
- 'devGuide/.*'
- 'devTools/.*'
- label: 'Test'
files:
- 'app/common/src/test/.*'
- 'app/proprietary/src/test/.*'
- 'app/core/src/test/.*'
- 'testing/.*'
- '.github/workflows/scorecards.yml'
- 'exampleYmlFiles/test_cicd.yml'
- label: 'Github'
files:
- '.github/.*'
- label: 'Gradle'
files:
- 'gradle/.*'
- 'gradlew'
- 'gradlew.bat'
- 'settings.gradle'
- 'build.gradle'
- 'app/common/build.gradle'
- 'app/proprietary/build.gradle'
- 'app/core/build.gradle'
+99
View File
@@ -0,0 +1,99 @@
Translation:
- changed-files:
- any-glob-to-any-file: 'src/main/resources/messages_*_*.properties'
- any-glob-to-any-file: 'scripts/ignore_translation.toml'
- any-glob-to-any-file: 'src/main/resources/templates/fragments/languages.html'
Front End:
- changed-files:
- any-glob-to-any-file: 'src/main/resources/templates/**/*'
- any-glob-to-any-file: 'src/main/resources/static/**/*'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/web/**'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/UI/**/*'
Java:
- changed-files:
- any-glob-to-any-file: 'src/main/java/**/*.java'
Back End:
- changed-files:
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/config/**/*'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/**/*'
- any-glob-to-any-file: 'src/main/resources/settings.yml.template'
- any-glob-to-any-file: 'src/main/resources/application.properties'
- any-glob-to-any-file: 'src/main/resources/banner.txt'
- any-glob-to-any-file: 'scripts/png_to_webp.py'
- any-glob-to-any-file: 'split_photos.py'
Security:
- changed-files:
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/config/interfaces/DatabaseInterface.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/config/security/**/*'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/api/DatabaseController.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/api/EmailController.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/api/H2SQLController.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/web/DatabaseWebController.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/api/UserController.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/api/Email.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/exception/BackupNotFoundException.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/exception/NoProviderFoundExceptionjava'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/provider/**/*'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/AuthenticationType.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/ApiKeyAuthenticationToken.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/AttemptCounter.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/Authority.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/PersistentLogin.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/SessionEntity.java'
- any-glob-to-any-file: 'scripts/download-security-jar.sh'
- any-glob-to-any-file: '.github/workflows/dependency-review.yml'
- any-glob-to-any-file: '.github/workflows/scorecards.yml'
API:
- changed-files:
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/config/OpenApiConfig.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/web/MetricsController.java'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/api/**/*'
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/api/**/*'
- any-glob-to-any-file: 'scripts/png_to_webp.py'
- any-glob-to-any-file: 'split_photos.py'
- any-glob-to-any-file: '.github/workflows/swagger.yml'
Documentation:
- changed-files:
- any-glob-to-any-file: '**/*.md'
- any-glob-to-any-file: 'scripts/counter_translation.py'
- any-glob-to-any-file: 'scripts/ignore_translation.toml'
Docker:
- changed-files:
- any-glob-to-any-file: '.github/workflows/build.yml'
- any-glob-to-any-file: '.github/workflows/push-docker.yml'
- any-glob-to-any-file: 'Dockerfile'
- any-glob-to-any-file: 'Dockerfile.fat'
- any-glob-to-any-file: 'Dockerfile.ultra-lite'
- any-glob-to-any-file: 'exampleYmlFiles/*.yml'
- any-glob-to-any-file: 'scripts/download-security-jar.sh'
- any-glob-to-any-file: 'scripts/init.sh'
- any-glob-to-any-file: 'scripts/init-without-ocr.sh'
- any-glob-to-any-file: 'scripts/installFonts.sh'
- any-glob-to-any-file: 'test.sh'
- any-glob-to-any-file: 'test2.sh'
Devtools:
- changed-files:
- any-glob-to-any-file: '.devcontainer/**/*'
- any-glob-to-any-file: 'Dockerfile.dev'
Test:
- changed-files:
- any-glob-to-any-file: 'cucumber/**/*'
- any-glob-to-any-file: 'src/test/**/*'
- any-glob-to-any-file: 'src/testing/**/*'
- any-glob-to-any-file: '.pre-commit-config'
- any-glob-to-any-file: '.github/workflows/pre_commit.yml'
- any-glob-to-any-file: '.github/workflows/scorecards.yml'
Github:
- changed-files:
- any-glob-to-any-file: '.github/**/*'
-73
View File
@@ -42,7 +42,6 @@
- name: "Front End"
color: "BBD2F1"
description: "Issues or pull requests related to front-end development"
from_name: "frontend"
- name: "github-actions"
description: "Pull requests that update GitHub Actions code"
color: "999999"
@@ -78,12 +77,10 @@
- name: "Translation"
color: "9FABF9"
from_name: "translation"
description: "Issues or pull requests related to translation"
- name: "upstream"
color: "DEDEDE"
- name: "v2"
color: "FFFF00"
description: "Issues or pull requests related to the v2 branch"
- name: "wontfix"
description: "This will not be worked on"
color: "FFFFFF"
@@ -114,73 +111,3 @@
- name: "Devtools"
color: "FF9E1F"
description: "Development tools"
- name: "Bugfix"
color: "FF9E1F"
description: "Pull requests that fix bugs"
- name: "Gradle"
color: "FF9E1F"
description: "Pull requests that update Gradle code"
- name: "build"
color: "1E90FF"
description: "Changes that affect the build system or external dependencies"
- name: "chore"
color: "FFD700"
description: "Routine tasks or maintenance that don't modify src or test files"
- name: "ci"
color: "4682B4"
description: "Changes to CI configuration files and scripts"
- name: "perf"
color: "FF69B4"
description: "Changes that improve performance"
- name: "refactor"
color: "9932CC"
description: "Code changes that neither fix a bug nor add a feature"
- name: "revert"
color: "DC143C"
description: "Reverts a previous commit"
- name: "style"
color: "FFA500"
description: "Changes that do not affect the meaning of the code (formatting, etc.)"
- name: "admin"
color: "195055"
- name: "codex"
color: "ededed"
description: null
- name: "Github"
color: "0052CC"
- name: "github_actions"
color: "000000"
description: "Pull requests that update GitHub Actions code"
- name: "needs-changes"
color: "A65A86"
- name: "on-hold"
color: "2526F9"
- name: "python"
color: "2b67c6"
description: "Pull requests that update Python code"
- name: "size:L"
color: "eb9500"
description: "This PR changes 100-499 lines ignoring generated files."
- name: "size:M"
color: "ebb800"
description: "This PR changes 30-99 lines ignoring generated files."
- name: "size:S"
color: "77b800"
description: "This PR changes 10-29 lines ignoring generated files."
- name: "size:XL"
color: "ff823f"
description: "This PR changes 500-999 lines ignoring generated files."
- name: "size:XS"
color: "00ff00"
description: "This PR changes 0-9 lines ignoring generated files."
- name: "size:XXL"
color: "ffb8b8"
description: "This PR changes 1000+ lines ignoring generated files."
- name: "to research"
color: "FBCA04"
- name: "pr-deployed"
color: "00FF00"
description: "Pull request has been deployed to a test environment"
- name: "codex"
color: "ededed"
description: "chatgpt AI generated code"
+4 -6
View File
@@ -1,6 +1,5 @@
# Description of Changes
<!--
Please provide a summary of the changes, including:
- What was changed
@@ -8,7 +7,6 @@ Please provide a summary of the changes, including:
- Any challenges encountered
Closes #(issue_number)
-->
---
@@ -17,15 +15,15 @@ Closes #(issue_number)
### 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 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/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only)
- [ ] 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)
@@ -33,4 +31,4 @@ Closes #(issue_number)
### 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.
- [ ] 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.
+5 -23
View File
@@ -196,9 +196,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
if len(file_list) == 1:
file_arr = file_list[0].split()
base_dir = os.path.abspath(
os.path.join(os.getcwd(), "app", "core", "src", "main", "resources")
)
base_dir = os.path.abspath(os.path.join(os.getcwd(), "src", "main", "resources"))
for file_path in file_arr:
file_normpath = os.path.normpath(file_path)
@@ -218,20 +216,10 @@ def check_for_differences(reference_file, file_list, branch, actor):
or (
# only local windows command
not file_normpath.startswith(
os.path.join(
"", "app", "core", "src", "main", "resources", "messages_"
)
os.path.join("", "src", "main", "resources", "messages_")
)
and not file_normpath.startswith(
os.path.join(
os.getcwd(),
"app",
"core",
"src",
"main",
"resources",
"messages_",
)
os.path.join(os.getcwd(), "src", "main", "resources", "messages_")
)
)
or not file_normpath.endswith(".properties")
@@ -329,7 +317,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
report.append("## ❌ Overall Check Status: **_Failed_**")
report.append("")
report.append(
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/messages_en_GB.properties)"
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/src/main/resources/messages_en_GB.properties)"
)
else:
report.append("## ✅ Overall Check Status: **_Success_**")
@@ -389,13 +377,7 @@ if __name__ == "__main__":
else:
file_list = glob.glob(
os.path.join(
os.getcwd(),
"app",
"core",
"src",
"main",
"resources",
"messages_*.properties",
os.getcwd(), "src", "main", "resources", "messages_*.properties"
)
)
update_missing_keys(args.reference_file, file_list)
+16 -16
View File
@@ -2,7 +2,7 @@
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' --strip-extras '.github\scripts\requirements_pre_commit.in'
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' '.github\scripts\requirements_pre_commit.in'
#
cfgv==3.4.0 \
--hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \
@@ -12,25 +12,25 @@ distlib==0.3.9 \
--hash=sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87 \
--hash=sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403
# via virtualenv
filelock==3.18.0 \
--hash=sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2 \
--hash=sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de
filelock==3.16.1 \
--hash=sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0 \
--hash=sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435
# via virtualenv
identify==2.6.12 \
--hash=sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2 \
--hash=sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6
identify==2.6.5 \
--hash=sha256:14181a47091eb75b337af4c23078c9d09225cd4c48929f521f3bf16b09d02566 \
--hash=sha256:c10b33f250e5bba374fae86fb57f3adcebf1161bce7cdf92031915fd480c13bc
# via pre-commit
nodeenv==1.9.1 \
--hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \
--hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9
# via pre-commit
platformdirs==4.3.8 \
--hash=sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc \
--hash=sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4
platformdirs==4.3.6 \
--hash=sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907 \
--hash=sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb
# via virtualenv
pre-commit==4.2.0 \
--hash=sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146 \
--hash=sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd
pre-commit==4.0.1 \
--hash=sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2 \
--hash=sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878
# via -r .github\scripts\requirements_pre_commit.in
pyyaml==6.0.2 \
--hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \
@@ -87,7 +87,7 @@ pyyaml==6.0.2 \
--hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \
--hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4
# via pre-commit
virtualenv==20.31.2 \
--hash=sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11 \
--hash=sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af
virtualenv==20.28.1 \
--hash=sha256:412773c85d4dab0409b83ec36f7a6499e72eaf08c80e81e9576bca61831c71cb \
--hash=sha256:5d34ab240fdb5d21549b76f9e8ff3af28252f5499fb6d6f031adac4e5a8c5329
# via pre-commit
+4 -4
View File
@@ -2,9 +2,9 @@
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_sync_readme.txt' --strip-extras '.github\scripts\requirements_sync_readme.in'
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_sync_readme.txt' '.github\scripts\requirements_sync_readme.in'
#
tomlkit==0.13.3 \
--hash=sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1 \
--hash=sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0
tomlkit==0.13.2 \
--hash=sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde \
--hash=sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79
# via -r .github\scripts\requirements_sync_readme.in
-505
View File
@@ -1,505 +0,0 @@
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"
"jbrunton96"
)
# 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"
SWAGGER_SERVER_URL: "https://${V2_PORT}.ssl.stirlingpdf.cloud"
baseUrl: "https://${V2_PORT}.ssl.stirlingpdf.cloud"
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 || true
# Clean up old backend/frontend images (older than 2 weeks)
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || 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 httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
`Your V2 PR with the new frontend/backend split architecture has been deployed!\n\n` +
`🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` +
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\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" || 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
@@ -1,384 +0,0 @@
name: PR Deployment via Comment
on:
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: read
jobs:
check-comment:
runs-on: ubuntu-latest
permissions:
issues: write
if: |
github.event.issue.pull_request &&
(
contains(github.event.comment.body, 'prdeploy') ||
contains(github.event.comment.body, 'deploypr')
)
&&
(
github.event.comment.user.login == 'frooodle' ||
github.event.comment.user.login == 'sf298' ||
github.event.comment.user.login == 'Ludy87' ||
github.event.comment.user.login == 'LaserKaspar' ||
github.event.comment.user.login == 'sbplat' ||
github.event.comment.user.login == 'reecebrowne' ||
github.event.comment.user.login == 'DarioGii' ||
github.event.comment.user.login == 'EthanHealy01' ||
github.event.comment.user.login == 'jbrunton96' ||
github.event.comment.user.login == 'ConnorYoh'
)
outputs:
pr_number: ${{ steps.get-pr.outputs.pr_number }}
pr_repository: ${{ steps.get-pr-info.outputs.repository }}
pr_ref: ${{ steps.get-pr-info.outputs.ref }}
comment_id: ${{ github.event.comment.id }}
disable_security: ${{ steps.check-security-flag.outputs.disable_security }}
enable_pro: ${{ steps.check-pro-flag.outputs.enable_pro }}
enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout PR
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
id: setup-bot
uses: ./.github/actions/setup-bot
continue-on-error: true
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Get PR data
id: get-pr
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const prNumber = context.payload.issue.number;
console.log(`PR Number: ${prNumber}`);
core.setOutput('pr_number', prNumber);
- name: Get PR repository and ref
id: get-pr-info
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.issue.number;
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: prNumber,
});
// For forks, use the full repository name, for internal PRs use the current repo
const repository = pr.head.repo.fork ? pr.head.repo.full_name : `${owner}/${repo}`;
console.log(`PR Repository: ${repository}`);
console.log(`PR Branch: ${pr.head.ref}`);
core.setOutput('repository', repository);
core.setOutput('ref', pr.head.ref);
- name: Check for security/login flag
id: check-security-flag
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
if [[ "$COMMENT_BODY" == *"security"* ]] || [[ "$COMMENT_BODY" == *"login"* ]]; then
echo "Security flags detected in comment"
echo "disable_security=false" >> $GITHUB_OUTPUT
else
echo "No security flags detected in comment"
echo "disable_security=true" >> $GITHUB_OUTPUT
fi
- name: Check for pro flag
id: check-pro-flag
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
if [[ "$COMMENT_BODY" == *"pro"* ]] || [[ "$COMMENT_BODY" == *"premium"* ]]; then
echo "pro flags detected in comment"
echo "enable_pro=true" >> $GITHUB_OUTPUT
echo "enable_enterprise=false" >> $GITHUB_OUTPUT
elif [[ "$COMMENT_BODY" == *"enterprise"* ]]; then
echo "enterprise flags detected in comment"
echo "enable_enterprise=true" >> $GITHUB_OUTPUT
echo "enable_pro=true" >> $GITHUB_OUTPUT
else
echo "No pro or enterprise flags detected in comment"
echo "enable_pro=false" >> $GITHUB_OUTPUT
echo "enable_enterprise=false" >> $GITHUB_OUTPUT
fi
- name: Add 'in_progress' reaction to comment
id: add-eyes-reaction
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
console.log(`Adding eyes reaction to comment ID: ${context.payload.comment.id}`);
try {
const { data: reaction } = await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'eyes'
});
console.log(`Added reaction with ID: ${reaction.id}`);
return { success: true, id: reaction.id };
} catch (error) {
console.error(`Failed to add reaction: ${error.message}`);
console.error(error);
return { success: false, error: error.message };
}
deploy-pr:
needs: check-comment
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout PR
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
id: setup-bot
uses: ./.github/actions/setup-bot
continue-on-error: true
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Checkout PR
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
repository: ${{ needs.check-comment.outputs.pr_repository }}
ref: ${{ needs.check-comment.outputs.pr_ref }}
token: ${{ steps.setup-bot.outputs.token }}
- name: Set up JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
- name: Run Gradle Command
run: |
if [ "${{ needs.check-comment.outputs.disable_security }}" == "true" ]; then
export DISABLE_ADDITIONAL_FEATURES=true
else
export DISABLE_ADDITIONAL_FEATURES=false
fi
./gradlew clean build
env:
STIRLING_PDF_DESKTOP_UI: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Login to Docker Hub
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Build and push PR-specific image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }}
build-args: VERSION_TAG=alpha
platforms: linux/amd64
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key
- name: Deploy to VPS
id: deploy
run: |
# Set security settings based on flags
if [ "${{ needs.check-comment.outputs.disable_security }}" == "false" ]; then
DISABLE_ADDITIONAL_FEATURES="false"
LOGIN_SECURITY="true"
SECURITY_STATUS="🔒 Security Enabled"
else
DISABLE_ADDITIONAL_FEATURES="true"
LOGIN_SECURITY="false"
SECURITY_STATUS="Security Disabled"
fi
# Set pro/enterprise settings (enterprise implies pro)
if [ "${{ needs.check-comment.outputs.enable_enterprise }}" == "true" ]; then
PREMIUM_ENABLED="true"
PREMIUM_KEY="${{ secrets.ENTERPRISE_KEY }}"
PREMIUM_PROFEATURES_AUDIT_ENABLED="true"
elif [ "${{ needs.check-comment.outputs.enable_pro }}" == "true" ]; then
PREMIUM_ENABLED="true"
PREMIUM_KEY="${{ secrets.PREMIUM_KEY }}"
PREMIUM_PROFEATURES_AUDIT_ENABLED="true"
else
PREMIUM_ENABLED="false"
PREMIUM_KEY=""
PREMIUM_PROFEATURES_AUDIT_ENABLED="false"
fi
# First create the docker-compose content locally
cat > docker-compose.yml << EOF
version: '3.3'
services:
stirling-pdf:
container_name: stirling-pdf-pr-${{ needs.check-comment.outputs.pr_number }}
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }}
ports:
- "${{ needs.check-comment.outputs.pr_number }}:8080"
volumes:
- /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/data:/usr/share/tessdata:rw
- /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/config:/configs:rw
- /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "${DISABLE_ADDITIONAL_FEATURES}"
SECURITY_ENABLELOGIN: "${LOGIN_SECURITY}"
SYSTEM_DEFAULTLOCALE: en-GB
UI_APPNAME: "Stirling-PDF PR#${{ needs.check-comment.outputs.pr_number }}"
UI_HOMEDESCRIPTION: "PR#${{ needs.check-comment.outputs.pr_number }} for Stirling-PDF Latest"
UI_APPNAMENAVBAR: "PR#${{ needs.check-comment.outputs.pr_number }}"
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
PREMIUM_KEY: "${PREMIUM_KEY}"
PREMIUM_ENABLED: "${PREMIUM_ENABLED}"
PREMIUM_PROFEATURES_AUDIT_ENABLED: "${PREMIUM_PROFEATURES_AUDIT_ENABLED}"
restart: on-failure:5
EOF
# Then copy the file and execute commands
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose.yml
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
# Create PR-specific directories
mkdir -p /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/{data,config,logs}
# Move docker-compose file to correct location
mv /tmp/docker-compose.yml /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/docker-compose.yml
# Start or restart the container
cd /stirling/PR-${{ needs.check-comment.outputs.pr_number }}
docker-compose pull
docker-compose up -d
ENDSSH
# Set output for use in PR comment
echo "security_status=${SECURITY_STATUS}" >> $GITHUB_ENV
- name: Add success reaction to comment
if: success()
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
console.log(`Adding rocket reaction to comment ID: ${{ needs.check-comment.outputs.comment_id }}`);
try {
const { data: reaction } = await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: ${{ needs.check-comment.outputs.comment_id }},
content: 'rocket'
});
console.log(`Added rocket reaction with ID: ${reaction.id}`);
} catch (error) {
console.error(`Failed to add reaction: ${error.message}`);
console.error(error);
}
// add label to PR
const prNumber = ${{ needs.check-comment.outputs.pr_number }};
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: ['pr-deployed']
});
console.log(`Added 'pr-deployed' label to PR #${prNumber}`);
} catch (error) {
console.error(`Failed to add label to PR: ${error.message}`);
console.error(error);
}
- name: Add failure reaction to comment
if: failure()
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
console.log(`Adding -1 reaction to comment ID: ${{ needs.check-comment.outputs.comment_id }}`);
try {
const { data: reaction } = await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: ${{ needs.check-comment.outputs.comment_id }},
content: '-1'
});
console.log(`Added -1 reaction with ID: ${reaction.id}`);
} catch (error) {
console.error(`Failed to add reaction: ${error.message}`);
console.error(error);
}
- name: Post deployment URL to PR
if: success()
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { GITHUB_REPOSITORY } = process.env;
const [repoOwner, repoName] = GITHUB_REPOSITORY.split('/');
const prNumber = ${{ needs.check-comment.outputs.pr_number }};
const securityStatus = process.env.security_status || "Security Disabled";
const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${prNumber}`;
const commentBody = `## 🚀 PR Test Deployment\n\n` +
`Your PR has been deployed for testing!\n\n` +
`🔗 **Test URL:** [${deploymentUrl}](${deploymentUrl})\n` +
`${securityStatus}\n\n` +
`This deployment will be automatically cleaned up when the PR is closed.\n\n`;
await github.rest.issues.createComment({
owner: repoOwner,
repo: repoName,
issue_number: prNumber,
body: commentBody
});
- name: Cleanup temporary files
if: always()
run: |
echo "Cleaning up temporary files..."
rm -f ../private.key docker-compose.yml
echo "Cleanup complete."
continue-on-error: true
-141
View File
@@ -1,141 +0,0 @@
name: PR Deployment cleanup
on:
pull_request_target:
types: [opened, synchronize, reopened, closed]
permissions:
contents: read
env:
SERVER_IP: ${{ secrets.VPS_IP }} # Add this to your GitHub secrets
CLEANUP_PERFORMED: "false" # Add flag to track if cleanup occurred
jobs:
cleanup:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout PR
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
id: setup-bot
uses: ./.github/actions/setup-bot
continue-on-error: true
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Remove 'pr-deployed' label if present
id: remove-label-comment
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const prNumber = ${{ github.event.pull_request.number }};
const owner = context.repo.owner;
const repo = context.repo.repo;
// Get all labels on the PR
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
owner,
repo,
issue_number: prNumber
});
const hasLabel = labels.some(label => label.name === 'pr-deployed');
if (hasLabel) {
console.log("Label 'pr-deployed' found. Removing...");
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: 'pr-deployed'
});
} else {
console.log("Label 'pr-deployed' not found. Nothing to do.");
}
// Find existing bot comments about the deployment
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber
});
const deploymentComments = comments.filter(c =>
c.body?.includes("## 🚀 PR Test Deployment") &&
c.user?.type === "Bot"
);
if (deploymentComments.length > 0) {
for (const comment of deploymentComments) {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id
});
console.log(`Deleted deployment comment (ID: ${comment.id})`);
}
} else {
console.log("No matching deployment comments found.");
}
// Set flag if either label or comment was present
const hasDeploymentComment = deploymentComments.length > 0;
core.setOutput('present', (hasLabel || hasDeploymentComment) ? 'true' : 'false');
- name: Set up SSH
if: steps.remove-label-comment.outputs.present == 'true'
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key
- name: Cleanup PR deployment
if: steps.remove-label-comment.outputs.present == 'true'
id: cleanup
run: |
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
if [ -d "/stirling/PR-${{ github.event.pull_request.number }}" ]; then
echo "Found PR directory, proceeding with cleanup..."
# Stop and remove containers
cd /stirling/PR-${{ github.event.pull_request.number }}
docker-compose down || true
# Go back to root before removal
cd /
# Remove PR-specific directories
rm -rf /stirling/PR-${{ github.event.pull_request.number }}
# Remove the Docker image
docker rmi --no-prune ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ github.event.pull_request.number }} || true
echo "PERFORMED_CLEANUP"
else
echo "PR directory not found, nothing to clean up"
echo "NO_CLEANUP_NEEDED"
fi
ENDSSH
- name: Cleanup temporary files
if: always()
run: |
echo "Cleaning up temporary files..."
rm -f ../private.key
echo "Cleanup complete."
continue-on-error: true
-228
View File
@@ -1,228 +0,0 @@
name: AI - PR Title Review
on:
pull_request:
types: [opened, edited]
branches: [main]
permissions: # required for secure-repo hardening
contents: read
jobs:
ai-title-review:
permissions:
contents: read
pull-requests: write
models: read
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
fetch-depth: 0
- name: Configure Git to suppress detached HEAD warning
run: git config --global advice.detachedHead false
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
id: setup-bot
uses: ./.github/actions/setup-bot
continue-on-error: true
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Check if actor is repo developer
id: actor
run: |
if [[ "${{ github.actor }}" == *"[bot]" ]]; then
echo "PR opened by a bot skipping AI title review."
echo "is_repo_dev=false" >> $GITHUB_OUTPUT
exit 0
fi
if [ ! -f .github/config/repo_devs.json ]; then
echo "Error: .github/config/repo_devs.json not found" >&2
exit 1
fi
# Validate JSON and extract repo_devs
REPO_DEVS=$(jq -r '.repo_devs[]' .github/config/repo_devs.json 2>/dev/null || { echo "Error: Invalid JSON in repo_devs.json" >&2; exit 1; })
# Convert developer list into Bash array
mapfile -t DEVS_ARRAY <<< "$REPO_DEVS"
if [[ " ${DEVS_ARRAY[*]} " == *" ${{ github.actor }} "* ]]; then
echo "is_repo_dev=true" >> $GITHUB_OUTPUT
else
echo "is_repo_dev=false" >> $GITHUB_OUTPUT
fi
- name: Get PR diff
if: steps.actor.outputs.is_repo_dev == 'true'
id: get_diff
run: |
git fetch origin ${{ github.base_ref }}
git diff origin/${{ github.base_ref }}...HEAD | head -n 10000 | grep -vP '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x{202E}\x{200B}]' > pr.diff
echo "diff<<EOF" >> $GITHUB_OUTPUT
cat pr.diff >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Check and sanitize PR title
if: steps.actor.outputs.is_repo_dev == 'true'
id: sanitize_pr_title
env:
PR_TITLE_RAW: ${{ github.event.pull_request.title }}
run: |
# Sanitize PR title: max 72 characters, only printable characters
PR_TITLE=$(echo "$PR_TITLE_RAW" | tr -d '\n\r' | head -c 72 | sed 's/[^[:print:]]//g')
if [[ ${#PR_TITLE} -lt 5 ]]; then
echo "PR title is too short. Must be at least 5 characters." >&2
fi
echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT
- name: AI PR Title Analysis
if: steps.actor.outputs.is_repo_dev == 'true'
id: ai-title-analysis
uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1.2.8
with:
model: openai/gpt-4o
system-prompt-file: ".github/config/system-prompt.txt"
prompt: |
Based on the following input data:
{
"diff": "${{ steps.get_diff.outputs.diff }}",
"pr_title": "${{ steps.sanitize_pr_title.outputs.pr_title }}"
}
Respond ONLY with valid JSON in the format:
{
"improved_rating": <0-10>,
"improved_ai_title_rating": <0-10>,
"improved_title": "<ai generated title>"
}
- name: Validate and set SCRIPT_OUTPUT
if: steps.actor.outputs.is_repo_dev == 'true'
run: |
cat <<EOF > ai_response.json
${{ steps.ai-title-analysis.outputs.response }}
EOF
# Validate JSON structure
jq -e '
(keys | sort) == ["improved_ai_title_rating", "improved_rating", "improved_title"] and
(.improved_rating | type == "number" and . >= 0 and . <= 10) and
(.improved_ai_title_rating | type == "number" and . >= 0 and . <= 10) and
(.improved_title | type == "string")
' ai_response.json
if [ $? -ne 0 ]; then
echo "Invalid AI response format" >&2
cat ai_response.json >&2
exit 1
fi
# Parse JSON fields
IMPROVED_RATING=$(jq -r '.improved_rating' ai_response.json)
IMPROVED_TITLE=$(jq -r '.improved_title' ai_response.json)
# Limit comment length to 1000 characters
COMMENT=$(cat <<EOF
## 🤖 AI PR Title Suggestion
**PR-Title Rating**: $IMPROVED_RATING/10
### ⬇️ Suggested Title (copy & paste):
\`\`\`
$IMPROVED_TITLE
\`\`\`
---
*Generated by GitHub Models AI*
EOF
)
echo "$COMMENT" > /tmp/ai-title-comment.md
# Log input and output to the GitHub Step Summary
echo "### 🤖 AI PR Title Analysis" >> $GITHUB_STEP_SUMMARY
echo "### Input PR Title" >> $GITHUB_STEP_SUMMARY
echo '```bash' >> $GITHUB_STEP_SUMMARY
echo "${{ steps.sanitize_pr_title.outputs.pr_title }}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo '### AI Response (raw JSON)' >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
cat ai_response.json >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- name: Post comment on PR if needed
if: steps.actor.outputs.is_repo_dev == 'true'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
continue-on-error: true
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const fs = require('fs');
const body = fs.readFileSync('/tmp/ai-title-comment.md', 'utf8');
const { GITHUB_REPOSITORY } = process.env;
const [owner, repo] = GITHUB_REPOSITORY.split('/');
const issue_number = context.issue.number;
const ratingMatch = body.match(/\*\*PR-Title Rating\*\*: (\d+)\/10/);
const rating = ratingMatch ? parseInt(ratingMatch[1], 10) : null;
const expectedActor = "${{ steps.setup-bot.outputs.app-slug }}[bot]";
const comments = await github.rest.issues.listComments({ owner, repo, issue_number });
const existing = comments.data.find(c =>
c.user?.login === expectedActor &&
c.body.includes("## 🤖 AI PR Title Suggestion")
);
if (rating === null) {
console.log("No rating found in AI response skipping.");
return;
}
if (rating <= 5) {
if (existing) {
await github.rest.issues.updateComment({
owner, repo,
comment_id: existing.id,
body
});
console.log("Updated existing suggestion comment.");
} else {
await github.rest.issues.createComment({
owner, repo, issue_number,
body
});
console.log("Created new suggestion comment.");
}
} else {
const praise = `## 🤖 AI PR Title Suggestion\n\nGreat job! The current PR title is clear and well-structured.\n\n✅ No suggestions needed.\n\n---\n*Generated by GitHub Models AI*`;
if (existing) {
await github.rest.issues.updateComment({
owner, repo,
comment_id: existing.id,
body: praise
});
console.log("Replaced suggestion with praise.");
} else {
console.log("Rating > 5 and no existing comment skipping comment.");
}
}
- name: is not repo dev
if: steps.actor.outputs.is_repo_dev != 'true'
run: |
exit 0 # Skip the AI title review for non-repo developers
- name: Clean up
if: always()
run: |
rm -f pr.diff ai_response.json /tmp/ai-title-comment.md
echo "Cleaned up temporary files."
continue-on-error: true # Ensure cleanup runs even if previous steps fail
-35
View File
@@ -1,35 +0,0 @@
name: "Auto Pull Request Labeler V2"
on:
pull_request_target:
types: [opened, synchronize]
permissions:
contents: read
jobs:
labeler:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.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 }}
- uses: srvaroa/labeler@0a20eccb8c94a1ee0bed5f16859aece1c45c3e55 # v1.13.0
with:
config_path: .github/labeler-config-srvaroa.yml
use_local_config: false
fail_on_error: true
env:
GITHUB_TOKEN: "${{ steps.setup-bot.outputs.token }}"
-313
View File
@@ -1,313 +0,0 @@
name: Build and Test Workflow
on:
pull_request:
branches: ["main", "V2", "V2-gha"]
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:
contents: read
jobs:
files-changed:
name: detect what files changed
runs-on: ubuntu-latest
timeout-minutes: 3
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@v4.3.0
- name: Check for file changes
uses: dorny/paths-filter@v3.0.2
id: changes
with:
filters: .github/config/.files.yaml
build:
runs-on: ubuntu-latest
permissions:
actions: read
security-events: write
strategy:
fail-fast: false
matrix:
jdk-version: [17, 21]
spring-security: [true, false]
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@v4.3.0
- name: Set up JDK ${{ matrix.jdk-version }}
uses: actions/setup-java@v4.7.1
with:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4.4.2
with:
gradle-version: 8.14
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
run: ./gradlew clean build -PnoSpotless
env:
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.spring-security }}
- name: Check Test Reports Exist
if: always()
run: |
declare -a dirs=(
"app/core/build/reports/tests/"
"app/core/build/test-results/"
"app/common/build/reports/tests/"
"app/common/build/test-results/"
"app/proprietary/build/reports/tests/"
"app/proprietary/build/test-results/"
)
for dir in "${dirs[@]}"; do
if [ ! -d "$dir" ]; then
echo "Missing $dir"
exit 1
fi
done
- name: Upload Test Reports
if: always()
uses: actions/upload-artifact@v4.6.2
with:
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
path: |
app/**/build/reports/tests/
app/**/build/test-results/
app/**/build/reports/problems/
build/reports/problems/
retention-days: 3
if-no-files-found: warn
check-generateOpenApiDocs:
if: needs.files-changed.outputs.openapi == 'true'
needs: [files-changed]
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@v4.3.0
- name: Set up JDK 17
uses: actions/setup-java@v4.7.1
with:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@v4.4.2
- name: Generate OpenAPI documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
env:
DISABLE_ADDITIONAL_FEATURES: true
- name: Upload OpenAPI Documentation
uses: actions/upload-artifact@v4.6.2
with:
name: openapi-docs
path: ./SwaggerDoc.json
frontend-validation:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@v2.12.2
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@v4.2.2
- name: Set up Node.js
uses: actions/setup-node@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: Lint frontend
run: cd frontend && npm run lint
- name: Build frontend
run: cd frontend && npm run build
- name: Run frontend tests
run: cd frontend && npm run test -- --run
- name: Upload frontend build artifacts
uses: actions/upload-artifact@v4.6.2
with:
name: frontend-build
path: frontend/dist/
retention-days: 3
check-licence:
if: needs.files-changed.outputs.build == 'true'
needs: [files-changed, build]
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@v4.3.0
- name: Set up JDK 17
uses: actions/setup-java@v4.7.1
with:
java-version: "17"
distribution: "temurin"
- name: check the licenses for compatibility
run: ./gradlew clean checkLicense
- name: FAILED - check the licenses for compatibility
if: failure()
uses: actions/upload-artifact@v4.6.2
with:
name: dependencies-without-allowed-license.json
path: build/reports/dependency-license/dependencies-without-allowed-license.json
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 &&
# (
# contains(github.event.pull_request.labels.*.name, 'Front End') ||
# contains(github.event.pull_request.labels.*.name, 'Java') ||
# contains(github.event.pull_request.labels.*.name, 'Back End') ||
# contains(github.event.pull_request.labels.*.name, 'Security') ||
# contains(github.event.pull_request.labels.*.name, 'API') ||
# contains(github.event.pull_request.labels.*.name, 'Docker') ||
# contains(github.event.pull_request.labels.*.name, 'Test')
# )
# )
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Set up Java 17
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Install Docker Compose
run: |
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.37.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
cache: 'pip' # caching pip dependencies
cache-dependency-path: ./testing/cucumber/requirements.txt
- name: Pip requirements
run: |
pip install --require-hashes -r ./testing/cucumber/requirements.txt
- name: Run Docker Compose Tests
run: |
chmod +x ./testing/test_webpages.sh
chmod +x ./testing/test.sh
chmod +x ./testing/test_disabledEndpoints.sh
./testing/test.sh
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@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- 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@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
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: ./docker/backend/${{ 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
-292
View File
@@ -1,292 +0,0 @@
name: Check Properties Files on PR
on:
pull_request_target:
types: [opened, synchronize, reopened]
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
jobs:
check-files:
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
permissions:
issues: write # Allow posting comments on issues/PRs
pull-requests: write # Allow writing to pull requests
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout main branch first
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.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: Get PR data
id: get-pr-data
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const prNumber = context.payload.pull_request.number;
const repoOwner = context.payload.repository.owner.login;
const repoName = context.payload.repository.name;
const branch = context.payload.pull_request.head.ref;
console.log(`PR Number: ${prNumber}`);
console.log(`Repo Owner: ${repoOwner}`);
console.log(`Repo Name: ${repoName}`);
console.log(`Branch: ${branch}`);
core.setOutput("pr_number", prNumber);
core.setOutput("repo_owner", repoOwner);
core.setOutput("repo_name", repoName);
core.setOutput("branch", branch);
continue-on-error: true
- name: Fetch PR changed files
id: fetch-pr-changes
env:
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
run: |
echo "Fetching PR changed files..."
echo "Getting list of changed files from PR..."
# Check if PR number exists
if [ -z "${{ steps.get-pr-data.outputs.pr_number }}" ]; then
echo "Error: PR number is empty"
exit 1
fi
# Get changed files and filter for properties files, handle case where no matches are found
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^app/core/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$' > changed_files.txt || echo "No matching properties files found in PR"
# Check if any files were found
if [ ! -s changed_files.txt ]; then
echo "No properties files changed in this PR"
echo "Workflow will exit early as no relevant files to check"
exit 0
fi
echo "Found $(wc -l < changed_files.txt) matching properties files"
- name: Determine reference file test
id: determine-file
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const fs = require("fs");
const path = require("path");
const prNumber = ${{ steps.get-pr-data.outputs.pr_number }};
const repoOwner = "${{ steps.get-pr-data.outputs.repo_owner }}";
const repoName = "${{ steps.get-pr-data.outputs.repo_name }}";
const prRepoOwner = "${{ github.event.pull_request.head.repo.owner.login }}";
const prRepoName = "${{ github.event.pull_request.head.repo.name }}";
const branch = "${{ steps.get-pr-data.outputs.branch }}";
console.log(`Determining reference file for PR #${prNumber}`);
// Validate inputs
const validateInput = (input, regex, name) => {
if (!regex.test(input)) {
throw new Error(`Invalid ${name}: ${input}`);
}
};
validateInput(repoOwner, /^[a-zA-Z0-9_-]+$/, "repository owner");
validateInput(repoName, /^[a-zA-Z0-9._-]+$/, "repository name");
validateInput(branch, /^[a-zA-Z0-9._/-]+$/, "branch name");
// Get the list of changed files in the PR
const { data: files } = await github.rest.pulls.listFiles({
owner: repoOwner,
repo: repoName,
pull_number: prNumber,
});
// Filter for relevant files based on the PR changes
const changedFiles = files
.filter(file =>
file.status !== "removed" &&
/^app\/core\/src\/main\/resources\/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$/.test(file.filename)
)
.map(file => file.filename);
console.log("Changed files:", changedFiles);
// Create a temporary directory for PR files
const tempDir = "pr-branch";
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
// Download and save each changed file
for (const file of changedFiles) {
const { data: fileContent } = await github.rest.repos.getContent({
owner: prRepoOwner,
repo: prRepoName,
path: file,
ref: branch,
});
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
const filePath = path.join(tempDir, file);
const dirPath = path.dirname(filePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
fs.writeFileSync(filePath, content);
console.log(`Saved file: ${filePath}`);
}
// Output the list of changed files for further processing
const fileList = changedFiles.join(" ");
core.exportVariable("FILES_LIST", fileList);
console.log("Files saved and listed in FILES_LIST.");
// Determine reference file
let referenceFilePath;
if (changedFiles.includes("app/core/src/main/resources/messages_en_GB.properties")) {
console.log("Using PR branch reference file.");
const { data: fileContent } = await github.rest.repos.getContent({
owner: prRepoOwner,
repo: prRepoName,
path: "app/core/src/main/resources/messages_en_GB.properties",
ref: branch,
});
referenceFilePath = "pr-branch-messages_en_GB.properties";
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
fs.writeFileSync(referenceFilePath, content);
} else {
console.log("Using main branch reference file.");
const { data: fileContent } = await github.rest.repos.getContent({
owner: repoOwner,
repo: repoName,
path: "app/core/src/main/resources/messages_en_GB.properties",
ref: "main",
});
referenceFilePath = "main-branch-messages_en_GB.properties";
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
fs.writeFileSync(referenceFilePath, content);
}
console.log(`Reference file path: ${referenceFilePath}`);
core.exportVariable("REFERENCE_FILE", referenceFilePath);
- name: Run Python script to check files
id: run-check
run: |
echo "Running Python script to check files..."
python .github/scripts/check_language_properties.py \
--actor ${{ github.event.pull_request.user.login }} \
--reference-file "${REFERENCE_FILE}" \
--branch "pr-branch" \
--files "${FILES_LIST[@]}" > result.txt
continue-on-error: true # Continue the job even if this step fails
- name: Capture output
id: capture-output
run: |
if [ -f result.txt ] && [ -s result.txt ]; then
echo "Test, capturing output..."
SCRIPT_OUTPUT=$(cat result.txt)
echo "SCRIPT_OUTPUT<<EOF" >> $GITHUB_ENV
echo "$SCRIPT_OUTPUT" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
echo "${SCRIPT_OUTPUT}"
# Determine job failure based on script output
if [[ "$SCRIPT_OUTPUT" == *"❌"* ]]; then
echo "FAIL_JOB=true" >> $GITHUB_ENV
else
echo "FAIL_JOB=false" >> $GITHUB_ENV
fi
else
echo "No update found."
echo "SCRIPT_OUTPUT=" >> $GITHUB_ENV
echo "FAIL_JOB=false" >> $GITHUB_ENV
fi
- name: Post comment on PR
if: env.SCRIPT_OUTPUT != ''
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { GITHUB_REPOSITORY, SCRIPT_OUTPUT } = process.env;
const [repoOwner, repoName] = GITHUB_REPOSITORY.split('/');
const issueNumber = context.issue.number;
// Find existing comment
const comments = await github.rest.issues.listComments({
owner: repoOwner,
repo: repoName,
issue_number: issueNumber
});
const comment = comments.data.find(c => c.body.includes("## 🚀 Translation Verification Summary"));
// Only update or create comments by the action user
const expectedActor = "${{ steps.setup-bot.outputs.app-slug }}[bot]";
if (comment && comment.user.login === expectedActor) {
// Update existing comment
await github.rest.issues.updateComment({
owner: repoOwner,
repo: repoName,
comment_id: comment.id,
body: `## 🚀 Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
});
console.log("Updated existing comment.");
} else if (!comment) {
// Create new comment if no existing comment is found
await github.rest.issues.createComment({
owner: repoOwner,
repo: repoName,
issue_number: issueNumber,
body: `## 🚀 Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
});
console.log("Created new comment.");
} else {
console.log("Comment update attempt denied. Actor does not match.");
}
- name: Fail job if errors found
if: env.FAIL_JOB == 'true'
run: |
echo "Failing the job because errors were detected."
exit 1
- name: Cleanup temporary files
if: always()
run: |
echo "Cleaning up temporary files..."
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
-79
View File
@@ -1,79 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
#disable for now
#on:
# push:
# branches: ["main"]
# pull_request:
# The branches below must be a subset of the branches above
# branches: ["main"]
# schedule:
# - cron: "0 0 * * 1"
permissions:
contents: read
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: ["java"]
# CodeQL supports [ $supported-codeql-languages ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Harden Runner
uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
with:
category: "/language:${{matrix.language}}"
-27
View File
@@ -1,27 +0,0 @@
# Dependency Review Action
#
# This Action will scan dependency manifest files that change as part of a Pull Request,
# surfacing known-vulnerable versions of the packages declared or updated in the PR.
# Once installed, if the workflow run is marked as required,
# PRs introducing known-vulnerable packages will be blocked from merging.
#
# Source repository: https://github.com/actions/dependency-review-action
name: "Dependency Review"
on: [pull_request]
permissions:
contents: read
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: "Checkout Repository"
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: "Dependency Review"
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
-189
View File
@@ -1,189 +0,0 @@
name: Auto V2 Deploy on Push
on:
push:
branches:
- V2
- deploy-on-v2-commit
permissions:
contents: read
jobs:
deploy-v2-on-push:
runs-on: ubuntu-latest
concurrency:
group: deploy-v2-push-V2
cancel-in-progress: true
steps:
- name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Get commit hashes for frontend and backend
id: commit-hashes
run: |
# Get last commit that touched the frontend folder, docker/frontend, or docker/compose
FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "")
if [ -z "$FRONTEND_HASH" ]; then
FRONTEND_HASH="no-frontend-changes"
fi
# Get last commit that touched backend code, docker/backend, or docker/compose
BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
if [ -z "$BACKEND_HASH" ]; then
BACKEND_HASH="no-backend-changes"
fi
echo "Frontend hash: $FRONTEND_HASH"
echo "Backend hash: $BACKEND_HASH"
echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
# Short hashes for tags
if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
else
echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
fi
if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
echo "backend_short=no-backend" >> $GITHUB_OUTPUT
else
echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT
fi
- name: Check if frontend image exists
id: check-frontend
run: |
if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Frontend image already exists, skipping build"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Frontend image needs to be built"
fi
- name: Check if backend image exists
id: check-backend
run: |
if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Backend image already exists, skipping build"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Backend image needs to be built"
fi
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Build and push frontend image
if: steps.check-frontend.outputs.exists == 'false'
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/frontend/Dockerfile
push: true
tags: |
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Build and push backend image
if: steps.check-backend.outputs.exists == 'false'
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/backend/Dockerfile
push: true
tags: |
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-latest
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
chmod 600 ../private.key
- name: Deploy to VPS on port 3000
run: |
export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml
cat > $UNIQUE_NAME << EOF
version: '3.3'
services:
backend:
container_name: stirling-v2-backend
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
ports:
- "13000:8080"
volumes:
- /stirling/V2/data:/usr/share/tessdata:rw
- /stirling/V2/config:/configs:rw
- /stirling/V2/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false"
SYSTEM_DEFAULTLOCALE: en-GB
UI_APPNAME: "Stirling-PDF V2"
UI_HOMEDESCRIPTION: "V2 Frontend/Backend Split"
UI_APPNAMENAVBAR: "V2 Deployment"
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
SWAGGER_SERVER_URL: "https://demo.stirlingpdf.cloud"
baseUrl: "https://demo.stirlingpdf.cloud"
restart: on-failure:5
frontend:
container_name: stirling-v2-frontend
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
ports:
- "3000:80"
environment:
VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:13000"
depends_on:
- backend
restart: on-failure:5
EOF
# Copy to remote with unique name
scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/$UNIQUE_NAME
# SSH and rename/move atomically to avoid interference
ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
mkdir -p /stirling/V2/{data,config,logs}
mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml
cd /stirling/V2
docker-compose down || true
docker-compose pull
docker-compose up -d
docker system prune -af --volumes || true
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
ENDSSH
- name: Cleanup temporary files
if: always()
run: |
rm -f ../private.key
@@ -1,217 +0,0 @@
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 }}
-102
View File
@@ -1,102 +0,0 @@
name: License Report Workflow
on:
push:
branches:
- main
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
jobs:
generate-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@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Check out code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
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 JDK 17
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
- name: Check licenses for compatibility
run: ./gradlew clean checkLicense
- name: Upload artifact on failure
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: dependencies-without-allowed-license.json
path: build/reports/dependency-license/dependencies-without-allowed-license.json
retention-days: 3
- name: Move and rename license file
run: |
mv build/reports/dependency-license/index.json app/core/src/main/resources/static/3rdPartyLicenses.json
- name: Commit changes
run: |
git add app/core/src/main/resources/static/3rdPartyLicenses.json
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
- name: Create Pull Request
id: cpr
if: env.CHANGES_DETECTED == 'true'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: "Update 3rd Party Licenses"
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: update-3rd-party-licenses
title: "Update 3rd Party Licenses"
body: |
Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
labels: Licenses,github-actions
draft: false
delete-branch: true
sign-commits: true
- name: Enable Pull Request Automerge
if: steps.cpr.outputs.pull-request-operation == 'created'
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
env:
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
-30
View File
@@ -1,30 +0,0 @@
name: Manage labels
on:
schedule:
- cron: "30 20 * * *"
permissions:
contents: read
jobs:
labeler:
name: Labeler
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Check out the repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Run Labeler
uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 # v5.3.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
yaml-file: .github/labels.yml
skip-delete: true
-318
View File
@@ -1,318 +0,0 @@
name: Test Installers Build
on:
workflow_dispatch:
inputs:
test_mode:
description: "Run in test mode (skip release step)"
required: false
default: "false"
release:
types: [created]
permissions:
contents: read
jobs:
read_versions:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.versionNumber.outputs.versionNumber }}
versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Set up JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: 'temurin'
java-version: '21'
# ✅ Get version from Gradle
- name: Get version number
id: versionNumber
run: |
VERSION=$(./gradlew printVersion --quiet | tail -1)
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
# ✅ Get Mac-specific version from Gradle
- name: Get version number mac
id: versionNumberMac
run: |
VERSION_MAC=$(./gradlew printMacVersion --quiet | tail -1)
echo "versionNumberMac=$VERSION_MAC" >> $GITHUB_OUTPUT
build-portable:
needs: read_versions
runs-on: ubuntu-latest
strategy:
matrix:
disable_security: [true, false]
include:
- disable_security: false
file_suffix: "-with-login"
- disable_security: true
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Set up JDK 21
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "21"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
with:
gradle-version: 8.14
- name: Generate jar (Disable Security=${{ matrix.disable_security }})
run: ./gradlew clean createExe
env:
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.disable_security }}
STIRLING_PDF_DESKTOP_UI: false
- name: Rename binaries
run: |
mkdir ./binaries
mv ./build/launch4j/Stirling-PDF.exe ./binaries/win-Stirling-PDF-portable-Server${{ matrix.file_suffix }}.exe
mv ./app/core/build/libs/stirling-pdf-${{ needs.read_versions.outputs.version }}.jar ./binaries/Stirling-PDF${{ matrix.file_suffix }}.jar
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
retention-days: 1
if-no-files-found: error
name: stirling${{ matrix.file_suffix }}-binaries
path: |
./binaries/*
sign_verify-portable:
needs: [build-portable, read_versions]
runs-on: ubuntu-latest
strategy:
matrix:
disable_security: [true, false]
include:
- disable_security: false
file_suffix: "with-login-"
- disable_security: true
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Download build artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: stirling-${{ matrix.file_suffix }}binaries
- name: Display structure of downloaded files
run: ls -R
- name: Upload signed artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
retention-days: 1
if-no-files-found: error
name: stirling-${{ matrix.file_suffix }}signed
path: |
./*
!cosign.*
build-installers:
needs: read_versions
strategy:
matrix:
include:
- os: windows-latest
platform: win-
- os: macos-latest
platform: mac-
# - os: ubuntu-latest
# platform: linux-
runs-on: ${{ matrix.os }}
permissions:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Set up JDK 21
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "21"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
with:
gradle-version: 8.14
# Install Windows dependencies
- name: Install WiX Toolset
if: matrix.os == 'windows-latest'
run: |
curl -L -o wix.exe https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314.exe
.\wix.exe /install /quiet
# Build installer
- name: Build Installer
run: ./gradlew build jpackage -x test --info
env:
DISABLE_ADDITIONAL_FEATURES: true
STIRLING_PDF_DESKTOP_UI: true
BROWSER_OPEN: true
- name: Set up JDK (x86_64)
if: matrix.os == 'macos-latest'
run: |
curl -L -o jdk.tar.gz https://cdn.azul.com/zulu/bin/zulu17.56.15-ca-jdk17.0.14-macosx_x64.tar.gz
mkdir -p zulu17
tar -xzf jdk.tar.gz -C zulu17 --strip-components=1
echo "JAVA_HOME=$PWD/zulu17" >> $GITHUB_ENV
echo "$PWD/zulu17/bin" >> $GITHUB_PATH
- name: Verify JDK architecture
if: matrix.os == 'macos-latest'
run: file $JAVA_HOME/bin/java
- name: Build project and run jpackage (x86_64)
if: matrix.os == 'macos-latest'
run: arch -x86_64 ./gradlew jpackageMacX64
# Rename and collect artifacts based on OS
- name: Prepare artifacts
id: prepare
shell: bash
run: |
ls -lah ./build/jpackage/
mkdir ./binaries
if [ "${{ matrix.os }}" = "windows-latest" ]; then
mv "./build/jpackage/Stirling PDF-${{ needs.read_versions.outputs.version }}.exe" "./binaries/Stirling-PDF-win-installer.exe"
elif [ "${{ matrix.os }}" = "macos-latest" ]; then
mv "./build/jpackage/Stirling PDF-${{ needs.read_versions.outputs.versionMac }}.dmg" "./binaries/Stirling-PDF-mac-installer.dmg"
mv "./build/jpackage/x86_64/Stirling PDF (x86_64)-${{ needs.read_versions.outputs.versionMac }}.dmg" "./binaries/Stirling-PDF-mac-x86_64-installer.dmg"
else
mv "./build/jpackage/stirling-pdf_${{ needs.read_versions.outputs.version }}-1_amd64.deb" "./binaries/Stirling-PDF-linux-installer.deb"
fi
- name: Display structure of downloaded files
run: ls -R ./binaries
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
retention-days: 1
if-no-files-found: error
name: ${{ matrix.platform }}binaries
path: |
./binaries/*
sign_verify:
needs: [read_versions, build-installers]
strategy:
matrix:
include:
- os: windows-latest
platform: win-
- os: macos-latest
platform: mac-
# - os: ubuntu-latest
# platform: linux-
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Download build artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: ${{ matrix.platform }}binaries
- name: Display structure of downloaded files
run: ls -R
- name: Install Cosign
if: matrix.os == 'windows-latest'
uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2
- name: Generate key pair
if: matrix.os == 'windows-latest'
run: cosign generate-key-pair
- name: Sign and generate attestations
if: matrix.os == 'windows-latest'
run: |
cosign sign-blob \
--key ./cosign.key \
--yes \
--output-signature ./Stirling-PDF-win-installer.exe.sig \
./Stirling-PDF-win-installer.exe
cosign attest-blob \
--predicate - \
--key ./cosign.key \
--yes \
--output-attestation ./Stirling-PDF-win-installer.exe.intoto.jsonl \
./Stirling-PDF-win-installer.exe
cosign verify-blob \
--key ./cosign.pub \
--signature ./Stirling-PDF-win-installer.exe.sig \
./Stirling-PDF-win-installer.exe
- name: Display structure of downloaded files
run: ls -R
- name: Upload signed artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
retention-days: 1
if-no-files-found: error
name: ${{ matrix.platform }}signed
path: |
./Stirling-PDF-${{ matrix.platform }}installer.*
./Stirling-PDF-${{ matrix.platform }}x86_64-installer.*
!cosign.*
create-release:
if: github.event_name != 'workflow_dispatch' || github.event.inputs.test_mode != 'true'
needs: [read_versions, sign_verify, sign_verify-portable]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Download signed artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
- name: Display structure of downloaded files
run: ls -R
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
with:
tag_name: v${{ needs.read_versions.outputs.version }}
generate_release_notes: true
files: |
./*signed/*
-82
View File
@@ -1,82 +0,0 @@
name: Pre-commit
on:
workflow_dispatch:
push:
branches:
- main
permissions:
contents: read
jobs:
pre-commit:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
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 Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
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: |
pip install --require-hashes -r ./.github/scripts/requirements_pre_commit.txt
- 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 .
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
- name: Create Pull Request
if: env.CHANGES_DETECTED == 'true'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: ":file_folder: pre-commit"
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: pre-commit
title: "🤖 format everything with pre-commit by ${{ steps.setup-bot.outputs.app-slug }}"
body: |
Auto-generated by [create-pull-request][1] with **${{ steps.setup-bot.outputs.app-slug }}**
[1]: https://github.com/peter-evans/create-pull-request
draft: false
delete-branch: true
labels: github-actions
sign-commits: true
-207
View File
@@ -1,207 +0,0 @@
name: Push Docker Image with VersionNumber
on:
workflow_dispatch:
push:
branches:
- 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
jobs:
push:
runs-on: ubuntu-latest
permissions:
packages: write
id-token: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Set up JDK 17
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
with:
gradle-version: 8.14
- name: Run Gradle Command
run: ./gradlew clean build
env:
DISABLE_ADDITIONAL_FEATURES: true
STIRLING_PDF_DESKTOP_UI: false
- name: Install cosign
if: github.ref == 'refs/heads/master'
uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2
with:
cosign-release: "v2.4.1"
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Get version number
id: versionNumber
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Convert repository owner to lowercase
id: repoowner
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
- name: Generate tags
id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref != 'refs/heads/main'
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
- name: Build and push main Dockerfile
id: build-push-regular
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
if: github.ref != 'refs/heads/main'
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./Dockerfile
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Sign regular images
if: github.ref == 'refs/heads/master'
env:
DIGEST: ${{ steps.build-push-regular.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
cosign sign --yes \
--key env://COSIGN_PRIVATE_KEY \
"${tag}@${DIGEST}"
done
- name: Generate tags ultra-lite
id: meta2
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref != 'refs/heads/main'
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' }}
- name: Build and push Dockerfile-ultra-lite
id: build-push-lite
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
if: github.ref != 'refs/heads/main'
with:
context: .
file: ./Dockerfile.ultra-lite
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta2.outputs.tags }}
labels: ${{ steps.meta2.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Generate tags fat
id: meta3
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push main Dockerfile fat
id: build-push-fat
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./Dockerfile.fat
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta3.outputs.tags }}
labels: ${{ steps.meta3.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Sign fat images
if: github.ref == 'refs/heads/master'
env:
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
TAGS: ${{ steps.meta3.outputs.tags }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
done
-180
View File
@@ -1,180 +0,0 @@
name: Release Artifacts
on:
workflow_dispatch:
release:
types: [created]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
disable_security: [true, false]
include:
- disable_security: false
file_suffix: "-with-login"
- disable_security: true
file_suffix: ""
outputs:
version: ${{ steps.versionNumber.outputs.versionNumber }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Set up JDK 17
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
with:
gradle-version: 8.14
- name: Generate jar (Disable Security=${{ matrix.disable_security }})
run: ./gradlew clean createExe
env:
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.disable_security }}
STIRLING_PDF_DESKTOP_UI: false
- name: Get version number
id: versionNumber
run: |
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Rename binaries
run: |
mv ./build/launch4j/Stirling-PDF.exe ./build/launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
mv ./build/libs/Stirling-PDF-${{ steps.versionNumber.outputs.versionNumber }}.jar ./build/libs/Stirling-PDF${{ matrix.file_suffix }}.jar
- name: Debug build artifacts
run: |
echo "Current Directory: $(pwd)"
ls -R ./build/libs
ls -R ./build/launch4j
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: binaries${{ matrix.file_suffix }}
path: |
./build/launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.*
./build/libs/Stirling-PDF${{ matrix.file_suffix }}.*
sign_verify:
needs: build
runs-on: ubuntu-latest
strategy:
matrix:
disable_security: [true, false]
include:
- disable_security: false
file_suffix: "-with-login"
- disable_security: true
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Download build artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: binaries${{ matrix.file_suffix }}
- name: Display structure of downloaded files
run: ls -R
- name: Install Cosign
uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2
- name: Generate key pair
run: cosign generate-key-pair
- name: Sign and generate attestations
run: |
cosign sign-blob \
--key ./cosign.key \
--yes \
--output-signature ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.sig \
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
cosign attest-blob \
--predicate - \
--key ./cosign.key \
--yes \
--output-attestation ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.intoto.jsonl \
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
cosign verify-blob \
--key ./cosign.pub \
--signature ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.sig \
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
cosign sign-blob \
--key ./cosign.key \
--yes \
--output-signature ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.sig \
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
cosign attest-blob \
--predicate - \
--key ./cosign.key \
--yes \
--output-attestation ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.intoto.jsonl \
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
cosign verify-blob \
--key ./cosign.pub \
--signature ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.sig \
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
- name: Upload signed artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: signed${{ matrix.file_suffix }}
path: |
./libs/Stirling-PDF${{ matrix.file_suffix }}.*
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.*
release:
needs: [build, sign_verify]
runs-on: ubuntu-latest
permissions:
contents: write
strategy:
matrix:
disable_security: [true, false]
include:
- disable_security: false
file_suffix: "-with-login"
- disable_security: true
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Download signed artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: signed${{ matrix.file_suffix }}
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
with:
tag_name: v${{ needs.build.outputs.version }}
generate_release_notes: true
files: |
./libs/Stirling-PDF*
./launch4j/Stirling-PDF-Server*
-79
View File
@@ -1,79 +0,0 @@
# This workflow uses actions that are not certified by GitHub. They are provided
# by a third-party and are governed by separate terms of service, privacy
# policy, and support documentation.
name: Scorecard supply-chain security
on:
# For Branch-Protection check. Only the default branch is supported. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
branch_protection_rule:
# To guarantee Maintained check is occasionally updated. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
schedule:
- cron: "20 7 * * 2"
push:
branches: ["main"]
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
# Needed to upload the results to code-scanning dashboard.
security-events: write
# Needed to publish results and get a badge (see publish_results below).
id-token: write
contents: read
actions: read
# To allow GraphQL ListCommits to work
issues: read
pull-requests: read
# To detect SAST tools
checks: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2
with:
results_file: results.sarif
results_format: sarif
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
# - you want to enable the Branch-Protection check on a *public* repository, or
# - you are installing Scorecards on a *private* repository
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat.
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
# Public repositories:
# - Publish results to OpenSSF REST API for easy access by consumers
# - Allows the repository to include the Scorecard badge.
# - See https://github.com/ossf/scorecard-action#publishing-results.
# For private repositories:
# - `publish_results` will always be set to `false`, regardless
# of the value entered here.
publish_results: true
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: SARIF file
path: results.sarif
retention-days: 5
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.5
with:
sarif_file: results.sarif
-75
View File
@@ -1,75 +0,0 @@
name: Run Sonarqube
on:
push:
branches:
- master
pull_request_target:
branches:
- 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
jobs:
sonarqube:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
fetch-depth: 0
- name: Setup Gradle
uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
- name: Build and analyze with Gradle
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
DISABLE_ADDITIONAL_FEATURES: false
STIRLING_PDF_DESKTOP_UI: true
run: |
./gradlew clean build sonar \
-Dsonar.projectKey=Stirling-Tools_Stirling-PDF \
-Dsonar.organization=stirling-tools \
-Dsonar.host.url=https://sonarcloud.io \
-Dsonar.login=${SONAR_TOKEN} \
-Dsonar.log.level=DEBUG \
--info
- name: Upload Problems Report on Failure
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: gradle-problems-report
path: build/reports/problems/problems-report.html
retention-days: 7
- name: Upload Sonar Logs on Failure
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: sonar-logs
path: |
.scannerwork/report-task.txt
build/sonar/
retention-days: 7
-40
View File
@@ -1,40 +0,0 @@
name: Close stale issues
on:
schedule:
- cron: "30 0 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: 30 days stale issues
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 30
days-before-close: 7
stale-issue-message: >
This issue has been automatically marked as stale because it has had no recent activity.
It will be closed if no further activity occurs. Thank you for your contributions.
close-issue-message: >
This issue has been automatically closed because it has had no recent activity after being marked as stale.
Please reopen if you need further assistance.
stale-issue-label: "Stale"
remove-stale-when-updated: true
only-issue-labels: "more-info-needed"
days-before-pr-stale: -1 # Prevents PRs from being marked as stale
days-before-pr-close: -1 # Prevents PRs from being closed
start-date: "2024-07-06T00:00:00Z" # ISO 8601 Format
-61
View File
@@ -1,61 +0,0 @@
name: Update Swagger
on:
workflow_dispatch:
push:
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
jobs:
push:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Set up JDK 17
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
- name: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
- name: Upload Swagger Documentation to SwaggerHub
run: ./gradlew swaggerhubUpload
env:
SWAGGERHUB_API_KEY: ${{ secrets.SWAGGERHUB_API_KEY }}
SWAGGERHUB_USER: "Frooodle"
- name: Get version number
id: versionNumber
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
- name: Set API version as published and default on SwaggerHub
run: |
curl -X PUT -H "Authorization: ${SWAGGERHUB_API_KEY}" "https://api.swaggerhub.com/apis/${SWAGGERHUB_USER}/Stirling-PDF/${{ steps.versionNumber.outputs.versionNumber }}/settings/lifecycle" -H "accept: application/json" -H "Content-Type: application/json" -d "{\"published\":true,\"default\":true}"
env:
SWAGGERHUB_API_KEY: ${{ secrets.SWAGGERHUB_API_KEY }}
SWAGGERHUB_USER: "Frooodle"
-116
View File
@@ -1,116 +0,0 @@
name: Sync Files
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "build.gradle"
- "README.md"
- "app/core/src/main/resources/messages_*.properties"
- "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
jobs:
sync-files:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.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 Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
- name: Sync translation property files
run: |
python .github/scripts/check_language_properties.py --reference-file "app/core/src/main/resources/messages_en_GB.properties" --branch main
- name: Commit translation files
run: |
git add app/core/src/main/resources/messages_*.properties
git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "No changes detected"
- name: Install dependencies
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
- name: Sync README.md
run: |
python scripts/counter_translation.py
- name: Run git add
run: |
git add README.md scripts/ignore_translation.toml
git diff --staged --quiet || git commit -m ":memo: Sync README.md & scripts/ignore_translation.toml" || echo "No changes detected"
- name: Create Pull Request
if: always()
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: Update files
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: sync_readme
title: ":globe_with_meridians: Sync Translations + Update README Progress Table"
body: |
### Description of Changes
This Pull Request was automatically generated to synchronize updates to translation files and documentation. Below are the details of the changes made:
#### **1. Synchronization of Translation Files**
- Updated translation files (`messages_*.properties`) to reflect changes in the reference file `messages_en_GB.properties`.
- Ensured consistency and synchronization across all supported language files.
- Highlighted any missing or incomplete translations.
#### **2. Update README.md**
- Generated the translation progress table in `README.md`.
- Added a summary of the current translation status for all supported languages.
- Included up-to-date statistics on translation coverage.
#### **Why these changes are necessary**
- Keeps translation files aligned with the latest reference updates.
- Ensures the documentation reflects the current translation progress.
---
Auto-generated by [create-pull-request][1].
[1]: https://github.com/peter-evans/create-pull-request
draft: false
delete-branch: true
labels: github-actions
sign-commits: true
add-paths: |
README.md
app/core/src/main/resources/messages_*.properties
-176
View File
@@ -1,176 +0,0 @@
name: UI test with TestDriverAI
on:
push:
branches: ["master", "UITest", "testdriver"]
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Set up JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
with:
gradle-version: 8.14
- name: Build with Gradle
run: ./gradlew clean build
env:
DISABLE_ADDITIONAL_FEATURES: true
- 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@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Build and push test image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key
- name: Deploy to VPS
run: |
cat > docker-compose.yml << EOF
version: '3.3'
services:
stirling-pdf:
container_name: stirling-pdf-test-${{ github.sha }}
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
ports:
- "1337:8080"
volumes:
- /stirling/test-${{ github.sha }}/data:/usr/share/tessdata:rw
- /stirling/test-${{ github.sha }}/config:/configs:rw
- /stirling/test-${{ github.sha }}/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false"
SYSTEM_DEFAULTLOCALE: en-GB
UI_APPNAME: "Stirling-PDF Test"
UI_HOMEDESCRIPTION: "Test Deployment"
UI_APPNAMENAVBAR: "Test"
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
SYSTEM_ENABLEANALYTICS: "false"
restart: on-failure:5
EOF
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose.yml
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << EOF
mkdir -p /stirling/test-${{ github.sha }}/{data,config,logs}
mv /tmp/docker-compose.yml /stirling/test-${{ github.sha }}/docker-compose.yml
cd /stirling/test-${{ github.sha }}
docker-compose pull
docker-compose up -d
EOF
test:
needs: deploy
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- 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:
key: ${{secrets.TESTDRIVER_API_KEY}}
prerun: |
npm install
npm run build
npm install dashcam-chrome --save
Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.VPS_HOST }}:1337"
Start-Sleep -Seconds 20
prompt: |
1. /run testing/testdriver/test.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FORCE_COLOR: "3"
cleanup:
needs: [deploy, test]
runs-on: ubuntu-latest
if: always()
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key
- name: Cleanup deployment
run: |
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << EOF
cd /stirling/test-${{ github.sha }}
docker-compose down
cd /stirling
rm -rf test-${{ github.sha }}
EOF
+2 -11
View File
@@ -13,7 +13,6 @@ local.properties
.recommenders
.classpath
.project
*.local.json
version.properties
#### Stirling-PDF Files ###
@@ -27,7 +26,6 @@ clientWebUI/
!cucumber/exampleFiles/
!cucumber/exampleFiles/example_html.zip
exampleYmlFiles/stirling/
/stirling/
/testing/file_snapshots
SwaggerDoc.json
@@ -125,13 +123,7 @@ SwaggerDoc.json
*.tar.gz
*.rar
*.db
build
app/core/build
app/common/build
app/proprietary/build
common/build
proprietary/build
stirling-pdf/build
/build
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -197,9 +189,8 @@ id_ed25519.pub
.pytest_cache
.ipynb_checkpoints
**/jcef-bundle/
# node_modules
node_modules/
*.mjs
+6 -16
View File
@@ -1,28 +1,28 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.7
rev: v0.11.6
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
hooks:
- id: codespell
args:
- --ignore-words-list=thirdParty,tabEl,tabEls
- --ignore-words-list=
- --skip="./.*,*.csv,*.json,*.ambr"
- --quiet-level=2
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)
exclude: (.vscode|.devcontainer|src/main/resources|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
- repo: https://github.com/gitleaks/gitleaks
rev: v8.28.0
rev: v8.24.3
hooks:
- id: gitleaks
- repo: https://github.com/pre-commit/pre-commit-hooks
@@ -34,13 +34,3 @@ repos:
- id: trailing-whitespace
files: ^.*(\.js|\.java|\.py|\.yml)$
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$)
# - repo: https://github.com/thibaudcolas/pre-commit-stylelint
# rev: v16.21.1
# hooks:
# - id: stylelint
# additional_dependencies:
# - stylelint@16.21.1
# - stylelint-config-standard@38.0.0
# - "@stylistic/stylelint-plugin@3.1.3"
# files: \.(css)$
# args: [--fix]
+1 -5
View File
@@ -15,10 +15,6 @@
"ms-azuretools.vscode-docker", // Docker extension for Visual Studio Code
"GitHub.copilot", // GitHub Copilot AI pair programmer for Visual Studio Code
"GitHub.vscode-pull-request-github", // GitHub Pull Requests extension for Visual Studio Code
"charliermarsh.ruff", // Ruff code formatter for Python to follow the Ruff Style Guide
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
"redhat.vscode-yaml", // YAML extension for Visual Studio Code
"dbaeumer.vscode-eslint", // ESLint extension for TypeScript linting
"charliermarsh.ruff" // Ruff code formatter for Python to follow the Ruff Style Guide
]
}
+1 -64
View File
@@ -2,46 +2,15 @@
"editor.wordSegmenterLocales": "",
"editor.guides.bracketPairs": "active",
"editor.guides.bracketPairsHorizontal": "active",
"editor.defaultFormatter": "EditorConfig.EditorConfig",
"cSpell.enabled": false,
"[feature]": {
"editor.defaultFormatter": "alexkrechik.cucumberautocomplete"
},
"[java]": {
"editor.defaultFormatter": "josevseb.google-java-format-for-vs-code"
},
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"[css]": {
"editor.defaultFormatter": "stylelint.vscode-stylelint"
},
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter"
},
"[gradle-kotlin-dsl]": {
"editor.defaultFormatter": "vscjava.vscode-gradle"
},
"[markdown]": {
"editor.defaultFormatter": "yzhang.markdown-all-in-one"
},
"[gradle-build]": {
"editor.defaultFormatter": "vscjava.vscode-gradle"
},
"[gradle]": {
"editor.defaultFormatter": "vscjava.vscode-gradle"
},
"[yaml]": {
"editor.defaultFormatter": "redhat.vscode-yaml"
},
"java.compile.nullAnalysis.mode": "automatic",
"java.configuration.updateBuildConfiguration": "interactive",
"java.format.enabled": true,
"java.format.settings.profile": "GoogleStyle",
"java.format.settings.google.version": "1.28.0",
"java.format.settings.google.version": "1.26.0",
"java.format.settings.google.extra": "--aosp --skip-sorting-imports --skip-javadoc-formatting",
// (DE) Aktiviert Kommentare im Java-Format.
// (EN) Enables comments in Java formatting.
@@ -80,17 +49,9 @@
".venv*/",
".vscode/",
"bin/",
"app/core/bin/",
"app/common/bin/",
"app/proprietary/bin/",
"build/",
"app/core/build/",
"app/common/build/",
"app/proprietary/build/",
"configs/",
"app/core/configs/",
"customFiles/",
"app/core/customFiles/",
"docs/",
"exampleYmlFiles",
"gradle/",
@@ -102,9 +63,6 @@
".git-blame-ignore-revs",
".gitattributes",
".gitignore",
"app/core/.gitignore",
"app/common/.gitignore",
"app/proprietary/.gitignore",
".pre-commit-config.yaml",
],
// Enables signature help in Java.
@@ -122,25 +80,4 @@
"spring.initializr.defaultLanguage": "Java",
"spring.initializr.defaultGroupId": "stirling.software.SPDF",
"spring.initializr.defaultArtifactId": "SPDF",
"java.jdt.ls.lombokSupport.enabled": true,
"html.format.wrapLineLength": 127,
"html.format.enable": true,
"html.format.indentInnerHtml": true,
"html.format.unformatted": "script,style,textarea",
"html.format.contentUnformatted": "pre,code",
"html.format.extraLiners": "head,body,/html",
"html.format.wrapAttributes": "force",
"html.format.wrapAttributesIndentSize": 2,
"html.format.indentHandlebars": true,
"html.format.preserveNewLines": true,
"html.format.maxPreserveNewLines": 2,
"stylelint.configFile": "devTools/.stylelintrc.json",
"java.project.sourcePaths": [
"app/core/src/main/java",
"app/common/src/main/java",
"app/proprietary/src/main/java"
],
"[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
}
}
-300
View File
@@ -1,300 +0,0 @@
# Adding New React Tools to Stirling PDF
This guide covers how to add new PDF tools to the React frontend, either by migrating existing Thymeleaf templates or creating entirely new tools.
## Overview
When adding tools, follow this systematic approach using the established patterns and architecture.
## 1. Create Tool Structure
Create these files in the correct directories:
```
frontend/src/hooks/tools/[toolName]/
├── use[ToolName]Parameters.ts # Parameter definitions and validation
└── use[ToolName]Operation.ts # Tool operation logic using useToolOperation
frontend/src/components/tools/[toolName]/
└── [ToolName]Settings.tsx # Settings UI component (if needed)
frontend/src/tools/
└── [ToolName].tsx # Main tool component
```
## 2. Implementation Pattern
Use `useBaseTool` for simplified hook management. This is the recommended approach for all new tools:
**Parameters Hook** (`use[ToolName]Parameters.ts`):
```typescript
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export interface [ToolName]Parameters extends BaseParameters {
// Define your tool-specific parameters here
someOption: boolean;
}
export const defaultParameters: [ToolName]Parameters = {
someOption: false,
};
export const use[ToolName]Parameters = (): BaseParametersHook<[ToolName]Parameters> => {
return useBaseParameters({
defaultParameters,
endpointName: 'your-endpoint-name',
validateFn: (params) => true, // Add validation logic
});
};
```
**Operation Hook** (`use[ToolName]Operation.ts`):
```typescript
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
export const build[ToolName]FormData = (parameters: [ToolName]Parameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
// Add parameters to formData
return formData;
};
export const [toolName]OperationConfig = {
toolType: ToolType.singleFile, // or ToolType.multiFile (buildFormData's file parameter will need to be updated)
buildFormData: build[ToolName]FormData,
operationType: '[toolName]',
endpoint: '/api/v1/category/endpoint-name',
filePrefix: 'processed_', // Will be overridden with translation
defaultParameters,
} as const;
export const use[ToolName]Operation = () => {
const { t } = useTranslation();
return useToolOperation({
...[toolName]OperationConfig,
filePrefix: t('[toolName].filenamePrefix', 'processed') + '_',
getErrorMessage: createStandardErrorHandler(t('[toolName].error.failed', 'Operation failed'))
});
};
```
**Main Component** (`[ToolName].tsx`):
```typescript
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import { use[ToolName]Parameters } from "../hooks/tools/[toolName]/use[ToolName]Parameters";
import { use[ToolName]Operation } from "../hooks/tools/[toolName]/use[ToolName]Operation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
const [ToolName] = (props: BaseToolProps) => {
const { t } = useTranslation();
const base = useBaseTool('[toolName]', use[ToolName]Parameters, use[ToolName]Operation, props);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
placeholder: t("[toolName].files.placeholder", "Select files to get started"),
},
steps: [
// Add settings steps if needed
],
executeButton: {
text: t("[toolName].submit", "Process"),
isVisible: !base.hasResults,
loadingText: t("loading"),
onClick: base.handleExecute,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("[toolName].results.title", "Results"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
[ToolName].tool = () => use[ToolName]Operation;
export default [ToolName] as ToolComponent;
```
**Note**: Some existing tools (like AddPassword, Compress) use a legacy pattern with manual hook management. **Always use the Modern Pattern above for new tools** - it's cleaner, more maintainable, and includes automation support.
## 3. Register Tool in System
Update these files to register your new tool:
**Tool Registry** (`frontend/src/data/useTranslatedToolRegistry.tsx`):
1. Add imports at the top:
```typescript
import [ToolName] from "../tools/[ToolName]";
import { [toolName]OperationConfig } from "../hooks/tools/[toolName]/use[ToolName]Operation";
import [ToolName]Settings from "../components/tools/[toolName]/[ToolName]Settings";
```
2. Add tool entry in the `allTools` object:
```typescript
[toolName]: {
icon: <LocalIcon icon="your-icon-name" width="1.5rem" height="1.5rem" />,
name: t("home.[toolName].title", "Tool Name"),
component: [ToolName],
description: t("home.[toolName].desc", "Tool description"),
categoryId: ToolCategoryId.STANDARD_TOOLS, // or appropriate category
subcategoryId: SubcategoryId.APPROPRIATE_SUBCATEGORY,
maxFiles: -1, // or specific number
endpoints: ["endpoint-name"],
operationConfig: [toolName]OperationConfig,
settingsComponent: [ToolName]Settings, // if settings exist
},
```
## 4. Add Tooltips (Optional but Recommended)
Create user-friendly tooltips to help non-technical users understand your tool. **Use simple, clear language - avoid technical jargon:**
**Tooltip Hook** (`frontend/src/components/tooltips/use[ToolName]Tips.ts`):
```typescript
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const use[ToolName]Tips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("[toolName].tooltip.header.title", "Tool Overview")
},
tips: [
{
title: t("[toolName].tooltip.description.title", "What does this tool do?"),
description: t("[toolName].tooltip.description.text", "Simple explanation in everyday language that non-technical users can understand."),
bullets: [
t("[toolName].tooltip.description.bullet1", "Easy-to-understand benefit 1"),
t("[toolName].tooltip.description.bullet2", "Easy-to-understand benefit 2")
]
}
// Add more tip sections as needed
]
};
};
```
**Add tooltip to your main component:**
```typescript
import { use[ToolName]Tips } from "../components/tooltips/use[ToolName]Tips";
const [ToolName] = (props: BaseToolProps) => {
const tips = use[ToolName]Tips();
// In your steps array:
steps: [
{
title: t("[toolName].steps.settings", "Settings"),
tooltip: tips, // Add this line
content: <[ToolName]Settings ... />
}
]
```
## 5. Add Translations
Update translation files. **Important: Only update `en-GB` files** - other languages are handled separately.
**File to update:** `frontend/public/locales/en-GB/translation.json`
**Required Translation Keys**:
```json
{
"home": {
"[toolName]": {
"title": "Tool Name",
"desc": "Tool description"
}
},
"[toolName]": {
"title": "Tool Name",
"submit": "Process",
"filenamePrefix": "processed",
"files": {
"placeholder": "Select files to get started"
},
"steps": {
"settings": "Settings"
},
"options": {
"title": "Tool Options",
"someOption": "Option Label",
"someOption.desc": "Option description",
"note": "General information about the tool."
},
"results": {
"title": "Results"
},
"error": {
"failed": "Operation failed"
},
"tooltip": {
"header": {
"title": "Tool Overview"
},
"description": {
"title": "What does this tool do?",
"text": "Simple explanation in everyday language",
"bullet1": "Easy-to-understand benefit 1",
"bullet2": "Easy-to-understand benefit 2"
}
}
}
}
```
**Translation Notes:**
- **Only update `en-GB/translation.json`** - other locale files are managed separately
- Use descriptive keys that match your component's `t()` calls
- Include tooltip translations if you created tooltip hooks
- Add `options.*` keys if your tool has settings with descriptions
**Tooltip Writing Guidelines:**
- **Use simple, everyday language** - avoid technical terms like "converts interactive elements"
- **Focus on benefits** - explain what the user gains, not how it works internally
- **Use concrete examples** - "text boxes become regular text" vs "form fields are flattened"
- **Answer user questions** - "What does this do?", "When should I use this?", "What's this option for?"
- **Keep descriptions concise** - 1-2 sentences maximum per section
- **Use bullet points** for multiple benefits or features
## 6. Migration from Thymeleaf
When migrating existing Thymeleaf templates:
1. **Identify Form Parameters**: Look at the original `<form>` inputs to determine parameter structure
2. **Extract Translation Keys**: Find `#{key.name}` references and add them to JSON translations (For many tools these translations will already exist but some parts will be missing)
3. **Map API Endpoint**: Note the `th:action` URL for the operation hook
4. **Preserve Functionality**: Ensure all original form behaviour is replicated which is applicable to V2 react UI
## 7. Testing Your Tool
- Verify tool appears in UI with correct icon and description
- Test with various file sizes and types
- Confirm translations work
- Check error handling
- Test undo functionality
- Verify results display correctly
## Tool Development Patterns
### Three Tool Patterns:
**Pattern 1: Single-File Tools** (Individual processing)
- Backend processes one file per API call
- Set `multiFileEndpoint: false`
- Examples: Compress, Rotate
**Pattern 2: Multi-File Tools** (Batch processing)
- Backend accepts `MultipartFile[]` arrays in single API call
- Set `multiFileEndpoint: true`
- Examples: Split, Merge, Overlay
**Pattern 3: Complex Tools** (Custom processing)
- Tools with complex routing logic or non-standard processing
- Provide `customProcessor` for full control
- Examples: Convert, OCR
View File
-225
View File
@@ -1,225 +0,0 @@
# 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
**Architecture**: Modular hook-based system with clear separation of concerns:
- **useToolOperation** (`frontend/src/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
- Coordinates all tool operations with consistent interface
- Integrates with FileContext for operation tracking
- Handles validation, error handling, and UI state management
- **Supporting Hooks**:
- **useToolState**: UI state management (loading, progress, error, files)
- **useToolApiCalls**: HTTP requests and file processing
- **useToolResources**: Blob URLs, thumbnails, ZIP downloads
- **Utilities**:
- **toolErrorHandler**: Standardized error extraction and i18n support
- **toolResponseProcessor**: API response handling (single/zip/custom)
- **toolOperationTracker**: FileContext integration utilities
**Three Tool Patterns**:
**Pattern 1: Single-File Tools** (Individual processing)
- Backend processes one file per API call
- Set `multiFileEndpoint: false`
- Examples: Compress, Rotate
```typescript
return useToolOperation({
operationType: 'compress',
endpoint: '/api/v1/misc/compress-pdf',
buildFormData: (params, file: File) => { /* single file */ },
multiFileEndpoint: false,
filePrefix: 'compressed_'
});
```
**Pattern 2: Multi-File Tools** (Batch processing)
- Backend accepts `MultipartFile[]` arrays in single API call
- Set `multiFileEndpoint: true`
- Examples: Split, Merge, Overlay
```typescript
return useToolOperation({
operationType: 'split',
endpoint: '/api/v1/general/split-pages',
buildFormData: (params, files: File[]) => { /* all files */ },
multiFileEndpoint: true,
filePrefix: 'split_'
});
```
**Pattern 3: Complex Tools** (Custom processing)
- Tools with complex routing logic or non-standard processing
- Provide `customProcessor` for full control
- Examples: Convert, OCR
```typescript
return useToolOperation({
operationType: 'convert',
customProcessor: async (params, files) => { /* custom logic */ },
filePrefix: 'converted_'
});
```
**Benefits**:
- **No Timeouts**: Operations run until completion (supports 100GB+ files)
- **Consistent**: All tools follow same pattern and interface
- **Maintainable**: Single responsibility hooks, easy to test and modify
- **i18n Ready**: Built-in internationalization support
- **Type Safe**: Full TypeScript support with generic interfaces
- **Memory Safe**: Automatic resource cleanup and blob URL management
## 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**: Modular hook architecture with `useToolOperation` orchestrator
- Individual hooks: `useToolState`, `useToolApiCalls`, `useToolResources`
- Utilities: `toolErrorHandler`, `toolResponseProcessor`, `toolOperationTracker`
- Pattern: Each tool creates focused operation hook, UI consumes state/actions
- **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 `useToolOperation` hook pattern (see `useCompressOperation.ts`)
- **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)
- **Adding Tools**: See `ADDING_TOOLS.md` for complete guide to creating new PDF tools
## 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
+2 -13
View File
@@ -25,7 +25,7 @@ Please make sure your Pull Request adheres to the following guidelines:
## Translations
If you would like to add or modify a translation, please see [How to add new languages to Stirling-PDF](devGuide/HowToAddNewLanguage.md). Also, please create a Pull Request so others can use it!
If you would like to add or modify a translation, please see [How to add new languages to Stirling-PDF](HowToAddNewLanguage.md). Also, please create a Pull Request so others can use it!
## Docs
@@ -37,18 +37,7 @@ First, make sure you've read the section [Pull Requests](#pull-requests).
If, at any point in time, you have a question, please feel free to ask in the same issue thread or in our [Discord](https://discord.gg/FJUSXUSYec).
## Developer Documentation
For technical guides, setup instructions, and development resources, please see our [Developer Documentation](devGuide/) which includes:
- [Developer Guide](devGuide/DeveloperGuide.md) - Main setup and architecture guide
- [Exception Handling Guide](devGuide/EXCEPTION_HANDLING_GUIDE.md) - Error handling patterns and i18n
- [Translation Guide](devGuide/HowToAddNewLanguage.md) - Adding new languages
- And more in the [devGuide folder](devGuide/)
For configuration and usage guides, see:
- [Database Guide](DATABASE.md) - Database setup and configuration
- [OCR Guide](HowToUseOCR.md) - OCR setup and configuration
Developers should review our [Developer Guide](DeveloperGuide.md)
## License
+46 -150
View File
@@ -2,38 +2,21 @@
## 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.
Stirling-PDF is a robust, locally hosted, web-based PDF manipulation tool. This guide focuses on Docker-based development and testing, which is the recommended approach for working with the full version of Stirling-PDF.
## 2. Project Overview
**Stirling 2.0** is built using:
Stirling-PDF 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)
- Spring Boot + Thymeleaf
- PDFBox
- LibreOffice
- qpdf
- HTML, CSS, JavaScript
- Docker
- PDF.js
- PDF-LIB.js
- Lombok
## 3. Development Environment Setup
@@ -41,8 +24,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
- Docker
- Git
- Java JDK 17 or later (JDK 21 recommended)
- Node.js 18+ and npm (required for frontend development)
- Java JDK 17 or later
- Gradle 7.0 or later (Included within the repo)
### Setup Steps
@@ -73,46 +55,16 @@ Stirling-PDF uses Lombok to reduce boilerplate code. Some IDEs, like Eclipse, do
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.
For local testing, you should generally be testing the full 'Security' version of Stirling-PDF. To do this, you must add the environment flag DOCKER_ENABLE_SECURITY=true to your system and/or IDE build/run step.
## 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
## 4. 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
├── cucumber/ # Cucumber test files
│ ├── features/
├── customFiles/ # Custom static files and templates (generated at runtime used to replace existing files)
├── docs/ # Documentation files
├── exampleYmlFiles/ # Example YAML configuration files
@@ -132,14 +84,16 @@ Stirling-PDF/
│ │ │ ├── service/
│ │ │ └── utils/
│ │ └── resources/
│ │ ├── static/ # Legacy static assets (reference only)
│ │ ├── static/
│ │ │ ├── css/
│ │ │ ├── js/
│ │ │ └── pdfjs/
│ │ └── templates/ # Legacy Thymeleaf templates (reference only)
│ │ └── templates/
│ └── test/
├── testing/ # Cucumber and integration tests
└── cucumber/ # Cucumber test files
│ └── java/
└── stirling/
│ └── software/
│ └── SPDF/
├── build.gradle # Gradle build configuration
├── Dockerfile # Main Dockerfile
├── Dockerfile.ultra-lite # Dockerfile for ultra-lite version
@@ -148,7 +102,7 @@ Stirling-PDF/
└── test.sh # Test script to deploy all docker versions and run cuke tests
```
## 6. Docker-based Development
## 5. Docker-based Development
Stirling-PDF offers several Docker versions:
@@ -160,9 +114,9 @@ Stirling-PDF offers several Docker versions:
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
- `docker-compose-latest.yml`: Latest version without security features
- `docker-compose-latest-security.yml`: Latest version with security features enabled
- `docker-compose-latest-fat-security.yml`: Fat version with security features enabled
These files provide pre-configured setups for different scenarios. For example, here's a snippet from `docker-compose-latest-security.yml`:
@@ -183,11 +137,11 @@ services:
ports:
- "8080:8080"
volumes:
- ./stirling/latest/data:/usr/share/tessdata:rw
- ./stirling/latest/config:/configs:rw
- ./stirling/latest/logs:/logs:rw
- /stirling/latest/data:/usr/share/tessdata:rw
- /stirling/latest/config:/configs:rw
- /stirling/latest/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "false"
DOCKER_ENABLE_SECURITY: "true"
SECURITY_ENABLELOGIN: "true"
PUID: 1002
PGID: 1002
@@ -216,7 +170,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
1. Set the security environment variable:
```bash
export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds
export DOCKER_ENABLE_SECURITY=false # or true for security-enabled builds
```
2. Build the project with Gradle:
@@ -239,16 +193,16 @@ Stirling-PDF uses different Docker images for various configurations. The build
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):
For the fat version (with security enabled):
```bash
export DISABLE_ADDITIONAL_FEATURES=false
export DOCKER_ENABLE_SECURITY=true
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
## 6. Testing
### Comprehensive Testing Script
@@ -274,15 +228,6 @@ Note: The `test.sh` script will run automatically when you raise a PR. However,
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:
@@ -313,7 +258,7 @@ Important notes:
- 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
## 7. Contributing
1. Fork the repository on GitHub.
2. Create a new branch for your feature or bug fix.
@@ -338,11 +283,11 @@ When you raise a PR:
Address any issues that arise from these checks before finalizing your pull request.
## 9. API Documentation
## 8. 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
## 9. Customization
Stirling-PDF can be customized through environment variables or a `settings.yml` file. Key customization options include:
@@ -361,7 +306,7 @@ 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
## 10. Language Translations
For managing language translations that affect multiple files, Stirling-PDF provides a helper script:
@@ -381,62 +326,13 @@ Remember to test your changes thoroughly to ensure they don't break any existing
## 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
### 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.
In Stirling-PDF, Thymeleaf is used to create HTML templates that are rendered on the server side. These templates are located in the `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:
@@ -488,7 +384,7 @@ 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.
- Create a new Java class in the `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")`.
@@ -515,7 +411,7 @@ This would generate n entries of tr for each person in exampleData
```
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.
- Create a new service class in the `src/main/java/stirling/software/SPDF/service` directory.
- Implement the business logic for the new feature.
```java
@@ -567,7 +463,7 @@ This would generate n entries of tr for each person in exampleData
### 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.
- Create a new HTML file in the `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.
@@ -611,7 +507,7 @@ This would generate n entries of tr for each person in exampleData
```
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.
- Create a new Java class in the `src/main/java/stirling/software/SPDF/controller/ui` directory.
- Annotate the class with `@Controller` and `@RequestMapping` to define the UI endpoint.
```java
@@ -641,7 +537,7 @@ This would generate n entries of tr for each person in exampleData
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.
- Update the `src/main/resources/templates/fragments/navbar.html` file.
```html
<li class="nav-item">
@@ -655,7 +551,7 @@ When adding a new feature or modifying existing ones in Stirling-PDF, you'll nee
### 1. Locate Existing Language Files
Find the existing `messages.properties` files in the `stirling-pdf/src/main/resources` directory. You'll see files like:
Find the existing `messages.properties` files in the `src/main/resources` directory. You'll see files like:
- `messages.properties` (default, usually English)
- `messages_en_GB.properties`
+35 -46
View File
@@ -1,40 +1,17 @@
# Backend Dockerfile - Java Spring Boot with all dependencies and build stage
# Build the application
FROM gradle:8.14-jdk21 AS build
COPY build.gradle .
COPY settings.gradle .
COPY gradlew .
COPY gradle gradle/
COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/.
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
# Set the working directory
WORKDIR /app
# Copy the entire project to the working directory
COPY . .
# Build the application with DISABLE_ADDITIONAL_FEATURES=false
RUN DISABLE_ADDITIONAL_FEATURES=false \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Main stage
FROM alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1
FROM alpine:3.21.3@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c
# Copy necessary files
COPY scripts /scripts
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
# first /app directory is for the build stage, second is for the final image
COPY --from=build /app/app/core/build/libs/*.jar app.jar
COPY pipeline /pipeline
COPY src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
#COPY src/main/resources/static/fonts/*.otf /usr/share/fonts/opentype/noto/
COPY build/libs/*.jar app.jar
ARG VERSION_TAG
LABEL org.opencontainers.image.title="Stirling-PDF Backend"
LABEL org.opencontainers.image.description="Backend service for Stirling-PDF - Java Spring Boot with PDF processing capabilities"
LABEL org.opencontainers.image.title="Stirling-PDF"
LABEL org.opencontainers.image.description="A powerful locally hosted web-based PDF manipulation tool supporting 50+ operations including merging, splitting, conversion, OCR, watermarking, and more."
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.vendor="Stirling-Tools"
@@ -43,10 +20,10 @@ LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
LABEL maintainer="Stirling-Tools"
LABEL org.opencontainers.image.authors="Stirling-Tools"
LABEL org.opencontainers.image.version="${VERSION_TAG}"
LABEL org.opencontainers.image.keywords="PDF, manipulation, backend, API, Spring Boot"
LABEL org.opencontainers.image.keywords="PDF, manipulation, merge, split, convert, OCR, watermark"
# Set Environment Variables
ENV DISABLE_ADDITIONAL_FEATURES=false \
ENV DOCKER_ENABLE_SECURITY=false \
VERSION_TAG=$VERSION_TAG \
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
JAVA_CUSTOM_OPTS="" \
@@ -57,13 +34,10 @@ ENV DISABLE_ADDITIONAL_FEATURES=false \
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
UNO_PATH=/usr/lib/libreoffice/program \
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
PATH=$PATH:/opt/venv/bin \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
PATH=$PATH:/opt/venv/bin
# JDK for app and all dependencies
# JDK for app
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
@@ -74,6 +48,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
tini \
bash \
curl \
qpdf \
shadow \
su-exec \
openssl \
@@ -86,13 +61,12 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
# pdftohtml
poppler-utils \
# OCR MY PDF (unpaper for descew and other advanced features)
unpaper \
tesseract-ocr-data-eng \
tesseract-ocr-data-chi_sim \
tesseract-ocr-data-deu \
tesseract-ocr-data-fra \
tesseract-ocr-data-por \
ocrmypdf \
font-terminus font-dejavu font-noto font-noto-cjk font-awesome font-noto-extra font-liberation font-linux-libertine \
# CV
py3-opencv \
python3 \
@@ -100,23 +74,38 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
py3-pillow@testing \
py3-pdf2image@testing && \
python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --upgrade pip setuptools && \
/opt/venv/bin/pip install --upgrade pip && \
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
mv /usr/share/tessdata /usr/share/tessdata-original && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf /pipeline/watchedFolders /pipeline/finishedFolders && \
# Prepare tessdata - copy to permanent location for rootless mode support
mkdir -p /usr/share/tessdata && \
cp -r /usr/share/tesseract-ocr/*/tessdata/* /usr/share/tessdata/ || true && \
# Keep a copy of original tessdata files for non-rootless mode
mkdir -p /usr/share/tessdata-original && \
cp -r /usr/share/tessdata/* /usr/share/tessdata-original/ || true && \
# Create required directories with proper permissions
mkdir -p $HOME /configs /logs /customFiles/signatures /customFiles/templates /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
# Font cache
fc-cache -f -v && \
chmod +x /scripts/* && \
chmod +x /scripts/init.sh && \
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar && \
# Make critical directories and files world-accessible for rootless execution
chmod -R 755 /opt/venv /usr/lib/libreoffice && \
chmod -R 1777 /configs /logs /customFiles /pipeline /usr/share/tessdata /tmp/stirling-pdf && \
chmod 755 /app.jar && \
# Ensure specific content directories are accessible
mkdir -p /tmp/stirling-pdf && chmod 1777 /tmp/stirling-pdf && \
# Set proper permissions for the Java temp dir
chmod 1777 /tmp
EXPOSE 8080/tcp
# Set user and run command
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
+54
View File
@@ -0,0 +1,54 @@
# 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 \
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
# Installation der benötigten Python-Pakete
RUN python3 -m venv --system-site-packages /opt/venv \
&& . /opt/venv/bin/activate \
&& 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 adduser --disabled-password --gecos '' devuser \
&& chown -R devuser:devuser /home/devuser /workspace
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
RUN sudo chmod +x /workspace/.devcontainer/init-setup.sh
# Wechsel zum NichtRoot Benutzer
USER devuser
@@ -1,4 +1,3 @@
# Backend fat Dockerfile - Java Spring Boot with all dependencies and build stage
# Build the application
FROM gradle:8.14-jdk21 AS build
@@ -6,9 +5,6 @@ COPY build.gradle .
COPY settings.gradle .
COPY gradlew .
COPY gradle gradle/
COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/.
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
# Set the working directory
@@ -17,24 +13,24 @@ WORKDIR /app
# Copy the entire project to the working directory
COPY . .
# Build the application with DISABLE_ADDITIONAL_FEATURES=false
RUN DISABLE_ADDITIONAL_FEATURES=false \
# Build the application with DOCKER_ENABLE_SECURITY=false
RUN DOCKER_ENABLE_SECURITY=true \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Main stage
FROM alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1
FROM alpine:3.21.3@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c
# Copy necessary files
COPY scripts /scripts
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
# first /app directory is for the build stage, second is for the final image
COPY --from=build /app/app/core/build/libs/*.jar app.jar
COPY pipeline /pipeline
COPY src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
COPY --from=build /app/build/libs/*.jar app.jar
ARG VERSION_TAG
# Set Environment Variables
ENV DISABLE_ADDITIONAL_FEATURES=true \
ENV DOCKER_ENABLE_SECURITY=false \
VERSION_TAG=$VERSION_TAG \
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
JAVA_CUSTOM_OPTS="" \
@@ -47,11 +43,8 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
UNO_PATH=/usr/lib/libreoffice/program \
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
PATH=$PATH:/opt/venv/bin \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
PATH=$PATH:/opt/venv/bin
# JDK for app
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
@@ -76,13 +69,12 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
# pdftohtml
poppler-utils \
# OCR MY PDF (unpaper for descew and other advanced featues)
unpaper \
qpdf \
tesseract-ocr-data-eng \
tesseract-ocr-data-chi_sim \
tesseract-ocr-data-deu \
tesseract-ocr-data-fra \
tesseract-ocr-data-por \
ocrmypdf \
font-terminus font-dejavu font-noto font-noto-cjk font-awesome font-noto-extra font-liberation font-linux-libertine \
# CV
py3-opencv \
@@ -91,22 +83,38 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
py3-pillow@testing \
py3-pdf2image@testing && \
python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --upgrade pip setuptools && \
/opt/venv/bin/pip install --upgrade pip && \
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
mv /usr/share/tessdata /usr/share/tessdata-original && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf /pipeline/watchedFolders /pipeline/finishedFolders && \
# Prepare tessdata - copy to permanent location for rootless mode support
mkdir -p /usr/share/tessdata && \
cp -r /usr/share/tesseract-ocr/*/tessdata/* /usr/share/tessdata/ || true && \
# Keep a copy of original tessdata files for non-rootless mode
mkdir -p /usr/share/tessdata-original && \
cp -r /usr/share/tessdata/* /usr/share/tessdata-original/ || true && \
# Create required directories with proper permissions
mkdir -p $HOME /configs /logs /customFiles/signatures /customFiles/templates /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
# Font cache
fc-cache -f -v && \
# Make scripts executable
chmod +x /scripts/* && \
chmod +x /scripts/init.sh && \
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar && \
# Make critical directories and files world-accessible for rootless execution
chmod -R 755 /opt/venv /usr/lib/libreoffice && \
chmod -R 1777 /configs /logs /customFiles /pipeline /usr/share/tessdata /tmp/stirling-pdf && \
chmod 755 /app.jar && \
# Ensure specific content directories are accessible
mkdir -p /tmp/stirling-pdf && chmod 1777 /tmp/stirling-pdf && \
# Set proper permissions for the Java temp dir
chmod 1777 /tmp
EXPOSE 8080/tcp
# Set user and run command
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
@@ -1,50 +1,24 @@
# Backend ultra-lite Dockerfile - Java Spring Boot with minimal dependencies and build stage
# Build the application
FROM gradle:8.14-jdk21 AS build
COPY build.gradle .
COPY settings.gradle .
COPY gradlew .
COPY gradle gradle/
COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/.
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
# Set the working directory
WORKDIR /app
# Copy the entire project to the working directory
COPY . .
# Build the application with DISABLE_ADDITIONAL_FEATURES=true
RUN DISABLE_ADDITIONAL_FEATURES=true \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Main stage
FROM alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1
# use alpine
FROM alpine:3.21.3@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c
ARG VERSION_TAG
# Set Environment Variables
ENV DISABLE_ADDITIONAL_FEATURES=true \
ENV DOCKER_ENABLE_SECURITY=false \
HOME=/home/stirlingpdfuser \
VERSION_TAG=$VERSION_TAG \
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
JAVA_CUSTOM_OPTS="" \
PUID=1000 \
PGID=1000 \
UMASK=022 \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
UMASK=022
# Copy necessary files
COPY scripts/download-security-jar.sh /scripts/download-security-jar.sh
COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
COPY scripts/installFonts.sh /scripts/installFonts.sh
COPY --from=build /app/app/core/build/libs/*.jar app.jar
COPY pipeline /pipeline
COPY build/libs/*.jar app.jar
# Set up necessary directories and permissions
RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
@@ -61,10 +35,10 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /et
su-exec \
openjdk21-jre && \
# User permissions
mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto /tmp/stirling-pdf /pipeline/watchedFolders /pipeline/finishedFolders && \
mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto && \
chmod +x /scripts/*.sh && \
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar
# Set environment variables
@@ -74,4 +48,4 @@ EXPOSE 8080/tcp
# Run the application
ENTRYPOINT ["tini", "--", "/scripts/init-without-ocr.sh"]
CMD ["java", "-Dfile.encoding=UTF-8", "-Djava.io.tmpdir=/tmp/stirling-pdf", "-jar", "/app.jar"]
CMD ["java", "-Dfile.encoding=UTF-8", "-jar", "/app.jar"]
@@ -10,7 +10,7 @@ Fork Stirling-PDF and create a new branch out of `main`.
Then add a reference to the language in the navbar by adding a new language entry to the dropdown:
- Edit the file: [languages.html](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/templates/fragments/languages.html)
- Edit the file: [languages.html](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/src/main/resources/templates/fragments/languages.html)
For example, to add Polish, you would add:
@@ -25,7 +25,7 @@ The `data-bs-language-code` is the code used to reference the file in the next s
Start by copying the existing English property file:
- [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/messages_en_GB.properties)
- [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/src/main/resources/messages_en_GB.properties)
Copy and rename it to `messages_{your data-bs-language-code here}.properties`. In the Polish example, you would set the name to `messages_pl_PL.properties`.
@@ -61,16 +61,8 @@ Make sure to place the entry under the correct language section. This helps main
#### Windows command
```powershell
python .github/scripts/check_language_properties.py --reference-file app\core\src\main\resources\messages_en_GB.properties --branch "" --files app\core\src\main\resources\messages_pl_PL.properties
```ps
python .github/scripts/check_language_properties.py --reference-file src\main\resources\messages_en_GB.properties --branch "" --files src\main\resources\messages_pl_PL.properties
python .github/scripts/check_language_properties.py --reference-file app\core\src\main\resources\messages_en_GB.properties --branch "" --check-file app\core\src\main\resources\messages_pl_PL.properties
```
#### Linux command
```bash
python3 .github/scripts/check_language_properties.py --reference-file app/core/src/main/resources/messages_en_GB.properties --branch "" --files app/core/src/main/resources/messages_pl_PL.properties
python3 .github/scripts/check_language_properties.py --reference-file app/core/src/main/resources/messages_en_GB.properties --branch "" --check-file app/core/src/main/resources/messages_pl_PL.properties
python .github/scripts/check_language_properties.py --reference-file src\main\resources\messages_en_GB.properties --branch "" --check-file src\main\resources\messages_pl_PL.properties
```
+1 -8
View File
@@ -1,13 +1,6 @@
MIT License
Copyright (c) 2025 Stirling PDF Inc.
Portions of this software are licensed as follows:
* All content that resides under the "app/proprietary/" directory of this repository,
if that directory exists, is licensed under the license defined in "app/proprietary/LICENSE".
* Content outside of the above mentioned directories or restrictions above is
available under the MIT License as defined below.
Copyright (c) 2024 Stirling Tools
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+39 -39
View File
@@ -29,7 +29,7 @@ All documentation available at [https://docs.stirlingpdf.com/](https://docs.stir
- API for integration with external scripts
- Optional Login and Authentication support (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/System%20and%20Security) for documentation)
- Database Backup and Import (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/DATABASE) for documentation)
- Enterprise features like SSO (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration) for documentation)
- Enterprise features like SSO see [here](https://docs.stirlingpdf.com/Enterprise%20Edition)
## PDF Features
@@ -116,47 +116,47 @@ Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![61%](https://geps.dev/progress/61) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![62%](https://geps.dev/progress/62) |
| Basque (Euskara) (eu_ES) | ![36%](https://geps.dev/progress/36) |
| Bulgarian (Български) (bg_BG) | ![68%](https://geps.dev/progress/68) |
| Catalan (Català) (ca_CA) | ![68%](https://geps.dev/progress/68) |
| Croatian (Hrvatski) (hr_HR) | ![60%](https://geps.dev/progress/60) |
| Czech (Česky) (cs_CZ) | ![70%](https://geps.dev/progress/70) |
| Danish (Dansk) (da_DK) | ![61%](https://geps.dev/progress/61) |
| Dutch (Nederlands) (nl_NL) | ![60%](https://geps.dev/progress/60) |
| Arabic (العربية) (ar_AR) | ![83%](https://geps.dev/progress/83) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![82%](https://geps.dev/progress/82) |
| Basque (Euskara) (eu_ES) | ![48%](https://geps.dev/progress/48) |
| Bulgarian (Български) (bg_BG) | ![92%](https://geps.dev/progress/92) |
| Catalan (Català) (ca_CA) | ![89%](https://geps.dev/progress/89) |
| Croatian (Hrvatski) (hr_HR) | ![81%](https://geps.dev/progress/81) |
| Czech (Česky) (cs_CZ) | ![91%](https://geps.dev/progress/91) |
| Danish (Dansk) (da_DK) | ![80%](https://geps.dev/progress/80) |
| Dutch (Nederlands) (nl_NL) | ![79%](https://geps.dev/progress/79) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![89%](https://geps.dev/progress/89) |
| German (Deutsch) (de_DE) | ![98%](https://geps.dev/progress/98) |
| Greek (Ελληνικά) (el_GR) | ![67%](https://geps.dev/progress/67) |
| Hindi (हिंदी) (hi_IN) | ![67%](https://geps.dev/progress/67) |
| French (Français) (fr_FR) | ![92%](https://geps.dev/progress/92) |
| German (Deutsch) (de_DE) | ![99%](https://geps.dev/progress/99) |
| Greek (Ελληνικά) (el_GR) | ![91%](https://geps.dev/progress/91) |
| Hindi (हिंदी) (hi_IN) | ![91%](https://geps.dev/progress/91) |
| Hungarian (Magyar) (hu_HU) | ![99%](https://geps.dev/progress/99) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![62%](https://geps.dev/progress/62) |
| Irish (Gaeilge) (ga_IE) | ![68%](https://geps.dev/progress/68) |
| Italian (Italiano) (it_IT) | ![98%](https://geps.dev/progress/98) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![80%](https://geps.dev/progress/80) |
| Irish (Gaeilge) (ga_IE) | ![91%](https://geps.dev/progress/91) |
| Italian (Italiano) (it_IT) | ![99%](https://geps.dev/progress/99) |
| Japanese (日本語) (ja_JP) | ![93%](https://geps.dev/progress/93) |
| Korean (한국어) (ko_KR) | ![67%](https://geps.dev/progress/67) |
| Norwegian (Norsk) (no_NB) | ![66%](https://geps.dev/progress/66) |
| Persian (فارسی) (fa_IR) | ![64%](https://geps.dev/progress/64) |
| Polish (Polski) (pl_PL) | ![72%](https://geps.dev/progress/72) |
| Portuguese (Português) (pt_PT) | ![69%](https://geps.dev/progress/69) |
| Portuguese Brazilian (Português) (pt_BR) | ![76%](https://geps.dev/progress/76) |
| Romanian (Română) (ro_RO) | ![57%](https://geps.dev/progress/57) |
| Russian (Русский) (ru_RU) | ![88%](https://geps.dev/progress/88) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![95%](https://geps.dev/progress/95) |
| Korean (한국어) (ko_KR) | ![92%](https://geps.dev/progress/92) |
| Norwegian (Norsk) (no_NB) | ![86%](https://geps.dev/progress/86) |
| Persian (فارسی) (fa_IR) | ![87%](https://geps.dev/progress/87) |
| Polish (Polski) (pl_PL) | ![95%](https://geps.dev/progress/95) |
| Portuguese (Português) (pt_PT) | ![91%](https://geps.dev/progress/91) |
| Portuguese Brazilian (Português) (pt_BR) | ![97%](https://geps.dev/progress/97) |
| Romanian (Română) (ro_RO) | ![75%](https://geps.dev/progress/75) |
| Russian (Русский) (ru_RU) | ![93%](https://geps.dev/progress/93) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![60%](https://geps.dev/progress/60) |
| Simplified Chinese (简体中文) (zh_CN) | ![93%](https://geps.dev/progress/93) |
| Slovakian (Slovensky) (sk_SK) | ![51%](https://geps.dev/progress/51) |
| Slovenian (Slovenščina) (sl_SI) | ![71%](https://geps.dev/progress/71) |
| Spanish (Español) (es_ES) | ![74%](https://geps.dev/progress/74) |
| Swedish (Svenska) (sv_SE) | ![65%](https://geps.dev/progress/65) |
| Thai (ไทย) (th_TH) | ![59%](https://geps.dev/progress/59) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) |
| Traditional Chinese (繁體中文) (zh_TW) | ![97%](https://geps.dev/progress/97) |
| Turkish (Türkçe) (tr_TR) | ![80%](https://geps.dev/progress/80) |
| Ukrainian (Українська) (uk_UA) | ![71%](https://geps.dev/progress/71) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![57%](https://geps.dev/progress/57) |
| Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) |
| Slovakian (Slovensky) (sk_SK) | ![69%](https://geps.dev/progress/69) |
| Slovenian (Slovenščina) (sl_SI) | ![94%](https://geps.dev/progress/94) |
| Spanish (Español) (es_ES) | ![99%](https://geps.dev/progress/99) |
| Swedish (Svenska) (sv_SE) | ![87%](https://geps.dev/progress/87) |
| Thai (ไทย) (th_TH) | ![80%](https://geps.dev/progress/80) |
| Tibetan (བོད་ཡིག་) (zh_BO) | ![88%](https://geps.dev/progress/88) |
| Traditional Chinese (繁體中文) (zh_TW) | ![99%](https://geps.dev/progress/99) |
| Turkish (Türkçe) (tr_TR) | ![97%](https://geps.dev/progress/97) |
| Ukrainian (Українська) (uk_UA) | ![96%](https://geps.dev/progress/96) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![73%](https://geps.dev/progress/73) |
| Malayalam (മലയാളം) (ml_ML) | ![99%](https://geps.dev/progress/99) |
## Stirling PDF Enterprise
@@ -168,7 +168,7 @@ Check out our [Enterprise docs](https://docs.stirlingpdf.com/Pro)
Join our community:
- [Contribution Guidelines](CONTRIBUTING.md)
- [Translation Guide (How to add custom languages)](devGuide/HowToAddNewLanguage.md)
- [Developer Guide](devGuide/DeveloperGuide.md)
- [Translation Guide (How to add custom languages)](HowToAddNewLanguage.md)
- [Issue Tracker](https://github.com/Stirling-Tools/Stirling-PDF/issues)
- [Discord Community](https://discord.gg/HYmhKj45pU)
- [Developer Guide](DeveloperGuide.md)
View File
@@ -124,18 +124,10 @@
"moduleName": ".*",
"moduleLicense": "COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.0"
},
{
"moduleName": ".*",
"moduleLicense": "Eclipse Public License 1.0"
},
{
"moduleName": ".*",
"moduleLicense": "Eclipse Public License - v 1.0"
},
{
"moduleName": ".*",
"moduleLicense": "Eclipse Public License v2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Eclipse Public License v. 2.0"
-196
View File
@@ -1,196 +0,0 @@
### Eclipse ###
.metadata
bin/
tmp/
*.tmp
*.bak
*.exe
*.swp
*~.nib
local.properties
.settings/
.loadpath
.recommenders
.classpath
.project
version.properties
#### Stirling-PDF Files ###
pipeline/watchedFolders/
pipeline/finishedFolders/
customFiles/
configs/
watchedFolders/
clientWebUI/
!cucumber/
!cucumber/exampleFiles/
!cucumber/exampleFiles/example_html.zip
exampleYmlFiles/stirling/
/testing/file_snapshots
SwaggerDoc.json
# Gradle
.gradle
.lock
# External tool builders
.externalToolBuilders/
# Locally stored "Eclipse launch configurations"
*.launch
# PyDev specific (Python IDE for Eclipse)
*.pydevproject
# CDT-specific (C/C++ Development Tooling)
.cproject
# CDT- autotools
.autotools
# Java annotation processor (APT)
.factorypath
# PDT-specific (PHP Development Tools)
.buildpath
# sbteclipse plugin
.target
# Tern plugin
.tern-project
# TeXlipse plugin
.texlipse
# STS (Spring Tool Suite)
.springBeans
# Code Recommenders
.recommenders/
# Annotation Processing
.apt_generated/
.apt_generated_test/
# Scala IDE specific (Scala & Java development for Eclipse)
.cache-main
.scala_dependencies
.worksheet
# Uncomment this line if you wish to ignore the project description file.
# Typically, this file would be tracked if it contains build/dependency configurations:
#.project
### Eclipse Patch ###
# Spring Boot Tooling
.sts4-cache/
### Git ###
# Created by git for backups. To disable backups in Git:
# $ git config --global mergetool.keepBackup false
*.orig
# Created by git when using merge tools for conflicts
*.BACKUP.*
*.BASE.*
*.LOCAL.*
*.REMOTE.*
*_BACKUP_*.txt
*_BASE_*.txt
*_LOCAL_*.txt
*_REMOTE_*.txt
### Java ###
# Compiled class file
*.class
# Log file
*.log
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
*.db
/build
/app/common/build/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*.pyo
# Virtual environments
.env*
.venv*
env*/
venv*/
ENV/
env.bak/
venv.bak/
# VS Code
/.vscode/**/*
!/.vscode/settings.json
!/.vscode/extensions.json
# IntelliJ IDEA
.idea/
*.iml
out/
# Ignore Mac DS_Store files
.DS_Store
**/.DS_Store
# cucumber
/cucumber/reports/**
# Certs and Security Files
*.p12
*.pk8
*.pem
*.crt
*.cer
*.cert
*.der
*.key
*.csr
*.kdbx
*.jks
*.asc
# SSH Keys
*.pub
*.priv
id_rsa
id_rsa.pub
id_ecdsa
id_ecdsa.pub
id_ed25519
id_ed25519.pub
.ssh/
*ssh
# cache
.cache
.ruff_cache
.mypy_cache
.pytest_cache
.ipynb_checkpoints
**/jcef-bundle/
# node_modules
node_modules/
-45
View File
@@ -1,45 +0,0 @@
// Configure bootRun to disable it or point to a main class
bootRun {
enabled = false
}
spotless {
java {
target 'src/**/java/**/*.java'
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
toggleOffOn()
trimTrailingWhitespace()
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'
api 'org.springframework.boot:spring-boot-starter-aop'
// api 'org.springframework.boot:spring-boot-starter-thymeleaf' // Deprecated - UI moved to React frontend
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1'
api 'com.fathzer:javaluator:3.0.6'
api 'com.posthog.java:posthog:1.2.0'
api 'org.apache.commons:commons-lang3:3.18.0'
api 'com.drewnoakes:metadata-extractor:2.19.0' // Image metadata extractor
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.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.4'
}
@@ -1,78 +0,0 @@
package stirling.software.common.annotations;
import java.lang.annotation.*;
import org.springframework.core.annotation.AliasFor;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
/**
* Shortcut for a POST endpoint that is executed through the Stirling "autojob" framework.
*
* <p>Behaviour notes:
*
* <ul>
* <li>The endpoint is registered with {@code POST} and, by default, consumes {@code
* multipart/form-data} unless you override {@link #consumes()}.
* <li>When the client supplies {@code ?async=true} the call is handed to {@link
* stirling.software.common.service.JobExecutorService JobExecutorService} where it may be
* queued, retried, tracked and subject to timeouts. For synchronous (default) invocations
* these advanced options are ignored.
* <li>Progress information (see {@link #trackProgress()}) is stored in {@link
* stirling.software.common.service.TaskManager TaskManager} and can be polled via <code>
* GET /api/v1/general/job/{id}</code>.
* </ul>
*
* <p>Unless stated otherwise an attribute only affects <em>async</em> execution.
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@RequestMapping(method = RequestMethod.POST)
public @interface AutoJobPostMapping {
/** Alias for {@link RequestMapping#value} the path mapping of the endpoint. */
@AliasFor(annotation = RequestMapping.class, attribute = "value")
String[] value() default {};
/** MIME types this endpoint accepts. Defaults to {@code multipart/form-data}. */
@AliasFor(annotation = RequestMapping.class, attribute = "consumes")
String[] consumes() default {"multipart/form-data"};
/**
* Maximum execution time in milliseconds before the job is aborted. A negative value means "use
* the application default".
*
* <p>Only honoured when {@code async=true}.
*/
long timeout() default -1;
/**
* Total number of attempts (initial + retries). Must be at least&nbsp;1. Retries are executed
* with exponential backoff.
*
* <p>Only honoured when {@code async=true}.
*/
int retryCount() default 1;
/**
* Record percentage / note updates so they can be retrieved via the REST status endpoint.
*
* <p>Only honoured when {@code async=true}.
*/
boolean trackProgress() default true;
/**
* If {@code true} the job may be placed in a queue instead of being rejected when resources are
* scarce.
*
* <p>Only honoured when {@code async=true}.
*/
boolean queueable() default false;
/**
* Relative resource weight (1100) used by the scheduler to prioritise / throttle jobs. Values
* below 1 are clamped to&nbsp;1, values above 100 to&nbsp;100.
*/
int resourceWeight() default 50;
}
@@ -1,289 +0,0 @@
package stirling.software.common.aop;
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.FileOrUploadService;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.JobExecutorService;
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
@Order(0) // Highest precedence - executes before audit aspects
public class AutoJobAspect {
private static final Duration RETRY_BASE_DELAY = Duration.ofMillis(100);
private final JobExecutorService jobExecutorService;
private final HttpServletRequest request;
private final FileOrUploadService fileOrUploadService;
private final FileStorage fileStorage;
@Around("@annotation(autoJobPostMapping)")
public Object wrapWithJobExecution(
ProceedingJoinPoint joinPoint, AutoJobPostMapping autoJobPostMapping) {
// This aspect will run before any audit aspects due to @Order(0)
// Extract parameters from the request and annotation
boolean async = Boolean.parseBoolean(request.getParameter("async"));
log.debug(
"AutoJobAspect: Processing {} {} with async={}",
request.getMethod(),
request.getRequestURI(),
async);
long timeout = autoJobPostMapping.timeout();
int retryCount = autoJobPostMapping.retryCount();
boolean trackProgress = autoJobPostMapping.trackProgress();
log.debug(
"AutoJobPostMapping execution with async={}, timeout={}, retryCount={}, trackProgress={}",
async,
timeout > 0 ? timeout : "default",
retryCount,
trackProgress);
// Process arguments in-place to avoid type mismatch issues
Object[] args = processArgsInPlace(joinPoint.getArgs(), async);
// Extract queueable and resourceWeight parameters and validate
boolean queueable = autoJobPostMapping.queueable();
int resourceWeight = Math.max(1, Math.min(100, autoJobPostMapping.resourceWeight()));
// Integrate with the JobExecutorService
if (retryCount <= 1) {
// No retries needed, simple execution
return jobExecutorService.runJobGeneric(
async,
() -> {
try {
// Note: Progress tracking is handled in TaskManager/JobExecutorService
// The trackProgress flag controls whether detailed progress is stored
// for REST API queries, not WebSocket notifications
return joinPoint.proceed(args);
} catch (Throwable ex) {
log.error(
"AutoJobAspect caught exception during job execution: {}",
ex.getMessage(),
ex);
throw new RuntimeException(ex);
}
},
timeout,
queueable,
resourceWeight);
} else {
// Use retry logic
return executeWithRetries(
joinPoint,
args,
async,
timeout,
retryCount,
trackProgress,
queueable,
resourceWeight);
}
}
private Object executeWithRetries(
ProceedingJoinPoint joinPoint,
Object[] args,
boolean async,
long timeout,
int maxRetries,
boolean trackProgress,
boolean queueable,
int resourceWeight) {
// Keep jobId reference for progress tracking in TaskManager
AtomicReference<String> jobIdRef = new AtomicReference<>();
return jobExecutorService.runJobGeneric(
async,
() -> {
// Use iterative approach instead of recursion to avoid stack overflow
Throwable lastException = null;
// Attempt counter starts at 1 for first try
for (int currentAttempt = 1; currentAttempt <= maxRetries; currentAttempt++) {
try {
if (trackProgress && async) {
// Get jobId for progress tracking in TaskManager
// This enables REST API progress queries, not WebSocket
if (jobIdRef.get() == null) {
jobIdRef.set(getJobIdFromContext());
}
String jobId = jobIdRef.get();
if (jobId != null) {
log.debug(
"Tracking progress for job {} (attempt {}/{})",
jobId,
currentAttempt,
maxRetries);
// Progress is tracked in TaskManager for REST API access
// No WebSocket notifications sent here
}
}
// Attempt to execute the operation
return joinPoint.proceed(args);
} catch (Throwable ex) {
lastException = ex;
log.error(
"AutoJobAspect caught exception during job execution (attempt {}/{}): {}",
currentAttempt,
maxRetries,
ex.getMessage(),
ex);
// Check if we should retry
if (currentAttempt < maxRetries) {
log.info(
"Retrying operation, attempt {}/{}",
currentAttempt + 1,
maxRetries);
if (trackProgress && async) {
String jobId = jobIdRef.get();
if (jobId != null) {
log.debug(
"Recording retry attempt for job {} in TaskManager",
jobId);
// Retry info is tracked in TaskManager for REST API access
}
}
// Use non-blocking delay for all retry attempts to avoid blocking
// threads
// For sync jobs this avoids starving the tomcat thread pool under
// load
long delayMs = RETRY_BASE_DELAY.toMillis() * currentAttempt;
// Execute the retry after a delay through the JobExecutorService
// rather than blocking the current thread with sleep
CompletableFuture<Object> delayedRetry = new CompletableFuture<>();
// Use a delayed executor for non-blocking delay
CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS)
.execute(
() -> {
// Continue the retry loop in the next iteration
// We can't return from here directly since
// we're in a Runnable
delayedRetry.complete(null);
});
// Wait for the delay to complete before continuing
try {
delayedRetry.join();
} catch (Exception e) {
Thread.currentThread().interrupt();
break;
}
} else {
// No more retries, we'll throw the exception after the loop
break;
}
}
}
// If we get here, all retries failed
if (lastException != null) {
throw new RuntimeException(
"Job failed after "
+ maxRetries
+ " attempts: "
+ lastException.getMessage(),
lastException);
}
// This should never happen if lastException is properly tracked
throw new RuntimeException("Job failed but no exception was recorded");
},
timeout,
queueable,
resourceWeight);
}
/**
* Processes arguments in-place to handle file resolution and async file persistence. This
* approach avoids type mismatch issues by modifying the original objects directly.
*
* @param originalArgs The original arguments
* @param async Whether this is an async operation
* @return The original array with processed arguments
*/
private Object[] processArgsInPlace(Object[] originalArgs, boolean async) {
if (originalArgs == null || originalArgs.length == 0) {
return originalArgs;
}
// Process all arguments in-place
for (int i = 0; i < originalArgs.length; i++) {
Object arg = originalArgs[i];
if (arg instanceof PDFFile pdfFile) {
// Case 1: fileId is provided but no fileInput
if (pdfFile.getFileInput() == null && pdfFile.getFileId() != null) {
try {
log.debug("Using fileId {} to get file content", pdfFile.getFileId());
MultipartFile file = fileStorage.retrieveFile(pdfFile.getFileId());
pdfFile.setFileInput(file);
} catch (Exception e) {
throw new RuntimeException(
"Failed to resolve file by ID: " + pdfFile.getFileId(), e);
}
}
// Case 2: For async requests, we need to make a copy of the MultipartFile
else if (async && pdfFile.getFileInput() != null) {
try {
log.debug("Making persistent copy of uploaded file for async processing");
MultipartFile originalFile = pdfFile.getFileInput();
String fileId = fileStorage.storeFile(originalFile);
// Store the fileId for later reference
pdfFile.setFileId(fileId);
// Replace the original MultipartFile with our persistent copy
MultipartFile persistentFile = fileStorage.retrieveFile(fileId);
pdfFile.setFileInput(persistentFile);
log.debug("Created persistent file copy with fileId: {}", fileId);
} catch (IOException e) {
throw new RuntimeException(
"Failed to create persistent copy of uploaded file", e);
}
}
}
}
return originalArgs;
}
private String getJobIdFromContext() {
try {
return (String) request.getAttribute("jobId");
} catch (Exception e) {
log.debug("Could not retrieve job ID from context: {}", e.getMessage());
return null;
}
}
}
@@ -1,59 +0,0 @@
package stirling.software.common.config;
import java.nio.file.Files;
import java.nio.file.Path;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.TempFileRegistry;
/**
* Configuration for the temporary file management system. Sets up the necessary beans and
* configures system properties.
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class TempFileConfiguration {
private final ApplicationProperties applicationProperties;
/**
* Create the TempFileRegistry bean.
*
* @return A new TempFileRegistry instance
*/
@Bean
public TempFileRegistry tempFileRegistry() {
return new TempFileRegistry();
}
@PostConstruct
public void initTempFileConfig() {
try {
ApplicationProperties.TempFileManagement tempFiles =
applicationProperties.getSystem().getTempFileManagement();
String customTempDirectory = tempFiles.getBaseTmpDir();
// Create the temp directory if it doesn't exist
Path tempDir = Path.of(customTempDirectory);
if (!Files.exists(tempDir)) {
Files.createDirectories(tempDir);
log.info("Created temporary directory: {}", tempDir);
}
log.debug("Temporary file configuration initialized");
log.debug("Using temp directory: {}", customTempDirectory);
log.debug("Temp file prefix: {}", tempFiles.getPrefix());
} catch (Exception e) {
log.error("Failed to initialize temporary file configuration", e);
}
}
}
@@ -1,82 +0,0 @@
package stirling.software.common.config;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFileRegistry;
/**
* Handles cleanup of temporary files on application shutdown. Implements Spring's DisposableBean
* interface to ensure cleanup happens during normal application shutdown.
*/
@Slf4j
@Component
public class TempFileShutdownHook implements DisposableBean {
private final TempFileRegistry registry;
public TempFileShutdownHook(TempFileRegistry registry) {
this.registry = registry;
// Register a JVM shutdown hook as a backup in case Spring's
// DisposableBean mechanism doesn't trigger (e.g., during a crash)
Runtime.getRuntime().addShutdownHook(new Thread(this::cleanupTempFiles));
}
/** Spring's DisposableBean interface method. Called during normal application shutdown. */
@Override
public void destroy() {
log.info("Application shutting down, cleaning up temporary files");
cleanupTempFiles();
}
/** Clean up all registered temporary files and directories. */
private void cleanupTempFiles() {
try {
// Clean up all registered files
Set<Path> files = registry.getAllRegisteredFiles();
int deletedCount = 0;
for (Path file : files) {
try {
if (Files.exists(file)) {
Files.deleteIfExists(file);
deletedCount++;
}
} catch (IOException e) {
log.warn("Failed to delete temp file during shutdown: {}", file, e);
}
}
// Clean up all registered directories
Set<Path> directories = registry.getTempDirectories();
for (Path dir : directories) {
try {
if (Files.exists(dir)) {
GeneralUtils.deleteDirectory(dir);
deletedCount++;
}
} catch (IOException e) {
log.warn("Failed to delete temp directory during shutdown: {}", dir, e);
}
}
log.info(
"Shutdown cleanup complete. Deleted {} temporary files/directories",
deletedCount);
// Clear the registry
registry.clear();
} catch (Exception e) {
log.error("Error during shutdown cleanup", e);
}
}
}
@@ -1,41 +0,0 @@
package stirling.software.common.model.api.converters;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class EmlToPdfRequest extends PDFFile {
// fileInput is inherited from PDFFile
@Schema(
description = "Include email attachments in the PDF output",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
example = "false")
private boolean includeAttachments = false;
@Schema(
description = "Maximum attachment size in MB to include (default 10MB, range: 1-100)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
example = "10",
minimum = "1",
maximum = "100")
private int maxAttachmentSizeMB = 10;
@Schema(
description = "Download HTML intermediate file instead of PDF",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
example = "false")
private boolean downloadHtml = false;
@Schema(
description = "Include CC and BCC recipients in header (if available)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
example = "true")
private boolean includeAllRecipients = true;
}
@@ -1,7 +0,0 @@
package stirling.software.common.model.exception;
public class UnsupportedClaimException extends RuntimeException {
public UnsupportedClaimException(String message) {
super(message);
}
}
@@ -1,15 +0,0 @@
package stirling.software.common.model.job;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class JobProgress {
private String jobId;
private String status;
private int percentComplete;
private String message;
}
@@ -1,14 +0,0 @@
package stirling.software.common.model.job;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class JobResponse<T> {
private boolean async;
private String jobId;
private T result;
}
@@ -1,164 +0,0 @@
package stirling.software.common.model.job;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** Represents the result of a job execution. Used by the TaskManager to store job results. */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JobResult {
/** The job ID */
private String jobId;
/** Flag indicating if the job is complete */
private boolean complete;
/** Error message if the job failed */
private String error;
/** List of result files for jobs that produce files */
@JsonIgnore private List<ResultFile> resultFiles;
/** Time when the job was created */
private LocalDateTime createdAt;
/** Time when the job was completed */
private LocalDateTime completedAt;
/** The actual result object, if not a file */
private Object result;
/**
* Notes attached to this job for tracking purposes. Uses CopyOnWriteArrayList for thread safety
* when notes are added concurrently.
*/
private final List<String> notes = new CopyOnWriteArrayList<>();
/**
* Create a new JobResult with the given job ID
*
* @param jobId The job ID
* @return A new JobResult
*/
public static JobResult createNew(String jobId) {
return JobResult.builder()
.jobId(jobId)
.complete(false)
.createdAt(LocalDateTime.now())
.build();
}
/**
* Mark this job as complete with a general result
*
* @param result The result object
*/
public void completeWithResult(Object result) {
this.complete = true;
this.result = result;
this.completedAt = LocalDateTime.now();
}
/**
* Mark this job as failed with an error message
*
* @param error The error message
*/
public void failWithError(String error) {
this.complete = true;
this.error = error;
this.completedAt = LocalDateTime.now();
}
/**
* Mark this job as complete with multiple file results
*
* @param resultFiles The list of result files
*/
public void completeWithFiles(List<ResultFile> resultFiles) {
this.complete = true;
this.resultFiles = new ArrayList<>(resultFiles);
this.completedAt = LocalDateTime.now();
}
/**
* Mark this job as complete with a single file result (convenience method)
*
* @param fileId The file ID of the result
* @param fileName The file name
* @param contentType The content type of the file
* @param fileSize The size of the file in bytes
*/
public void completeWithSingleFile(
String fileId, String fileName, String contentType, long fileSize) {
ResultFile resultFile =
ResultFile.builder()
.fileId(fileId)
.fileName(fileName)
.contentType(contentType)
.fileSize(fileSize)
.build();
completeWithFiles(List.of(resultFile));
}
/**
* Check if this job has file results
*
* @return true if this job has file results, false otherwise
*/
public boolean hasFiles() {
return resultFiles != null && !resultFiles.isEmpty();
}
/**
* Check if this job has multiple file results
*
* @return true if this job has multiple file results, false otherwise
*/
public boolean hasMultipleFiles() {
return resultFiles != null && resultFiles.size() > 1;
}
/**
* Get all result files
*
* @return List of result files
*/
public List<ResultFile> getAllResultFiles() {
if (resultFiles != null && !resultFiles.isEmpty()) {
return Collections.unmodifiableList(resultFiles);
}
return Collections.emptyList();
}
/**
* Add a note to this job
*
* @param note The note to add
*/
public void addNote(String note) {
this.notes.add(note);
}
/**
* Get all notes attached to this job
*
* @return An unmodifiable view of the notes list
*/
public List<String> getNotes() {
return Collections.unmodifiableList(notes);
}
}
@@ -1,43 +0,0 @@
package stirling.software.common.model.job;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** Represents statistics about jobs in the system */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JobStats {
/** Total number of jobs (active and completed) */
private int totalJobs;
/** Number of active (incomplete) jobs */
private int activeJobs;
/** Number of completed jobs */
private int completedJobs;
/** Number of failed jobs */
private int failedJobs;
/** Number of successful jobs */
private int successfulJobs;
/** Number of jobs with file results */
private int fileResultJobs;
/** The oldest active job's creation timestamp */
private LocalDateTime oldestActiveJobTime;
/** The newest active job's creation timestamp */
private LocalDateTime newestActiveJobTime;
/** The average processing time for completed jobs in milliseconds */
private long averageProcessingTimeMs;
}
@@ -1,26 +0,0 @@
package stirling.software.common.model.job;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** Represents a single file result from a job execution */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ResultFile {
/** The file ID for accessing the file */
private String fileId;
/** The original file name */
private String fileName;
/** MIME type of the file */
private String contentType;
/** Size of the file in bytes */
private long fileSize;
}
@@ -1,78 +0,0 @@
package stirling.software.common.service;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.file.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class FileOrUploadService {
@Value("${stirling.tempDir:/tmp/stirling-files}")
private String tempDirPath;
public Path resolveFilePath(String fileId) {
return Path.of(tempDirPath).resolve(fileId);
}
public MultipartFile toMockMultipartFile(String name, byte[] data) throws IOException {
return new CustomMultipartFile(name, data);
}
// Custom implementation of MultipartFile
private static class CustomMultipartFile implements MultipartFile {
private final String name;
private final byte[] content;
public CustomMultipartFile(String name, byte[] content) {
this.name = name;
this.content = content;
}
@Override
public String getName() {
return name;
}
@Override
public String getOriginalFilename() {
return name;
}
@Override
public String getContentType() {
return "application/pdf";
}
@Override
public boolean isEmpty() {
return content == null || content.length == 0;
}
@Override
public long getSize() {
return content.length;
}
@Override
public byte[] getBytes() throws IOException {
return content;
}
@Override
public java.io.InputStream getInputStream() throws IOException {
return new ByteArrayInputStream(content);
}
@Override
public void transferTo(java.io.File dest) throws IOException, IllegalStateException {
Files.write(dest.toPath(), content);
}
}
}
@@ -1,184 +0,0 @@
package stirling.software.common.service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* Service for storing and retrieving files with unique file IDs. Used by the AutoJobPostMapping
* system to handle file references.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class FileStorage {
@Value("${stirling.tempDir:/tmp/stirling-files}")
private String tempDirPath;
private final FileOrUploadService fileOrUploadService;
/**
* Store a file and return its unique ID
*
* @param file The file to store
* @return The unique ID assigned to the file
* @throws IOException If there is an error storing the file
*/
public String storeFile(MultipartFile file) throws IOException {
String fileId = generateFileId();
Path filePath = getFilePath(fileId);
// Ensure the directory exists
Files.createDirectories(filePath.getParent());
// Transfer the file to the storage location
file.transferTo(filePath.toFile());
log.debug("Stored file with ID: {}", fileId);
return fileId;
}
/**
* Store a byte array as a file and return its unique ID
*
* @param bytes The byte array to store
* @param originalName The original name of the file (for extension)
* @return The unique ID assigned to the file
* @throws IOException If there is an error storing the file
*/
public String storeBytes(byte[] bytes, String originalName) throws IOException {
String fileId = generateFileId();
Path filePath = getFilePath(fileId);
// Ensure the directory exists
Files.createDirectories(filePath.getParent());
// Write the bytes to the file
Files.write(filePath, bytes);
log.debug("Stored byte array with ID: {}", fileId);
return fileId;
}
/**
* Retrieve a file by its ID as a MultipartFile
*
* @param fileId The ID of the file to retrieve
* @return The file as a MultipartFile
* @throws IOException If the file doesn't exist or can't be read
*/
public MultipartFile retrieveFile(String fileId) throws IOException {
Path filePath = getFilePath(fileId);
if (!Files.exists(filePath)) {
throw new IOException("File not found with ID: " + fileId);
}
byte[] fileData = Files.readAllBytes(filePath);
return fileOrUploadService.toMockMultipartFile(fileId, fileData);
}
/**
* Retrieve a file by its ID as a byte array
*
* @param fileId The ID of the file to retrieve
* @return The file as a byte array
* @throws IOException If the file doesn't exist or can't be read
*/
public byte[] retrieveBytes(String fileId) throws IOException {
Path filePath = getFilePath(fileId);
if (!Files.exists(filePath)) {
throw new IOException("File not found with ID: " + fileId);
}
return Files.readAllBytes(filePath);
}
/**
* Delete a file by its ID
*
* @param fileId The ID of the file to delete
* @return true if the file was deleted, false otherwise
*/
public boolean deleteFile(String fileId) {
try {
Path filePath = getFilePath(fileId);
return Files.deleteIfExists(filePath);
} catch (IOException e) {
log.error("Error deleting file with ID: {}", fileId, e);
return false;
}
}
/**
* Check if a file exists by its ID
*
* @param fileId The ID of the file to check
* @return true if the file exists, false otherwise
*/
public boolean fileExists(String fileId) {
Path filePath = getFilePath(fileId);
return Files.exists(filePath);
}
/**
* Get the size of a file by its ID without loading the content into memory
*
* @param fileId The ID of the file
* @return The size of the file in bytes
* @throws IOException If the file doesn't exist or can't be read
*/
public long getFileSize(String fileId) throws IOException {
Path filePath = getFilePath(fileId);
if (!Files.exists(filePath)) {
throw new IOException("File not found with ID: " + fileId);
}
return Files.size(filePath);
}
/**
* Get the path for a file ID
*
* @param fileId The ID of the file
* @return The path to the file
* @throws IllegalArgumentException if fileId contains path traversal characters or resolves
* outside base directory
*/
private Path getFilePath(String fileId) {
// Validate fileId to prevent path traversal
if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) {
throw new IllegalArgumentException("Invalid file ID");
}
Path basePath = Path.of(tempDirPath).normalize().toAbsolutePath();
Path resolvedPath = basePath.resolve(fileId).normalize();
// Ensure resolved path is within the base directory
if (!resolvedPath.startsWith(basePath)) {
throw new IllegalArgumentException("File ID resolves to an invalid path");
}
return resolvedPath;
}
/**
* Generate a unique file ID
*
* @return A unique file ID
*/
private String generateFileId() {
return UUID.randomUUID().toString();
}
}
@@ -1,476 +0,0 @@
package stirling.software.common.service;
import java.io.IOException;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.job.JobResponse;
import stirling.software.common.util.ExecutorFactory;
/** Service for executing jobs asynchronously or synchronously */
@Service
@Slf4j
public class JobExecutorService {
private final TaskManager taskManager;
private final FileStorage fileStorage;
private final HttpServletRequest request;
private final ResourceMonitor resourceMonitor;
private final JobQueue jobQueue;
private final ExecutorService executor = ExecutorFactory.newVirtualOrCachedThreadExecutor();
private final long effectiveTimeoutMs;
public JobExecutorService(
TaskManager taskManager,
FileStorage fileStorage,
HttpServletRequest request,
ResourceMonitor resourceMonitor,
JobQueue jobQueue,
@Value("${spring.mvc.async.request-timeout:1200000}") long asyncRequestTimeoutMs,
@Value("${server.servlet.session.timeout:30m}") String sessionTimeout) {
this.taskManager = taskManager;
this.fileStorage = fileStorage;
this.request = request;
this.resourceMonitor = resourceMonitor;
this.jobQueue = jobQueue;
// Parse session timeout and calculate effective timeout once during initialization
long sessionTimeoutMs = parseSessionTimeout(sessionTimeout);
this.effectiveTimeoutMs = Math.min(asyncRequestTimeoutMs, sessionTimeoutMs);
log.debug(
"Job executor configured with effective timeout of {} ms", this.effectiveTimeoutMs);
}
/**
* Run a job either asynchronously or synchronously
*
* @param async Whether to run the job asynchronously
* @param work The work to be done
* @return The response
*/
public ResponseEntity<?> runJobGeneric(boolean async, Supplier<Object> work) {
return runJobGeneric(async, work, -1);
}
/**
* Run a job either asynchronously or synchronously with a custom timeout
*
* @param async Whether to run the job asynchronously
* @param work The work to be done
* @param customTimeoutMs Custom timeout in milliseconds, or -1 to use the default
* @return The response
*/
public ResponseEntity<?> runJobGeneric(
boolean async, Supplier<Object> work, long customTimeoutMs) {
return runJobGeneric(async, work, customTimeoutMs, false, 50);
}
/**
* Run a job either asynchronously or synchronously with custom parameters
*
* @param async Whether to run the job asynchronously
* @param work The work to be done
* @param customTimeoutMs Custom timeout in milliseconds, or -1 to use the default
* @param queueable Whether this job can be queued when system resources are limited
* @param resourceWeight The resource weight of this job (1-100)
* @return The response
*/
public ResponseEntity<?> runJobGeneric(
boolean async,
Supplier<Object> work,
long customTimeoutMs,
boolean queueable,
int resourceWeight) {
String jobId = UUID.randomUUID().toString();
// Store the job ID in the request for potential use by other components
if (request != null) {
request.setAttribute("jobId", jobId);
// Also track this job ID in the user's session for authorization purposes
// This ensures users can only cancel their own jobs
if (request.getSession() != null) {
@SuppressWarnings("unchecked")
java.util.Set<String> userJobIds =
(java.util.Set<String>) request.getSession().getAttribute("userJobIds");
if (userJobIds == null) {
userJobIds = new java.util.concurrent.ConcurrentSkipListSet<>();
request.getSession().setAttribute("userJobIds", userJobIds);
}
userJobIds.add(jobId);
log.debug("Added job ID {} to user session", jobId);
}
}
// Determine which timeout to use
long timeoutToUse = customTimeoutMs > 0 ? customTimeoutMs : effectiveTimeoutMs;
log.debug(
"Running job with ID: {}, async: {}, timeout: {}ms, queueable: {}, weight: {}",
jobId,
async,
timeoutToUse,
queueable,
resourceWeight);
// Check if we need to queue this job based on resource availability
boolean shouldQueue =
queueable
&& async
&& // Only async jobs can be queued
resourceMonitor.shouldQueueJob(resourceWeight);
if (shouldQueue) {
// Queue the job instead of executing immediately
log.debug(
"Queueing job {} due to resource constraints (weight: {})",
jobId,
resourceWeight);
taskManager.createTask(jobId);
// Create a specialized wrapper that updates the TaskManager
Supplier<Object> wrappedWork =
() -> {
try {
Object result = work.get();
processJobResult(jobId, result);
return result;
} catch (Exception e) {
log.error(
"Error executing queued job {}: {}", jobId, e.getMessage(), e);
taskManager.setError(jobId, e.getMessage());
throw e;
}
};
// Queue the job and get the future
CompletableFuture<ResponseEntity<?>> future =
jobQueue.queueJob(jobId, resourceWeight, wrappedWork, timeoutToUse);
// Return immediately with job ID
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
} else if (async) {
taskManager.createTask(jobId);
executor.execute(
() -> {
try {
log.debug(
"Running async job {} with timeout {} ms", jobId, timeoutToUse);
// Execute with timeout
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
processJobResult(jobId, result);
} catch (TimeoutException te) {
log.error("Job {} timed out after {} ms", jobId, timeoutToUse);
taskManager.setError(jobId, "Job timed out");
} catch (Exception e) {
log.error("Error executing job {}: {}", jobId, e.getMessage(), e);
taskManager.setError(jobId, e.getMessage());
}
});
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
} else {
try {
log.debug("Running sync job with timeout {} ms", timeoutToUse);
// Execute with timeout
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
// If the result is already a ResponseEntity, return it directly
if (result instanceof ResponseEntity) {
return (ResponseEntity<?>) result;
}
// Process different result types
return handleResultForSyncJob(result);
} catch (TimeoutException te) {
log.error("Synchronous job timed out after {} ms", timeoutToUse);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Job timed out after " + timeoutToUse + " ms"));
} catch (Exception e) {
log.error("Error executing synchronous job: {}", e.getMessage(), e);
// Construct a JSON error response
return ResponseEntity.internalServerError()
.body(Map.of("error", "Job failed: " + e.getMessage()));
}
}
}
/**
* Process the result of an asynchronous job
*
* @param jobId The job ID
* @param result The result
*/
private void processJobResult(String jobId, Object result) {
try {
if (result instanceof byte[]) {
// Store byte array directly to disk to avoid double memory consumption
String fileId = fileStorage.storeBytes((byte[]) result, "result.pdf");
taskManager.setFileResult(jobId, fileId, "result.pdf", "application/pdf");
log.debug("Stored byte[] result with fileId: {}", fileId);
// Let the byte array get collected naturally in the next GC cycle
// We don't need to force System.gc() which can be harmful
} else if (result instanceof ResponseEntity) {
ResponseEntity<?> response = (ResponseEntity<?>) result;
Object body = response.getBody();
if (body instanceof byte[]) {
// Extract filename from content-disposition header if available
String filename = "result.pdf";
String contentType = "application/pdf";
if (response.getHeaders().getContentDisposition() != null) {
String disposition =
response.getHeaders().getContentDisposition().toString();
if (disposition.contains("filename=")) {
filename =
disposition.substring(
disposition.indexOf("filename=") + 9,
disposition.lastIndexOf("\""));
}
}
if (response.getHeaders().getContentType() != null) {
contentType = response.getHeaders().getContentType().toString();
}
// Store byte array directly to disk
String fileId = fileStorage.storeBytes((byte[]) body, filename);
taskManager.setFileResult(jobId, fileId, filename, contentType);
log.debug("Stored ResponseEntity<byte[]> result with fileId: {}", fileId);
// Let the GC handle the memory naturally
} else {
// Check if the response body contains a fileId
if (body != null && body.toString().contains("fileId")) {
try {
// Try to extract fileId using reflection
java.lang.reflect.Method getFileId =
body.getClass().getMethod("getFileId");
String fileId = (String) getFileId.invoke(body);
if (fileId != null && !fileId.isEmpty()) {
// Try to get filename and content type
String filename = "result.pdf";
String contentType = "application/pdf";
try {
java.lang.reflect.Method getOriginalFileName =
body.getClass().getMethod("getOriginalFilename");
String origName = (String) getOriginalFileName.invoke(body);
if (origName != null && !origName.isEmpty()) {
filename = origName;
}
} catch (Exception e) {
log.debug(
"Could not get original filename: {}", e.getMessage());
}
try {
java.lang.reflect.Method getContentType =
body.getClass().getMethod("getContentType");
String ct = (String) getContentType.invoke(body);
if (ct != null && !ct.isEmpty()) {
contentType = ct;
}
} catch (Exception e) {
log.debug("Could not get content type: {}", e.getMessage());
}
taskManager.setFileResult(jobId, fileId, filename, contentType);
log.debug("Extracted fileId from response body: {}", fileId);
taskManager.setComplete(jobId);
return;
}
} catch (Exception e) {
log.debug(
"Failed to extract fileId from response body: {}",
e.getMessage());
}
}
// Store generic result
taskManager.setResult(jobId, body);
}
} else if (result instanceof MultipartFile) {
MultipartFile file = (MultipartFile) result;
String fileId = fileStorage.storeFile(file);
taskManager.setFileResult(
jobId, fileId, file.getOriginalFilename(), file.getContentType());
log.debug("Stored MultipartFile result with fileId: {}", fileId);
} else {
// Check if result has a fileId field
if (result != null) {
try {
// Try to extract fileId using reflection
java.lang.reflect.Method getFileId =
result.getClass().getMethod("getFileId");
String fileId = (String) getFileId.invoke(result);
if (fileId != null && !fileId.isEmpty()) {
// Try to get filename and content type
String filename = "result.pdf";
String contentType = "application/pdf";
try {
java.lang.reflect.Method getOriginalFileName =
result.getClass().getMethod("getOriginalFilename");
String origName = (String) getOriginalFileName.invoke(result);
if (origName != null && !origName.isEmpty()) {
filename = origName;
}
} catch (Exception e) {
log.debug("Could not get original filename: {}", e.getMessage());
}
try {
java.lang.reflect.Method getContentType =
result.getClass().getMethod("getContentType");
String ct = (String) getContentType.invoke(result);
if (ct != null && !ct.isEmpty()) {
contentType = ct;
}
} catch (Exception e) {
log.debug("Could not get content type: {}", e.getMessage());
}
taskManager.setFileResult(jobId, fileId, filename, contentType);
log.debug("Extracted fileId from result object: {}", fileId);
taskManager.setComplete(jobId);
return;
}
} catch (Exception e) {
log.debug(
"Failed to extract fileId from result object: {}", e.getMessage());
}
}
// Default case: store the result as is
taskManager.setResult(jobId, result);
}
taskManager.setComplete(jobId);
} catch (Exception e) {
log.error("Error processing job result: {}", e.getMessage(), e);
taskManager.setError(jobId, "Error processing result: " + e.getMessage());
}
}
/**
* Handle different result types for synchronous jobs
*
* @param result The result object
* @return The appropriate ResponseEntity
* @throws IOException If there is an error processing the result
*/
private ResponseEntity<?> handleResultForSyncJob(Object result) throws IOException {
if (result instanceof byte[]) {
// Return byte array as PDF
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.header(
HttpHeaders.CONTENT_DISPOSITION,
"form-data; name=\"attachment\"; filename=\"result.pdf\"")
.body(result);
} else if (result instanceof MultipartFile) {
// Return MultipartFile content
MultipartFile file = (MultipartFile) result;
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(file.getContentType()))
.header(
HttpHeaders.CONTENT_DISPOSITION,
"form-data; name=\"attachment\"; filename=\""
+ file.getOriginalFilename()
+ "\"")
.body(file.getBytes());
} else {
// Default case: return as JSON
return ResponseEntity.ok(result);
}
}
/**
* Parse session timeout string (e.g., "30m", "1h") to milliseconds
*
* @param timeout The timeout string
* @return The timeout in milliseconds
*/
private long parseSessionTimeout(String timeout) {
if (timeout == null || timeout.isEmpty()) {
return 30 * 60 * 1000; // Default: 30 minutes
}
try {
String value = timeout.replaceAll("[^\\d.]", "");
String unit = timeout.replaceAll("[\\d.]", "");
double numericValue = Double.parseDouble(value);
return switch (unit.toLowerCase()) {
case "s" -> (long) (numericValue * 1000);
case "m" -> (long) (numericValue * 60 * 1000);
case "h" -> (long) (numericValue * 60 * 60 * 1000);
case "d" -> (long) (numericValue * 24 * 60 * 60 * 1000);
default -> (long) (numericValue * 60 * 1000); // Default to minutes
};
} catch (Exception e) {
log.warn("Could not parse session timeout '{}', using default", timeout);
return 30 * 60 * 1000; // Default: 30 minutes
}
}
/**
* Execute a supplier with a timeout
*
* @param supplier The supplier to execute
* @param timeoutMs The timeout in milliseconds
* @return The result from the supplier
* @throws TimeoutException If the execution times out
* @throws Exception If the supplier throws an exception
*/
private <T> T executeWithTimeout(Supplier<T> supplier, long timeoutMs)
throws TimeoutException, Exception {
// Use the same executor as other async jobs for consistency
// This ensures all operations run on the same thread pool
java.util.concurrent.CompletableFuture<T> future =
java.util.concurrent.CompletableFuture.supplyAsync(supplier, executor);
try {
return future.get(timeoutMs, TimeUnit.MILLISECONDS);
} catch (java.util.concurrent.TimeoutException e) {
future.cancel(true);
throw new TimeoutException("Execution timed out after " + timeoutMs + " ms");
} catch (java.util.concurrent.ExecutionException e) {
throw (Exception) e.getCause();
} catch (java.util.concurrent.CancellationException e) {
throw new Exception("Execution was cancelled", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new Exception("Execution was interrupted", e);
}
}
}
@@ -1,495 +0,0 @@
package stirling.software.common.service;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.*;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.SmartLifecycle;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.ExecutorFactory;
import stirling.software.common.util.SpringContextHolder;
/**
* Manages a queue of jobs with dynamic sizing based on system resources. Used when system resources
* are limited to prevent overloading.
*/
@Service
@Slf4j
public class JobQueue implements SmartLifecycle {
private volatile boolean running = false;
private final ResourceMonitor resourceMonitor;
@Value("${stirling.job.queue.base-capacity:10}")
private int baseQueueCapacity = 10;
@Value("${stirling.job.queue.min-capacity:2}")
private int minQueueCapacity = 2;
@Value("${stirling.job.queue.check-interval-ms:1000}")
private long queueCheckIntervalMs = 1000;
@Value("${stirling.job.queue.max-wait-time-ms:600000}")
private long maxWaitTimeMs = 600000; // 10 minutes
private volatile BlockingQueue<QueuedJob> jobQueue;
private final Map<String, QueuedJob> jobMap = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
private final ExecutorService jobExecutor = ExecutorFactory.newVirtualOrCachedThreadExecutor();
private final Object queueLock = new Object(); // Lock for synchronizing queue operations
private boolean shuttingDown = false;
@Getter private int rejectedJobs = 0;
@Getter private int totalQueuedJobs = 0;
@Getter private int currentQueueSize = 0;
/** Represents a job waiting in the queue. */
@Data
@AllArgsConstructor
private static class QueuedJob {
private final String jobId;
private final int resourceWeight;
private final Supplier<Object> work;
private final long timeoutMs;
private final Instant queuedAt;
private CompletableFuture<ResponseEntity<?>> future;
private volatile boolean cancelled = false;
}
public JobQueue(ResourceMonitor resourceMonitor) {
this.resourceMonitor = resourceMonitor;
// Initialize with dynamic capacity
int capacity =
resourceMonitor.calculateDynamicQueueCapacity(baseQueueCapacity, minQueueCapacity);
this.jobQueue = new LinkedBlockingQueue<>(capacity);
}
// Remove @PostConstruct to let SmartLifecycle control startup
private void initializeSchedulers() {
log.debug(
"Starting job queue with base capacity {}, min capacity {}",
baseQueueCapacity,
minQueueCapacity);
// Periodically process the job queue
scheduler.scheduleWithFixedDelay(
this::processQueue, 0, queueCheckIntervalMs, TimeUnit.MILLISECONDS);
// Periodically update queue capacity based on resource usage
scheduler.scheduleWithFixedDelay(
this::updateQueueCapacity,
10000, // Initial delay
30000, // 30 second interval
TimeUnit.MILLISECONDS);
}
// Remove @PreDestroy to let SmartLifecycle control shutdown
private void shutdownSchedulers() {
log.info("Shutting down job queue");
shuttingDown = true;
// Complete any futures that are still waiting
jobMap.forEach(
(id, job) -> {
if (!job.future.isDone()) {
job.future.completeExceptionally(
new RuntimeException("Server shutting down, job cancelled"));
}
});
// Shutdown schedulers and wait for termination
try {
scheduler.shutdown();
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
scheduler.shutdownNow();
}
jobExecutor.shutdown();
if (!jobExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
jobExecutor.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
scheduler.shutdownNow();
jobExecutor.shutdownNow();
}
log.info(
"Job queue shutdown complete. Stats: total={}, rejected={}",
totalQueuedJobs,
rejectedJobs);
}
// SmartLifecycle methods
@Override
public void start() {
log.info("Starting JobQueue lifecycle");
if (!running) {
initializeSchedulers();
running = true;
}
}
@Override
public void stop() {
log.info("Stopping JobQueue lifecycle");
shutdownSchedulers();
running = false;
}
@Override
public boolean isRunning() {
return running;
}
@Override
public int getPhase() {
// Start earlier than most components, but shutdown later
return 10;
}
@Override
public boolean isAutoStartup() {
return true;
}
/**
* Queues a job for execution when resources permit.
*
* @param jobId The job ID
* @param resourceWeight The resource weight of the job (1-100)
* @param work The work to be done
* @param timeoutMs The timeout in milliseconds
* @return A CompletableFuture that will complete when the job is executed
*/
public CompletableFuture<ResponseEntity<?>> queueJob(
String jobId, int resourceWeight, Supplier<Object> work, long timeoutMs) {
// Create a CompletableFuture to track this job's completion
CompletableFuture<ResponseEntity<?>> future = new CompletableFuture<>();
// Create the queued job
QueuedJob job =
new QueuedJob(jobId, resourceWeight, work, timeoutMs, Instant.now(), future, false);
// Store in our map for lookup
jobMap.put(jobId, job);
// Update stats
totalQueuedJobs++;
// Synchronize access to the queue
synchronized (queueLock) {
currentQueueSize = jobQueue.size();
// Try to add to the queue
try {
boolean added = jobQueue.offer(job, 5, TimeUnit.SECONDS);
if (!added) {
log.warn("Queue full, rejecting job {}", jobId);
rejectedJobs++;
future.completeExceptionally(
new RuntimeException("Job queue full, please try again later"));
jobMap.remove(jobId);
return future;
}
log.debug(
"Job {} queued for execution (weight: {}, queue size: {})",
jobId,
resourceWeight,
jobQueue.size());
return future;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
future.completeExceptionally(new RuntimeException("Job queue interrupted"));
jobMap.remove(jobId);
return future;
}
}
}
/**
* Gets the current capacity of the job queue.
*
* @return The current capacity
*/
public int getQueueCapacity() {
synchronized (queueLock) {
return ((LinkedBlockingQueue<QueuedJob>) jobQueue).remainingCapacity()
+ jobQueue.size();
}
}
/** Updates the capacity of the job queue based on available system resources. */
private void updateQueueCapacity() {
try {
// Calculate new capacity once and cache the result
int newCapacity =
resourceMonitor.calculateDynamicQueueCapacity(
baseQueueCapacity, minQueueCapacity);
int currentCapacity = getQueueCapacity();
if (newCapacity != currentCapacity) {
log.debug(
"Updating job queue capacity from {} to {}", currentCapacity, newCapacity);
synchronized (queueLock) {
// Double-check that capacity still needs to be updated
// Use the cached currentCapacity to avoid calling getQueueCapacity() again
if (newCapacity != currentCapacity) {
// Create new queue with updated capacity
BlockingQueue<QueuedJob> newQueue = new LinkedBlockingQueue<>(newCapacity);
// Transfer jobs from old queue to new queue
jobQueue.drainTo(newQueue);
jobQueue = newQueue;
currentQueueSize = jobQueue.size();
}
}
}
} catch (Exception e) {
log.error("Error updating queue capacity: {}", e.getMessage(), e);
}
}
/** Processes jobs in the queue, executing them when resources permit. */
private void processQueue() {
// Jobs to execute after releasing the lock
java.util.List<QueuedJob> jobsToExecute = new java.util.ArrayList<>();
// First synchronized block: poll jobs from the queue and prepare them for execution
synchronized (queueLock) {
if (shuttingDown || jobQueue.isEmpty()) {
return;
}
try {
// Get current resource status
ResourceMonitor.ResourceStatus status = resourceMonitor.getCurrentStatus().get();
// Check if we should execute any jobs
boolean canExecuteJobs = (status != ResourceMonitor.ResourceStatus.CRITICAL);
if (!canExecuteJobs) {
// Under critical load, don't execute any jobs
log.debug("System under critical load, delaying job execution");
return;
}
// Get jobs from the queue, up to a limit based on resource availability
int jobsToProcess =
Math.max(
1,
switch (status) {
case OK -> 3;
case WARNING -> 1;
case CRITICAL -> 0;
});
for (int i = 0; i < jobsToProcess && !jobQueue.isEmpty(); i++) {
QueuedJob job = jobQueue.poll();
if (job == null) break;
// Check if it's been waiting too long
long waitTimeMs = Instant.now().toEpochMilli() - job.queuedAt.toEpochMilli();
if (waitTimeMs > maxWaitTimeMs) {
log.warn(
"Job {} exceeded maximum wait time ({} ms), executing anyway",
job.jobId,
waitTimeMs);
// Add a specific status to the job context that can be tracked
// This will be visible in the job status API
try {
TaskManager taskManager =
SpringContextHolder.getBean(TaskManager.class);
if (taskManager != null) {
taskManager.addNote(
job.jobId,
"QUEUED_TIMEOUT: Job waited in queue for "
+ (waitTimeMs / 1000)
+ " seconds, exceeding the maximum wait time of "
+ (maxWaitTimeMs / 1000)
+ " seconds.");
}
} catch (Exception e) {
log.error(
"Failed to add timeout note to job {}: {}",
job.jobId,
e.getMessage());
}
}
// Remove from our map
jobMap.remove(job.jobId);
currentQueueSize = jobQueue.size();
// Add to the list of jobs to execute outside the synchronized block
jobsToExecute.add(job);
}
} catch (Exception e) {
log.error("Error processing job queue: {}", e.getMessage(), e);
}
}
// Now execute the jobs outside the synchronized block to avoid holding the lock
for (QueuedJob job : jobsToExecute) {
executeJob(job);
}
}
/**
* Executes a job from the queue.
*
* @param job The job to execute
*/
private void executeJob(QueuedJob job) {
if (job.cancelled) {
log.debug("Job {} was cancelled, not executing", job.jobId);
return;
}
jobExecutor.execute(
() -> {
log.debug("Executing queued job {} (queued at {})", job.jobId, job.queuedAt);
try {
// Execute with timeout
Object result = executeWithTimeout(job.work, job.timeoutMs);
// Process the result
if (result instanceof ResponseEntity) {
job.future.complete((ResponseEntity<?>) result);
} else {
job.future.complete(ResponseEntity.ok(result));
}
} catch (Exception e) {
log.error(
"Error executing queued job {}: {}", job.jobId, e.getMessage(), e);
job.future.completeExceptionally(e);
}
});
}
/**
* Execute a supplier with a timeout.
*
* @param supplier The supplier to execute
* @param timeoutMs The timeout in milliseconds
* @return The result from the supplier
* @throws Exception If there is an execution error
*/
private <T> T executeWithTimeout(Supplier<T> supplier, long timeoutMs) throws Exception {
CompletableFuture<T> future = CompletableFuture.supplyAsync(supplier);
try {
if (timeoutMs <= 0) {
// No timeout
return future.join();
} else {
// With timeout
return future.get(timeoutMs, TimeUnit.MILLISECONDS);
}
} catch (TimeoutException e) {
future.cancel(true);
throw new TimeoutException("Job timed out after " + timeoutMs + "ms");
} catch (ExecutionException e) {
throw (Exception) e.getCause();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new InterruptedException("Job was interrupted");
}
}
/**
* Checks if a job is queued.
*
* @param jobId The job ID
* @return true if the job is queued
*/
public boolean isJobQueued(String jobId) {
return jobMap.containsKey(jobId);
}
/**
* Gets the current position of a job in the queue.
*
* @param jobId The job ID
* @return The position (0-based) or -1 if not found
*/
public int getJobPosition(String jobId) {
if (!jobMap.containsKey(jobId)) {
return -1;
}
// Count positions
int position = 0;
for (QueuedJob job : jobQueue) {
if (job.jobId.equals(jobId)) {
return position;
}
position++;
}
// If we didn't find it in the queue but it's in the map,
// it might be executing already
return -1;
}
/**
* Cancels a queued job.
*
* @param jobId The job ID
* @return true if the job was cancelled, false if not found
*/
public boolean cancelJob(String jobId) {
QueuedJob job = jobMap.remove(jobId);
if (job != null) {
job.cancelled = true;
job.future.completeExceptionally(new RuntimeException("Job cancelled by user"));
// Try to remove from queue if it's still there
jobQueue.remove(job);
currentQueueSize = jobQueue.size();
log.debug("Job {} cancelled", jobId);
return true;
}
return false;
}
/**
* Get queue statistics.
*
* @return A map containing queue statistics
*/
public Map<String, Object> getQueueStats() {
return Map.of(
"queuedJobs", jobQueue.size(),
"queueCapacity", getQueueCapacity(),
"totalQueuedJobs", totalQueuedJobs,
"rejectedJobs", rejectedJobs,
"resourceStatus", resourceMonitor.getCurrentStatus().get().name());
}
}
@@ -1,279 +0,0 @@
package stirling.software.common.service;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.OperatingSystemMXBean;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
/**
* Monitors system resources (CPU, memory) to inform job scheduling decisions. Provides information
* about available resources to prevent overloading the system.
*/
@Service
@Slf4j
public class ResourceMonitor {
@Value("${stirling.resource.memory.critical-threshold:0.9}")
private double memoryCriticalThreshold = 0.9; // 90% usage is critical
@Value("${stirling.resource.memory.high-threshold:0.75}")
private double memoryHighThreshold = 0.75; // 75% usage is high
@Value("${stirling.resource.cpu.critical-threshold:0.9}")
private double cpuCriticalThreshold = 0.9; // 90% usage is critical
@Value("${stirling.resource.cpu.high-threshold:0.75}")
private double cpuHighThreshold = 0.75; // 75% usage is high
@Value("${stirling.resource.monitor.interval-ms:60000}")
private long monitorIntervalMs = 60000; // 60 seconds
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
private final MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean();
private final OperatingSystemMXBean osMXBean = ManagementFactory.getOperatingSystemMXBean();
@Getter
private final AtomicReference<ResourceStatus> currentStatus =
new AtomicReference<>(ResourceStatus.OK);
@Getter
private final AtomicReference<ResourceMetrics> latestMetrics =
new AtomicReference<>(new ResourceMetrics());
/** Represents the current status of system resources. */
public enum ResourceStatus {
/** Resources are available, normal operations can proceed */
OK,
/** Resources are under strain, consider queueing high-resource operations */
WARNING,
/** Resources are critically low, queue all operations */
CRITICAL
}
/** Detailed metrics about system resources. */
@Getter
public static class ResourceMetrics {
private final double cpuUsage;
private final double memoryUsage;
private final long freeMemoryBytes;
private final long totalMemoryBytes;
private final long maxMemoryBytes;
private final Instant timestamp;
public ResourceMetrics() {
this(0, 0, 0, 0, 0, Instant.now());
}
public ResourceMetrics(
double cpuUsage,
double memoryUsage,
long freeMemoryBytes,
long totalMemoryBytes,
long maxMemoryBytes,
Instant timestamp) {
this.cpuUsage = cpuUsage;
this.memoryUsage = memoryUsage;
this.freeMemoryBytes = freeMemoryBytes;
this.totalMemoryBytes = totalMemoryBytes;
this.maxMemoryBytes = maxMemoryBytes;
this.timestamp = timestamp;
}
/**
* Gets the age of these metrics.
*
* @return Duration since these metrics were collected
*/
public Duration getAge() {
return Duration.between(timestamp, Instant.now());
}
/**
* Check if these metrics are stale (older than threshold).
*
* @param thresholdMs Staleness threshold in milliseconds
* @return true if metrics are stale
*/
public boolean isStale(long thresholdMs) {
return getAge().toMillis() > thresholdMs;
}
}
@PostConstruct
public void initialize() {
log.debug("Starting resource monitoring with interval of {}ms", monitorIntervalMs);
scheduler.scheduleAtFixedRate(
this::updateResourceMetrics, 0, monitorIntervalMs, TimeUnit.MILLISECONDS);
}
@PreDestroy
public void shutdown() {
log.info("Shutting down resource monitoring");
scheduler.shutdownNow();
}
/** Updates the resource metrics by sampling current system state. */
private void updateResourceMetrics() {
try {
// Get CPU usage
double cpuUsage = osMXBean.getSystemLoadAverage() / osMXBean.getAvailableProcessors();
if (cpuUsage < 0) cpuUsage = getAlternativeCpuLoad(); // Fallback if not available
// Get memory usage
long heapUsed = memoryMXBean.getHeapMemoryUsage().getUsed();
long nonHeapUsed = memoryMXBean.getNonHeapMemoryUsage().getUsed();
long totalUsed = heapUsed + nonHeapUsed;
long maxMemory = Runtime.getRuntime().maxMemory();
long totalMemory = Runtime.getRuntime().totalMemory();
long freeMemory = Runtime.getRuntime().freeMemory();
double memoryUsage = (double) totalUsed / maxMemory;
// Create new metrics
ResourceMetrics metrics =
new ResourceMetrics(
cpuUsage,
memoryUsage,
freeMemory,
totalMemory,
maxMemory,
Instant.now());
latestMetrics.set(metrics);
// Determine system status
ResourceStatus newStatus;
if (cpuUsage > cpuCriticalThreshold || memoryUsage > memoryCriticalThreshold) {
newStatus = ResourceStatus.CRITICAL;
} else if (cpuUsage > cpuHighThreshold || memoryUsage > memoryHighThreshold) {
newStatus = ResourceStatus.WARNING;
} else {
newStatus = ResourceStatus.OK;
}
// Update status if it changed
ResourceStatus oldStatus = currentStatus.getAndSet(newStatus);
if (oldStatus != newStatus) {
log.info("System resource status changed from {} to {}", oldStatus, newStatus);
log.info(
"Current metrics - CPU: {}%, Memory: {}%, Free Memory: {} MB",
String.format("%.1f", cpuUsage * 100),
String.format("%.1f", memoryUsage * 100),
freeMemory / (1024 * 1024));
}
} catch (Exception e) {
log.error("Error updating resource metrics: {}", e.getMessage(), e);
}
}
/**
* Alternative method to estimate CPU load if getSystemLoadAverage() is not available. This is a
* fallback and less accurate than the official JMX method.
*
* @return Estimated CPU load as a value between 0.0 and 1.0
*/
private double getAlternativeCpuLoad() {
try {
// Try to get CPU time if available through reflection
// This is a fallback since we can't directly cast to platform-specific classes
try {
java.lang.reflect.Method m =
osMXBean.getClass().getDeclaredMethod("getProcessCpuLoad");
m.setAccessible(true);
return (double) m.invoke(osMXBean);
} catch (Exception e) {
// Try the older method
try {
java.lang.reflect.Method m =
osMXBean.getClass().getDeclaredMethod("getSystemCpuLoad");
m.setAccessible(true);
return (double) m.invoke(osMXBean);
} catch (Exception e2) {
log.trace(
"Could not get CPU load through reflection, assuming moderate load (0.5)");
return 0.5;
}
}
} catch (Exception e) {
log.trace("Could not get CPU load, assuming moderate load (0.5)");
return 0.5; // Default to moderate load
}
}
/**
* Calculates the dynamic job queue capacity based on current resource usage.
*
* @param baseCapacity The base capacity when system is under minimal load
* @param minCapacity The minimum capacity to maintain even under high load
* @return The calculated job queue capacity
*/
public int calculateDynamicQueueCapacity(int baseCapacity, int minCapacity) {
ResourceMetrics metrics = latestMetrics.get();
ResourceStatus status = currentStatus.get();
// Simple linear reduction based on memory and CPU load
double capacityFactor =
switch (status) {
case OK -> 1.0;
case WARNING -> 0.6;
case CRITICAL -> 0.3;
};
// Apply additional reduction based on specific memory pressure
if (metrics.memoryUsage > 0.8) {
capacityFactor *= 0.5; // Further reduce capacity under memory pressure
}
// Calculate capacity with minimum safeguard
int capacity = (int) Math.max(minCapacity, Math.ceil(baseCapacity * capacityFactor));
log.debug(
"Dynamic queue capacity: {} (base: {}, factor: {:.2f}, status: {})",
capacity,
baseCapacity,
capacityFactor,
status);
return capacity;
}
/**
* Checks if a job with the given weight can be executed immediately or should be queued based
* on current resource availability.
*
* @param resourceWeight The resource weight of the job (1-100)
* @return true if the job should be queued, false if it can run immediately
*/
public boolean shouldQueueJob(int resourceWeight) {
ResourceStatus status = currentStatus.get();
// Always run lightweight jobs (weight < 20) unless critical
if (resourceWeight < 20 && status != ResourceStatus.CRITICAL) {
return false;
}
// Medium weight jobs run immediately if resources are OK
if (resourceWeight < 60 && status == ResourceStatus.OK) {
return false;
}
// Heavy jobs (weight >= 60) and any job during WARNING/CRITICAL should be queued
return true;
}
}
@@ -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,466 +0,0 @@
package stirling.software.common.service;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.ZipSecurity;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.JobStats;
import stirling.software.common.model.job.ResultFile;
/** Manages async tasks and their results */
@Service
@Slf4j
public class TaskManager {
private final Map<String, JobResult> jobResults = new ConcurrentHashMap<>();
@Value("${stirling.jobResultExpiryMinutes:30}")
private int jobResultExpiryMinutes = 30;
private final FileStorage fileStorage;
private final ScheduledExecutorService cleanupExecutor =
Executors.newSingleThreadScheduledExecutor();
/** Initialize the task manager and start the cleanup scheduler */
public TaskManager(FileStorage fileStorage) {
this.fileStorage = fileStorage;
// Schedule periodic cleanup of old job results
cleanupExecutor.scheduleAtFixedRate(
this::cleanupOldJobs,
10, // Initial delay
10, // Interval
TimeUnit.MINUTES);
log.debug(
"Task manager initialized with job result expiry of {} minutes",
jobResultExpiryMinutes);
}
/**
* Create a new task with the given job ID
*
* @param jobId The job ID
*/
public void createTask(String jobId) {
jobResults.put(jobId, JobResult.createNew(jobId));
log.debug("Created task with job ID: {}", jobId);
}
/**
* Set the result of a task as a general object
*
* @param jobId The job ID
* @param result The result object
*/
public void setResult(String jobId, Object result) {
JobResult jobResult = getOrCreateJobResult(jobId);
jobResult.completeWithResult(result);
log.debug("Set result for job ID: {}", jobId);
}
/**
* Set the result of a task as a file
*
* @param jobId The job ID
* @param fileId The file ID
* @param originalFileName The original file name
* @param contentType The content type of the file
*/
public void setFileResult(
String jobId, String fileId, String originalFileName, String contentType) {
JobResult jobResult = getOrCreateJobResult(jobId);
// Check if this is a ZIP file that should be extracted
if (isZipFile(contentType, originalFileName)) {
try {
List<ResultFile> extractedFiles =
extractZipToIndividualFiles(fileId, originalFileName);
if (!extractedFiles.isEmpty()) {
jobResult.completeWithFiles(extractedFiles);
log.debug(
"Set multiple file results for job ID: {} with {} files extracted from ZIP",
jobId,
extractedFiles.size());
return;
}
} catch (Exception e) {
log.warn(
"Failed to extract ZIP file for job {}: {}. Falling back to single file result.",
jobId,
e.getMessage());
}
}
// Handle as single file using new ResultFile approach
try {
long fileSize = fileStorage.getFileSize(fileId);
jobResult.completeWithSingleFile(fileId, originalFileName, contentType, fileSize);
log.debug("Set single file result for job ID: {} with file ID: {}", jobId, fileId);
} catch (Exception e) {
log.warn(
"Failed to get file size for job {}: {}. Using size 0.", jobId, e.getMessage());
jobResult.completeWithSingleFile(fileId, originalFileName, contentType, 0);
}
}
/**
* Set the result of a task as multiple files
*
* @param jobId The job ID
* @param resultFiles The list of result files
*/
public void setMultipleFileResults(String jobId, List<ResultFile> resultFiles) {
JobResult jobResult = getOrCreateJobResult(jobId);
jobResult.completeWithFiles(resultFiles);
log.debug(
"Set multiple file results for job ID: {} with {} files",
jobId,
resultFiles.size());
}
/**
* Set an error for a task
*
* @param jobId The job ID
* @param error The error message
*/
public void setError(String jobId, String error) {
JobResult jobResult = getOrCreateJobResult(jobId);
jobResult.failWithError(error);
log.debug("Set error for job ID: {}: {}", jobId, error);
}
/**
* Mark a task as complete
*
* @param jobId The job ID
*/
public void setComplete(String jobId) {
JobResult jobResult = getOrCreateJobResult(jobId);
if (jobResult.getResult() == null
&& !jobResult.hasFiles()
&& jobResult.getError() == null) {
// If no result or error has been set, mark it as complete with an empty result
jobResult.completeWithResult("Task completed successfully");
}
log.debug("Marked job ID: {} as complete", jobId);
}
/**
* Check if a task is complete
*
* @param jobId The job ID
* @return true if the task is complete, false otherwise
*/
public boolean isComplete(String jobId) {
JobResult result = jobResults.get(jobId);
return result != null && result.isComplete();
}
/**
* Get the result of a task
*
* @param jobId The job ID
* @return The result object, or null if the task doesn't exist or is not complete
*/
public JobResult getJobResult(String jobId) {
return jobResults.get(jobId);
}
/**
* Add a note to a task. Notes are informational messages that can be attached to a job for
* tracking purposes.
*
* @param jobId The job ID
* @param note The note to add
* @return true if the note was added successfully, false if the job doesn't exist
*/
public boolean addNote(String jobId, String note) {
JobResult jobResult = jobResults.get(jobId);
if (jobResult != null) {
jobResult.addNote(note);
log.debug("Added note to job ID: {}: {}", jobId, note);
return true;
}
log.warn("Attempted to add note to non-existent job ID: {}", jobId);
return false;
}
/**
* Get statistics about all jobs in the system
*
* @return Job statistics
*/
public JobStats getJobStats() {
int totalJobs = jobResults.size();
int activeJobs = 0;
int completedJobs = 0;
int failedJobs = 0;
int successfulJobs = 0;
int fileResultJobs = 0;
LocalDateTime oldestActiveJobTime = null;
LocalDateTime newestActiveJobTime = null;
long totalProcessingTimeMs = 0;
for (JobResult result : jobResults.values()) {
if (result.isComplete()) {
completedJobs++;
// Calculate processing time for completed jobs
if (result.getCreatedAt() != null && result.getCompletedAt() != null) {
long processingTimeMs =
java.time.Duration.between(
result.getCreatedAt(), result.getCompletedAt())
.toMillis();
totalProcessingTimeMs += processingTimeMs;
}
if (result.getError() != null) {
failedJobs++;
} else {
successfulJobs++;
if (result.hasFiles()) {
fileResultJobs++;
}
}
} else {
activeJobs++;
// Track oldest and newest active jobs
if (result.getCreatedAt() != null) {
if (oldestActiveJobTime == null
|| result.getCreatedAt().isBefore(oldestActiveJobTime)) {
oldestActiveJobTime = result.getCreatedAt();
}
if (newestActiveJobTime == null
|| result.getCreatedAt().isAfter(newestActiveJobTime)) {
newestActiveJobTime = result.getCreatedAt();
}
}
}
}
// Calculate average processing time
long averageProcessingTimeMs =
completedJobs > 0 ? totalProcessingTimeMs / completedJobs : 0;
return JobStats.builder()
.totalJobs(totalJobs)
.activeJobs(activeJobs)
.completedJobs(completedJobs)
.failedJobs(failedJobs)
.successfulJobs(successfulJobs)
.fileResultJobs(fileResultJobs)
.oldestActiveJobTime(oldestActiveJobTime)
.newestActiveJobTime(newestActiveJobTime)
.averageProcessingTimeMs(averageProcessingTimeMs)
.build();
}
/**
* Get or create a job result
*
* @param jobId The job ID
* @return The job result
*/
private JobResult getOrCreateJobResult(String jobId) {
return jobResults.computeIfAbsent(jobId, JobResult::createNew);
}
/** Clean up old completed job results */
public void cleanupOldJobs() {
LocalDateTime expiryThreshold =
LocalDateTime.now().minus(jobResultExpiryMinutes, ChronoUnit.MINUTES);
int removedCount = 0;
try {
for (Map.Entry<String, JobResult> entry : jobResults.entrySet()) {
JobResult result = entry.getValue();
// Remove completed jobs that are older than the expiry threshold
if (result.isComplete()
&& result.getCompletedAt() != null
&& result.getCompletedAt().isBefore(expiryThreshold)) {
// Clean up file results
cleanupJobFiles(result, entry.getKey());
// Remove the job result
jobResults.remove(entry.getKey());
removedCount++;
}
}
if (removedCount > 0) {
log.info("Cleaned up {} expired job results", removedCount);
}
} catch (Exception e) {
log.error("Error during job cleanup: {}", e.getMessage(), e);
}
}
/** Shutdown the cleanup executor */
@PreDestroy
public void shutdown() {
try {
log.info("Shutting down job result cleanup executor");
cleanupExecutor.shutdown();
if (!cleanupExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
cleanupExecutor.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
cleanupExecutor.shutdownNow();
}
}
/** Check if a file is a ZIP file based on content type and filename */
private boolean isZipFile(String contentType, String fileName) {
if (contentType != null
&& (contentType.equals("application/zip")
|| contentType.equals("application/x-zip-compressed"))) {
return true;
}
if (fileName != null && fileName.toLowerCase().endsWith(".zip")) {
return true;
}
return false;
}
/** Extract a ZIP file into individual files and store them */
private List<ResultFile> extractZipToIndividualFiles(
String zipFileId, String originalZipFileName) throws IOException {
List<ResultFile> extractedFiles = new ArrayList<>();
MultipartFile zipFile = fileStorage.retrieveFile(zipFileId);
try (ZipInputStream zipIn =
ZipSecurity.createHardenedInputStream(
new ByteArrayInputStream(zipFile.getBytes()))) {
ZipEntry entry;
while ((entry = zipIn.getNextEntry()) != null) {
if (!entry.isDirectory()) {
// Use buffered reading for memory safety
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = zipIn.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
byte[] fileContent = out.toByteArray();
String contentType = determineContentType(entry.getName());
String individualFileId = fileStorage.storeBytes(fileContent, entry.getName());
ResultFile resultFile =
ResultFile.builder()
.fileId(individualFileId)
.fileName(entry.getName())
.contentType(contentType)
.fileSize(fileContent.length)
.build();
extractedFiles.add(resultFile);
log.debug(
"Extracted file: {} (size: {} bytes)",
entry.getName(),
fileContent.length);
}
zipIn.closeEntry();
}
}
// Clean up the original ZIP file after extraction
try {
fileStorage.deleteFile(zipFileId);
log.debug("Cleaned up original ZIP file: {}", zipFileId);
} catch (Exception e) {
log.warn("Failed to clean up original ZIP file {}: {}", zipFileId, e.getMessage());
}
return extractedFiles;
}
/** Determine content type based on file extension */
private String determineContentType(String fileName) {
if (fileName == null) {
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
String lowerName = fileName.toLowerCase();
if (lowerName.endsWith(".pdf")) {
return MediaType.APPLICATION_PDF_VALUE;
} else if (lowerName.endsWith(".txt")) {
return MediaType.TEXT_PLAIN_VALUE;
} else if (lowerName.endsWith(".json")) {
return MediaType.APPLICATION_JSON_VALUE;
} else if (lowerName.endsWith(".xml")) {
return MediaType.APPLICATION_XML_VALUE;
} else if (lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg")) {
return MediaType.IMAGE_JPEG_VALUE;
} else if (lowerName.endsWith(".png")) {
return MediaType.IMAGE_PNG_VALUE;
} else {
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
}
/** Clean up files associated with a job result */
private void cleanupJobFiles(JobResult result, String jobId) {
// Clean up all result files
if (result.hasFiles()) {
for (ResultFile resultFile : result.getAllResultFiles()) {
try {
fileStorage.deleteFile(resultFile.getFileId());
} catch (Exception e) {
log.warn(
"Failed to delete file {} for job {}: {}",
resultFile.getFileId(),
jobId,
e.getMessage());
}
}
}
}
/** Find the ResultFile metadata for a given file ID by searching through all job results */
public ResultFile findResultFileByFileId(String fileId) {
for (JobResult jobResult : jobResults.values()) {
if (jobResult.hasFiles()) {
for (ResultFile resultFile : jobResult.getAllResultFiles()) {
if (fileId.equals(resultFile.getFileId())) {
return resultFile;
}
}
}
}
return null;
}
}
@@ -1,449 +0,0 @@
package stirling.software.common.service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Stream;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry;
/**
* Service to periodically clean up temporary files. Runs scheduled tasks to delete old temp files
* and directories.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TempFileCleanupService {
private final TempFileRegistry registry;
private final TempFileManager tempFileManager;
private final ApplicationProperties applicationProperties;
@Autowired
@Qualifier("machineType")
private String machineType;
// Maximum recursion depth for directory traversal
private static final int MAX_RECURSION_DEPTH = 5;
// File patterns that identify our temp files
private static final Predicate<String> IS_OUR_TEMP_FILE =
fileName ->
fileName.startsWith("stirling-pdf-")
|| fileName.startsWith("output_")
|| fileName.startsWith("compressedPDF")
|| fileName.startsWith("pdf-save-")
|| fileName.startsWith("pdf-stream-")
|| fileName.startsWith("PDFBox")
|| fileName.startsWith("input_")
|| fileName.startsWith("overlay-");
// File patterns that identify common system temp files
private static final Predicate<String> IS_SYSTEM_TEMP_FILE =
fileName ->
fileName.matches("lu\\d+[a-z0-9]*\\.tmp")
|| fileName.matches("ocr_process\\d+")
|| (fileName.startsWith("tmp") && !fileName.contains("jetty"))
|| fileName.startsWith("OSL_PIPE_")
|| (fileName.endsWith(".tmp") && !fileName.contains("jetty"));
// File patterns that should be excluded from cleanup
private static final Predicate<String> SHOULD_SKIP =
fileName ->
fileName.contains("jetty")
|| fileName.startsWith("jetty-")
|| "proc".equals(fileName)
|| "sys".equals(fileName)
|| "dev".equals(fileName)
|| "hsperfdata_stirlingpdfuser".equals(fileName)
|| fileName.startsWith("hsperfdata_")
|| ".pdfbox.cache".equals(fileName);
@PostConstruct
public void init() {
// Create necessary directories
ensureDirectoriesExist();
// Perform startup cleanup if enabled
if (applicationProperties.getSystem().getTempFileManagement().isStartupCleanup()) {
runStartupCleanup();
}
}
/** Ensure that all required temp directories exist */
private void ensureDirectoriesExist() {
try {
ApplicationProperties.TempFileManagement tempFiles =
applicationProperties.getSystem().getTempFileManagement();
// Create the main temp directory
String customTempDirectory = tempFiles.getBaseTmpDir();
if (customTempDirectory != null && !customTempDirectory.isEmpty()) {
Path tempDir = Path.of(customTempDirectory);
if (!Files.exists(tempDir)) {
Files.createDirectories(tempDir);
log.info("Created temp directory: {}", tempDir);
}
}
// Create LibreOffice temp directory
String libreOfficeTempDir = tempFiles.getLibreofficeDir();
if (libreOfficeTempDir != null && !libreOfficeTempDir.isEmpty()) {
Path loTempDir = Path.of(libreOfficeTempDir);
if (!Files.exists(loTempDir)) {
Files.createDirectories(loTempDir);
log.info("Created LibreOffice temp directory: {}", loTempDir);
}
}
} catch (IOException e) {
log.error("Error creating temp directories", e);
}
}
/** Scheduled task to clean up old temporary files. Runs at the configured interval. */
@Scheduled(
fixedDelayString =
"#{applicationProperties.system.tempFileManagement.cleanupIntervalMinutes}",
timeUnit = TimeUnit.MINUTES)
public void scheduledCleanup() {
log.info("Running scheduled temporary file cleanup");
long maxAgeMillis = tempFileManager.getMaxAgeMillis();
// Clean up registered temp files (managed by TempFileRegistry)
int registeredDeletedCount = tempFileManager.cleanupOldTempFiles(maxAgeMillis);
log.info("Cleaned up {} registered temporary files", registeredDeletedCount);
// Clean up registered temp directories
int directoriesDeletedCount = 0;
for (Path directory : registry.getTempDirectories()) {
try {
if (Files.exists(directory)) {
GeneralUtils.deleteDirectory(directory);
directoriesDeletedCount++;
log.debug("Cleaned up temporary directory: {}", directory);
}
} catch (IOException e) {
log.warn("Failed to clean up temporary directory: {}", directory, e);
}
}
// Clean up PDFBox cache file
cleanupPDFBoxCache();
// Clean up unregistered temp files based on our cleanup strategy
boolean containerMode = isContainerMode();
int unregisteredDeletedCount = cleanupUnregisteredFiles(containerMode, true, maxAgeMillis);
if (registeredDeletedCount > 0
|| unregisteredDeletedCount > 0
|| directoriesDeletedCount > 0) {
log.info(
"Scheduled cleanup complete. Deleted {} registered files, {} unregistered files, {} directories",
registeredDeletedCount,
unregisteredDeletedCount,
directoriesDeletedCount);
}
}
/**
* Perform startup cleanup of stale temporary files from previous runs. This is especially
* important in Docker environments where temp files persist between container restarts.
*/
private void runStartupCleanup() {
boolean containerMode = isContainerMode();
log.info(
"Running in {} mode, using {} cleanup strategy",
machineType,
containerMode ? "aggressive" : "conservative");
// For startup cleanup, we use a longer timeout for non-container environments
long maxAgeMillis = containerMode ? 0 : 24 * 60 * 60 * 1000; // 0 or 24 hours
int totalDeletedCount = cleanupUnregisteredFiles(containerMode, false, maxAgeMillis);
log.info(
"Startup cleanup complete. Deleted {} temporary files/directories",
totalDeletedCount);
}
/**
* Clean up unregistered temporary files across all configured temp directories.
*
* @param containerMode Whether we're in container mode (more aggressive cleanup)
* @param isScheduled Whether this is a scheduled cleanup or startup cleanup
* @param maxAgeMillis Maximum age of files to clean in milliseconds
* @return Number of files deleted
*/
private int cleanupUnregisteredFiles(
boolean containerMode, boolean isScheduled, long maxAgeMillis) {
AtomicInteger totalDeletedCount = new AtomicInteger(0);
try {
ApplicationProperties.TempFileManagement tempFiles =
applicationProperties.getSystem().getTempFileManagement();
Path[] dirsToScan;
if (tempFiles.isCleanupSystemTemp()
&& tempFiles.getSystemTempDir() != null
&& !tempFiles.getSystemTempDir().isEmpty()) {
Path systemTempPath = getSystemTempPath();
dirsToScan =
new Path[] {
systemTempPath,
Path.of(tempFiles.getBaseTmpDir()),
Path.of(tempFiles.getLibreofficeDir())
};
} else {
dirsToScan =
new Path[] {
Path.of(tempFiles.getBaseTmpDir()),
Path.of(tempFiles.getLibreofficeDir())
};
}
// Process each directory
Arrays.stream(dirsToScan)
.filter(Files::exists)
.forEach(
tempDir -> {
try {
String phase = isScheduled ? "scheduled" : "startup";
log.debug(
"Scanning directory for {} cleanup: {}",
phase,
tempDir);
AtomicInteger dirDeletedCount = new AtomicInteger(0);
cleanupDirectoryStreaming(
tempDir,
containerMode,
0,
maxAgeMillis,
isScheduled,
path -> {
dirDeletedCount.incrementAndGet();
if (log.isDebugEnabled()) {
log.debug(
"Deleted temp file during {} cleanup: {}",
phase,
path);
}
});
int count = dirDeletedCount.get();
totalDeletedCount.addAndGet(count);
if (count > 0) {
log.info(
"Cleaned up {} files/directories in {}",
count,
tempDir);
}
} catch (IOException e) {
log.error("Error during cleanup of directory: {}", tempDir, e);
}
});
} catch (Exception e) {
log.error("Error during cleanup of unregistered files", e);
}
return totalDeletedCount.get();
}
/** Get the system temp directory path based on configuration or system property. */
private Path getSystemTempPath() {
String systemTempDir =
applicationProperties.getSystem().getTempFileManagement().getSystemTempDir();
if (systemTempDir != null && !systemTempDir.isEmpty()) {
return Path.of(systemTempDir);
} else {
return Path.of(System.getProperty("java.io.tmpdir"));
}
}
/** Determine if we're running in a container environment. */
private boolean isContainerMode() {
return "Docker".equals(machineType) || "Kubernetes".equals(machineType);
}
/**
* Recursively clean up a directory using a streaming approach to reduce memory usage.
*
* @param directory The directory to clean
* @param containerMode Whether we're in container mode (more aggressive cleanup)
* @param depth Current recursion depth
* @param maxAgeMillis Maximum age of files to delete
* @param isScheduled Whether this is a scheduled cleanup (vs startup)
* @param onDeleteCallback Callback function when a file is deleted
* @throws IOException If an I/O error occurs
*/
private void cleanupDirectoryStreaming(
Path directory,
boolean containerMode,
int depth,
long maxAgeMillis,
boolean isScheduled,
Consumer<Path> onDeleteCallback)
throws IOException {
if (depth > MAX_RECURSION_DEPTH) {
log.debug("Maximum directory recursion depth reached for: {}", directory);
return;
}
java.util.List<Path> subdirectories = new java.util.ArrayList<>();
try (Stream<Path> pathStream = Files.list(directory)) {
pathStream.forEach(
path -> {
try {
String fileName = path.getFileName().toString();
if (SHOULD_SKIP.test(fileName)) {
return;
}
if (Files.isDirectory(path)) {
subdirectories.add(path);
return;
}
if (registry.contains(path.toFile())) {
return;
}
if (shouldDeleteFile(path, fileName, containerMode, maxAgeMillis)) {
try {
Files.deleteIfExists(path);
onDeleteCallback.accept(path);
} catch (IOException e) {
if (e.getMessage() != null
&& e.getMessage()
.contains("being used by another process")) {
log.debug("File locked, skipping delete: {}", path);
} else {
log.warn("Failed to delete temp file: {}", path, e);
}
}
}
} catch (Exception e) {
log.warn("Error processing path: {}", path, e);
}
});
}
for (Path subdirectory : subdirectories) {
try {
cleanupDirectoryStreaming(
subdirectory,
containerMode,
depth + 1,
maxAgeMillis,
isScheduled,
onDeleteCallback);
} catch (IOException e) {
log.warn("Error processing subdirectory: {}", subdirectory, e);
}
}
}
/** Determine if a file should be deleted based on its name, age, and other criteria. */
private boolean shouldDeleteFile(
Path path, String fileName, boolean containerMode, long maxAgeMillis) {
// First check if it matches our known temp file patterns
boolean isOurTempFile = IS_OUR_TEMP_FILE.test(fileName);
boolean isSystemTempFile = IS_SYSTEM_TEMP_FILE.test(fileName);
// Normal operation - check against temp file patterns
boolean shouldDelete = isOurTempFile || (containerMode && isSystemTempFile);
// Get file info for age checks
long lastModified = 0;
long currentTime = System.currentTimeMillis();
boolean isEmptyFile = false;
try {
lastModified = Files.getLastModifiedTime(path).toMillis();
// Special case for zero-byte files - these are often corrupted temp files
if (Files.size(path) == 0) {
isEmptyFile = true;
// For empty files, use a shorter timeout (5 minutes)
// Delete empty files older than 5 minutes
if ((currentTime - lastModified) > 5 * 60 * 1000) {
shouldDelete = true;
}
}
} catch (IOException e) {
log.debug("Could not check file info, skipping: {}", path);
}
// Check file age against maxAgeMillis only if it's not an empty file that we've already
// decided to delete
if (!isEmptyFile && shouldDelete && maxAgeMillis > 0) {
// In normal mode, check age against maxAgeMillis
shouldDelete = (currentTime - lastModified) > maxAgeMillis;
}
return shouldDelete;
}
/** Clean up LibreOffice temporary files. This method is called after LibreOffice operations. */
public void cleanupLibreOfficeTempFiles() {
// Cleanup known LibreOffice temp directories
try {
Set<Path> directories = registry.getTempDirectories();
for (Path dir : directories) {
if (dir.getFileName().toString().contains("libreoffice") && Files.exists(dir)) {
// For directories containing "libreoffice", delete all contents
// but keep the directory itself for future use
cleanupDirectoryStreaming(
dir,
isContainerMode(),
0,
0, // age doesn't matter for LibreOffice cleanup
false,
path -> log.debug("Cleaned up LibreOffice temp file: {}", path));
log.debug("Cleaned up LibreOffice temp directory contents: {}", dir);
}
}
} catch (IOException e) {
log.warn("Failed to clean up LibreOffice temp files", e);
}
}
/**
* Clean up PDFBox cache file from user home directory. This cache file can grow large and
* should be periodically cleaned.
*/
private void cleanupPDFBoxCache() {
try {
Path userHome = Path.of(System.getProperty("user.home"));
Path pdfboxCache = userHome.resolve(".pdfbox.cache");
if (Files.exists(pdfboxCache)) {
Files.deleteIfExists(pdfboxCache);
log.debug("Cleaned up PDFBox cache file: {}", pdfboxCache);
}
} catch (IOException e) {
log.warn("Failed to clean up PDFBox cache file", e);
}
}
}
@@ -1,76 +0,0 @@
package stirling.software.common.util;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
/**
* Helper class that provides access to the ApplicationContext. Useful for getting beans in classes
* that are not managed by Spring.
*/
@Component
public class ApplicationContextProvider implements ApplicationContextAware {
private static ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext context) throws BeansException {
applicationContext = context;
}
/**
* Get a bean by class type.
*
* @param <T> The type of the bean
* @param beanClass The class of the bean
* @return The bean instance, or null if not found
*/
public static <T> T getBean(Class<T> beanClass) {
if (applicationContext == null) {
return null;
}
try {
return applicationContext.getBean(beanClass);
} catch (BeansException e) {
return null;
}
}
/**
* Get a bean by name and class type.
*
* @param <T> The type of the bean
* @param name The name of the bean
* @param beanClass The class of the bean
* @return The bean instance, or null if not found
*/
public static <T> T getBean(String name, Class<T> beanClass) {
if (applicationContext == null) {
return null;
}
try {
return applicationContext.getBean(name, beanClass);
} catch (BeansException e) {
return null;
}
}
/**
* Check if a bean of the specified type exists.
*
* @param beanClass The class of the bean
* @return true if the bean exists, false otherwise
*/
public static boolean containsBean(Class<?> beanClass) {
if (applicationContext == null) {
return false;
}
try {
applicationContext.getBean(beanClass);
return true;
} catch (BeansException e) {
return false;
}
}
}
@@ -1,50 +0,0 @@
package stirling.software.common.util;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PageMode;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class AttachmentUtils {
/**
* Sets the PDF catalog viewer preferences to display attachments in the viewer.
*
* @param document The <code>PDDocument</code> to modify.
* @param pageMode The <code>PageMode</code> to set for the PDF viewer. <code>PageMode</code>
* values: <code>UseNone</code>, <code>UseOutlines</code>, <code>UseThumbs</code>, <code>
* FullScreen</code>, <code>UseOC</code>, <code>UseAttachments</code>.
*/
public static void setCatalogViewerPreferences(PDDocument document, PageMode pageMode) {
try {
PDDocumentCatalog catalog = document.getDocumentCatalog();
if (catalog != null) {
COSDictionary catalogDict = catalog.getCOSObject();
catalog.setPageMode(pageMode);
catalogDict.setName(COSName.PAGE_MODE, pageMode.stringValue());
COSDictionary viewerPrefs =
(COSDictionary) catalogDict.getDictionaryObject(COSName.VIEWER_PREFERENCES);
if (viewerPrefs == null) {
viewerPrefs = new COSDictionary();
catalogDict.setItem(COSName.VIEWER_PREFERENCES, viewerPrefs);
}
viewerPrefs.setName(
COSName.getPDFName("NonFullScreenPageMode"), pageMode.stringValue());
viewerPrefs.setBoolean(COSName.getPDFName("DisplayDocTitle"), true);
log.info(
"Set PDF PageMode to UseAttachments to automatically show attachments pane");
}
} catch (Exception e) {
log.error("Failed to set catalog viewer preferences for attachments", e);
}
}
}
@@ -1,71 +0,0 @@
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 =
Sanitizers.FORMATTING
.and(Sanitizers.BLOCKS)
.and(Sanitizers.STYLES)
.and(Sanitizers.LINKS)
.and(Sanitizers.TABLES)
.and(SSRF_SAFE_IMAGES_POLICY)
.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);
}
}
@@ -1,652 +0,0 @@
package stirling.software.common.util;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Properties;
import java.util.regex.Pattern;
import lombok.Data;
import lombok.experimental.UtilityClass;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
@UtilityClass
public class EmlParser {
private static volatile Boolean jakartaMailAvailable = null;
private static volatile Method mimeUtilityDecodeTextMethod = null;
private static volatile boolean mimeUtilityChecked = false;
private static final Pattern MIME_ENCODED_PATTERN =
Pattern.compile("=\\?([^?]+)\\?([BbQq])\\?([^?]*)\\?=");
private static final String DISPOSITION_ATTACHMENT = "attachment";
private static final String TEXT_PLAIN = "text/plain";
private static final String TEXT_HTML = "text/html";
private static final String MULTIPART_PREFIX = "multipart/";
private static final String HEADER_CONTENT_TYPE = "content-type:";
private static final String HEADER_CONTENT_DISPOSITION = "content-disposition:";
private static final String HEADER_CONTENT_TRANSFER_ENCODING = "content-transfer-encoding:";
private static final String HEADER_CONTENT_ID = "Content-ID";
private static final String HEADER_SUBJECT = "Subject:";
private static final String HEADER_FROM = "From:";
private static final String HEADER_TO = "To:";
private static final String HEADER_CC = "Cc:";
private static final String HEADER_BCC = "Bcc:";
private static final String HEADER_DATE = "Date:";
private static synchronized boolean isJakartaMailAvailable() {
if (jakartaMailAvailable == null) {
try {
Class.forName("jakarta.mail.internet.MimeMessage");
Class.forName("jakarta.mail.Session");
Class.forName("jakarta.mail.internet.MimeUtility");
Class.forName("jakarta.mail.internet.MimePart");
Class.forName("jakarta.mail.internet.MimeMultipart");
Class.forName("jakarta.mail.Multipart");
Class.forName("jakarta.mail.Part");
jakartaMailAvailable = true;
} catch (ClassNotFoundException e) {
jakartaMailAvailable = false;
}
}
return jakartaMailAvailable;
}
public static EmailContent extractEmailContent(
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer)
throws IOException {
EmlProcessingUtils.validateEmlInput(emlBytes);
if (isJakartaMailAvailable()) {
return extractEmailContentAdvanced(emlBytes, request, customHtmlSanitizer);
} else {
return extractEmailContentBasic(emlBytes, request, customHtmlSanitizer);
}
}
private static EmailContent extractEmailContentBasic(
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) {
String emlContent = new String(emlBytes, StandardCharsets.UTF_8);
EmailContent content = new EmailContent();
content.setSubject(extractBasicHeader(emlContent, HEADER_SUBJECT));
content.setFrom(extractBasicHeader(emlContent, HEADER_FROM));
content.setTo(extractBasicHeader(emlContent, HEADER_TO));
content.setCc(extractBasicHeader(emlContent, HEADER_CC));
content.setBcc(extractBasicHeader(emlContent, HEADER_BCC));
String dateStr = extractBasicHeader(emlContent, HEADER_DATE);
if (!dateStr.isEmpty()) {
content.setDateString(dateStr);
}
String htmlBody = extractHtmlBody(emlContent);
if (htmlBody != null) {
content.setHtmlBody(htmlBody);
} else {
String textBody = extractTextBody(emlContent);
content.setTextBody(textBody != null ? textBody : "Email content could not be parsed");
}
content.getAttachments().addAll(extractAttachmentsBasic(emlContent));
return content;
}
private static EmailContent extractEmailContentAdvanced(
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) {
try {
Class<?> sessionClass = Class.forName("jakarta.mail.Session");
Class<?> mimeMessageClass = Class.forName("jakarta.mail.internet.MimeMessage");
Method getDefaultInstance =
sessionClass.getMethod("getDefaultInstance", Properties.class);
Object session = getDefaultInstance.invoke(null, new Properties());
Class<?>[] constructorArgs = new Class<?>[] {sessionClass, InputStream.class};
Constructor<?> mimeMessageConstructor =
mimeMessageClass.getConstructor(constructorArgs);
Object message =
mimeMessageConstructor.newInstance(session, new ByteArrayInputStream(emlBytes));
return extractFromMimeMessage(message, request, customHtmlSanitizer);
} catch (ReflectiveOperationException e) {
return extractEmailContentBasic(emlBytes, request, customHtmlSanitizer);
}
}
private static EmailContent extractFromMimeMessage(
Object message, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) {
EmailContent content = new EmailContent();
try {
Class<?> messageClass = message.getClass();
Method getSubject = messageClass.getMethod("getSubject");
String subject = (String) getSubject.invoke(message);
content.setSubject(subject != null ? safeMimeDecode(subject) : "No Subject");
Method getFrom = messageClass.getMethod("getFrom");
Object[] fromAddresses = (Object[]) getFrom.invoke(message);
content.setFrom(buildAddressString(fromAddresses));
extractRecipients(message, messageClass, content);
Method getSentDate = messageClass.getMethod("getSentDate");
content.setDate((Date) getSentDate.invoke(message));
Method getContent = messageClass.getMethod("getContent");
Object messageContent = getContent.invoke(message);
processMessageContent(message, messageContent, content, request, customHtmlSanitizer);
} catch (ReflectiveOperationException | RuntimeException e) {
content.setSubject("Email Conversion");
content.setFrom("Unknown");
content.setTo("Unknown");
content.setCc("");
content.setBcc("");
content.setTextBody("Email content could not be parsed with advanced processing");
}
return content;
}
private static void extractRecipients(
Object message, Class<?> messageClass, EmailContent content) {
try {
Method getRecipients =
messageClass.getMethod(
"getRecipients", Class.forName("jakarta.mail.Message$RecipientType"));
Class<?> recipientTypeClass = Class.forName("jakarta.mail.Message$RecipientType");
Object toType = recipientTypeClass.getField("TO").get(null);
Object[] toRecipients = (Object[]) getRecipients.invoke(message, toType);
content.setTo(buildAddressString(toRecipients));
Object ccType = recipientTypeClass.getField("CC").get(null);
Object[] ccRecipients = (Object[]) getRecipients.invoke(message, ccType);
content.setCc(buildAddressString(ccRecipients));
Object bccType = recipientTypeClass.getField("BCC").get(null);
Object[] bccRecipients = (Object[]) getRecipients.invoke(message, bccType);
content.setBcc(buildAddressString(bccRecipients));
} catch (ReflectiveOperationException e) {
try {
Method getAllRecipients = messageClass.getMethod("getAllRecipients");
Object[] recipients = (Object[]) getAllRecipients.invoke(message);
content.setTo(buildAddressString(recipients));
content.setCc("");
content.setBcc("");
} catch (ReflectiveOperationException ex) {
content.setTo("");
content.setCc("");
content.setBcc("");
}
}
}
private static String buildAddressString(Object[] addresses) {
if (addresses == null || addresses.length == 0) {
return "";
}
StringBuilder builder = new StringBuilder();
for (int i = 0; i < addresses.length; i++) {
if (i > 0) builder.append(", ");
builder.append(safeMimeDecode(addresses[i].toString()));
}
return builder.toString();
}
private static void processMessageContent(
Object message,
Object messageContent,
EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer) {
try {
if (messageContent instanceof String stringContent) {
Method getContentType = message.getClass().getMethod("getContentType");
String contentType = (String) getContentType.invoke(message);
if (contentType != null && contentType.toLowerCase().contains(TEXT_HTML)) {
content.setHtmlBody(stringContent);
} else {
content.setTextBody(stringContent);
}
} else {
Class<?> multipartClass = Class.forName("jakarta.mail.Multipart");
if (multipartClass.isInstance(messageContent)) {
processMultipart(messageContent, content, request, customHtmlSanitizer, 0);
}
}
} catch (ReflectiveOperationException | ClassCastException e) {
content.setTextBody("Email content could not be parsed with advanced processing");
}
}
private static void processMultipart(
Object multipart,
EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer,
int depth) {
final int MAX_MULTIPART_DEPTH = 10;
if (depth > MAX_MULTIPART_DEPTH) {
content.setHtmlBody("<div class=\"error\">Maximum multipart depth exceeded</div>");
return;
}
try {
Class<?> multipartClass = multipart.getClass();
Method getCount = multipartClass.getMethod("getCount");
int count = (Integer) getCount.invoke(multipart);
Method getBodyPart = multipartClass.getMethod("getBodyPart", int.class);
for (int i = 0; i < count; i++) {
Object part = getBodyPart.invoke(multipart, i);
processPart(part, content, request, customHtmlSanitizer, depth + 1);
}
} catch (ReflectiveOperationException | ClassCastException e) {
content.setHtmlBody("<div class=\"error\">Error processing multipart content</div>");
}
}
private static void processPart(
Object part,
EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer,
int depth) {
try {
Class<?> partClass = part.getClass();
Method isMimeType = partClass.getMethod("isMimeType", String.class);
Method getContent = partClass.getMethod("getContent");
Method getDisposition = partClass.getMethod("getDisposition");
Method getFileName = partClass.getMethod("getFileName");
Method getContentType = partClass.getMethod("getContentType");
Method getHeader = partClass.getMethod("getHeader", String.class);
Object disposition = getDisposition.invoke(part);
String filename = (String) getFileName.invoke(part);
String contentType = (String) getContentType.invoke(part);
String normalizedDisposition =
disposition != null ? ((String) disposition).toLowerCase() : null;
if ((Boolean) isMimeType.invoke(part, TEXT_PLAIN) && normalizedDisposition == null) {
Object partContent = getContent.invoke(part);
if (partContent instanceof String stringContent) {
content.setTextBody(stringContent);
}
} else if ((Boolean) isMimeType.invoke(part, TEXT_HTML)
&& normalizedDisposition == null) {
Object partContent = getContent.invoke(part);
if (partContent instanceof String stringContent) {
String htmlBody =
customHtmlSanitizer != null
? customHtmlSanitizer.sanitize(stringContent)
: stringContent;
content.setHtmlBody(htmlBody);
}
} else if ((normalizedDisposition != null
&& normalizedDisposition.contains(DISPOSITION_ATTACHMENT))
|| (filename != null && !filename.trim().isEmpty())) {
processAttachment(
part, content, request, getHeader, getContent, filename, contentType);
} else if ((Boolean) isMimeType.invoke(part, "multipart/*")) {
Object multipartContent = getContent.invoke(part);
if (multipartContent != null) {
Class<?> multipartClass = Class.forName("jakarta.mail.Multipart");
if (multipartClass.isInstance(multipartContent)) {
processMultipart(
multipartContent, content, request, customHtmlSanitizer, depth + 1);
}
}
}
} catch (ReflectiveOperationException | RuntimeException e) {
// Continue processing other parts if one fails
}
}
private static void processAttachment(
Object part,
EmailContent content,
EmlToPdfRequest request,
Method getHeader,
Method getContent,
String filename,
String contentType) {
content.setAttachmentCount(content.getAttachmentCount() + 1);
if (filename != null && !filename.trim().isEmpty()) {
EmailAttachment attachment = new EmailAttachment();
attachment.setFilename(safeMimeDecode(filename));
attachment.setContentType(contentType);
try {
String[] contentIdHeaders = (String[]) getHeader.invoke(part, HEADER_CONTENT_ID);
if (contentIdHeaders != null) {
for (String contentIdHeader : contentIdHeaders) {
if (contentIdHeader != null && !contentIdHeader.trim().isEmpty()) {
attachment.setEmbedded(true);
String contentId = contentIdHeader.trim().replaceAll("[<>]", "");
attachment.setContentId(contentId);
break;
}
}
}
} catch (ReflectiveOperationException e) {
}
if ((request != null && request.isIncludeAttachments()) || attachment.isEmbedded()) {
extractAttachmentData(part, attachment, getContent, request);
}
content.getAttachments().add(attachment);
}
}
private static void extractAttachmentData(
Object part, EmailAttachment attachment, Method getContent, EmlToPdfRequest request) {
try {
Object attachmentContent = getContent.invoke(part);
byte[] attachmentData = null;
if (attachmentContent instanceof InputStream inputStream) {
try (InputStream stream = inputStream) {
attachmentData = stream.readAllBytes();
} catch (IOException e) {
if (attachment.isEmbedded()) {
attachmentData = new byte[0];
} else {
throw new RuntimeException(e);
}
}
} else if (attachmentContent instanceof byte[] byteArray) {
attachmentData = byteArray;
} else if (attachmentContent instanceof String stringContent) {
attachmentData = stringContent.getBytes(StandardCharsets.UTF_8);
}
if (attachmentData != null) {
long maxSizeMB = request != null ? request.getMaxAttachmentSizeMB() : 10L;
long maxSizeBytes = maxSizeMB * 1024 * 1024;
if (attachmentData.length <= maxSizeBytes || attachment.isEmbedded()) {
attachment.setData(attachmentData);
attachment.setSizeBytes(attachmentData.length);
} else {
attachment.setSizeBytes(attachmentData.length);
}
}
} catch (ReflectiveOperationException | RuntimeException e) {
// Continue without attachment data
}
}
private static String extractBasicHeader(String emlContent, String headerName) {
try {
String[] lines = emlContent.split("\r?\n");
for (int i = 0; i < lines.length; i++) {
String line = lines[i];
if (line.toLowerCase().startsWith(headerName.toLowerCase())) {
StringBuilder value =
new StringBuilder(line.substring(headerName.length()).trim());
for (int j = i + 1; j < lines.length; j++) {
if (lines[j].startsWith(" ") || lines[j].startsWith("\t")) {
value.append(" ").append(lines[j].trim());
} else {
break;
}
}
return safeMimeDecode(value.toString());
}
if (line.trim().isEmpty()) break;
}
} catch (RuntimeException e) {
// Ignore errors in header extraction
}
return "";
}
private static String extractHtmlBody(String emlContent) {
try {
String lowerContent = emlContent.toLowerCase();
int htmlStart = lowerContent.indexOf(HEADER_CONTENT_TYPE + " " + TEXT_HTML);
if (htmlStart == -1) return null;
int bodyStart = emlContent.indexOf("\r\n\r\n", htmlStart);
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n", htmlStart);
if (bodyStart == -1) return null;
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
int bodyEnd = findPartEnd(emlContent, bodyStart);
return emlContent.substring(bodyStart, bodyEnd).trim();
} catch (Exception e) {
return null;
}
}
private static String extractTextBody(String emlContent) {
try {
String lowerContent = emlContent.toLowerCase();
int textStart = lowerContent.indexOf(HEADER_CONTENT_TYPE + " " + TEXT_PLAIN);
if (textStart == -1) {
int bodyStart = emlContent.indexOf("\r\n\r\n");
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n");
if (bodyStart != -1) {
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
int bodyEnd = findPartEnd(emlContent, bodyStart);
return emlContent.substring(bodyStart, bodyEnd).trim();
}
return null;
}
int bodyStart = emlContent.indexOf("\r\n\r\n", textStart);
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n", textStart);
if (bodyStart == -1) return null;
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
int bodyEnd = findPartEnd(emlContent, bodyStart);
return emlContent.substring(bodyStart, bodyEnd).trim();
} catch (RuntimeException e) {
return null;
}
}
private static int findPartEnd(String content, int start) {
String[] lines = content.substring(start).split("\r?\n");
StringBuilder result = new StringBuilder();
for (String line : lines) {
if (line.startsWith("--") && line.length() > 10) break;
result.append(line).append("\n");
}
return start + result.length();
}
private static List<EmailAttachment> extractAttachmentsBasic(String emlContent) {
List<EmailAttachment> attachments = new ArrayList<>();
try {
String[] lines = emlContent.split("\r?\n");
boolean inHeaders = true;
String currentContentType = "";
String currentDisposition = "";
String currentFilename = "";
String currentEncoding = "";
for (String line : lines) {
String lowerLine = line.toLowerCase().trim();
if (line.trim().isEmpty()) {
inHeaders = false;
if (isAttachment(currentDisposition, currentFilename, currentContentType)) {
EmailAttachment attachment = new EmailAttachment();
attachment.setFilename(currentFilename);
attachment.setContentType(currentContentType);
attachment.setTransferEncoding(currentEncoding);
attachments.add(attachment);
}
currentContentType = "";
currentDisposition = "";
currentFilename = "";
currentEncoding = "";
inHeaders = true;
continue;
}
if (!inHeaders) continue;
if (lowerLine.startsWith(HEADER_CONTENT_TYPE)) {
currentContentType = line.substring(HEADER_CONTENT_TYPE.length()).trim();
} else if (lowerLine.startsWith(HEADER_CONTENT_DISPOSITION)) {
currentDisposition = line.substring(HEADER_CONTENT_DISPOSITION.length()).trim();
currentFilename = extractFilenameFromDisposition(currentDisposition);
} else if (lowerLine.startsWith(HEADER_CONTENT_TRANSFER_ENCODING)) {
currentEncoding =
line.substring(HEADER_CONTENT_TRANSFER_ENCODING.length()).trim();
}
}
} catch (RuntimeException e) {
// Continue with empty list
}
return attachments;
}
private static boolean isAttachment(String disposition, String filename, String contentType) {
return (disposition.toLowerCase().contains(DISPOSITION_ATTACHMENT) && !filename.isEmpty())
|| (!filename.isEmpty() && !contentType.toLowerCase().startsWith("text/"))
|| (contentType.toLowerCase().contains("application/") && !filename.isEmpty());
}
private static String extractFilenameFromDisposition(String disposition) {
if (disposition == null || !disposition.contains("filename=")) {
return "";
}
// Handle filename*= (RFC 2231 encoded filename)
if (disposition.toLowerCase().contains("filename*=")) {
int filenameStarStart = disposition.toLowerCase().indexOf("filename*=") + 10;
int filenameStarEnd = disposition.indexOf(";", filenameStarStart);
if (filenameStarEnd == -1) filenameStarEnd = disposition.length();
String extendedFilename =
disposition.substring(filenameStarStart, filenameStarEnd).trim();
extendedFilename = extendedFilename.replaceAll("^\"|\"$", "");
if (extendedFilename.contains("'")) {
String[] parts = extendedFilename.split("'", 3);
if (parts.length == 3) {
return EmlProcessingUtils.decodeUrlEncoded(parts[2]);
}
}
}
// Handle regular filename=
int filenameStart = disposition.toLowerCase().indexOf("filename=") + 9;
int filenameEnd = disposition.indexOf(";", filenameStart);
if (filenameEnd == -1) filenameEnd = disposition.length();
String filename = disposition.substring(filenameStart, filenameEnd).trim();
filename = filename.replaceAll("^\"|\"$", "");
return safeMimeDecode(filename);
}
public static String safeMimeDecode(String headerValue) {
if (headerValue == null || headerValue.trim().isEmpty()) {
return "";
}
if (!mimeUtilityChecked) {
synchronized (EmlParser.class) {
if (!mimeUtilityChecked) {
initializeMimeUtilityDecoding();
}
}
}
if (mimeUtilityDecodeTextMethod != null) {
try {
return (String) mimeUtilityDecodeTextMethod.invoke(null, headerValue.trim());
} catch (ReflectiveOperationException | RuntimeException e) {
// Fall through to custom implementation
}
}
return EmlProcessingUtils.decodeMimeHeader(headerValue.trim());
}
private static void initializeMimeUtilityDecoding() {
try {
Class<?> mimeUtilityClass = Class.forName("jakarta.mail.internet.MimeUtility");
mimeUtilityDecodeTextMethod = mimeUtilityClass.getMethod("decodeText", String.class);
} catch (ClassNotFoundException | NoSuchMethodException e) {
mimeUtilityDecodeTextMethod = null;
}
mimeUtilityChecked = true;
}
@Data
public static class EmailContent {
private String subject;
private String from;
private String to;
private String cc;
private String bcc;
private Date date;
private String dateString; // For basic parsing fallback
private String htmlBody;
private String textBody;
private int attachmentCount;
private List<EmailAttachment> attachments = new ArrayList<>();
public void setHtmlBody(String htmlBody) {
this.htmlBody = htmlBody != null ? htmlBody.replaceAll("\r", "") : null;
}
public void setTextBody(String textBody) {
this.textBody = textBody != null ? textBody.replaceAll("\r", "") : null;
}
}
@Data
public static class EmailAttachment {
private String filename;
private String contentType;
private byte[] data;
private boolean embedded;
private String embeddedFilename;
private long sizeBytes;
private String contentId;
private String disposition;
private String transferEncoding;
public void setData(byte[] data) {
this.data = data;
if (data != null) {
this.sizeBytes = data.length;
}
}
}
}
@@ -1,601 +0,0 @@
package stirling.software.common.util;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.experimental.UtilityClass;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
@UtilityClass
public class EmlProcessingUtils {
// Style constants
private static final int DEFAULT_FONT_SIZE = 12;
private static final String DEFAULT_FONT_FAMILY = "Helvetica, sans-serif";
private static final float DEFAULT_LINE_HEIGHT = 1.4f;
private static final String DEFAULT_ZOOM = "1.0";
private static final String DEFAULT_TEXT_COLOR = "#202124";
private static final String DEFAULT_BACKGROUND_COLOR = "#ffffff";
private static final String DEFAULT_BORDER_COLOR = "#e8eaed";
private static final String ATTACHMENT_BACKGROUND_COLOR = "#f9f9f9";
private static final String ATTACHMENT_BORDER_COLOR = "#eeeeee";
private static final int EML_CHECK_LENGTH = 8192;
private static final int MIN_HEADER_COUNT_FOR_VALID_EML = 2;
// MIME type detection
private static final Map<String, String> EXTENSION_TO_MIME_TYPE =
Map.of(
".png", "image/png",
".jpg", "image/jpeg",
".jpeg", "image/jpeg",
".gif", "image/gif",
".bmp", "image/bmp",
".webp", "image/webp",
".svg", "image/svg+xml",
".ico", "image/x-icon",
".tiff", "image/tiff",
".tif", "image/tiff");
public static void validateEmlInput(byte[] emlBytes) {
if (emlBytes == null || emlBytes.length == 0) {
throw new IllegalArgumentException("EML file is empty or null");
}
if (isInvalidEmlFormat(emlBytes)) {
throw new IllegalArgumentException("Invalid EML file format");
}
}
private static boolean isInvalidEmlFormat(byte[] emlBytes) {
try {
int checkLength = Math.min(emlBytes.length, EML_CHECK_LENGTH);
String content;
try {
content = new String(emlBytes, 0, checkLength, StandardCharsets.UTF_8);
if (content.contains("\uFFFD")) {
content = new String(emlBytes, 0, checkLength, StandardCharsets.ISO_8859_1);
}
} catch (Exception e) {
content = new String(emlBytes, 0, checkLength, StandardCharsets.ISO_8859_1);
}
String lowerContent = content.toLowerCase(Locale.ROOT);
boolean hasFrom =
lowerContent.contains("from:") || lowerContent.contains("return-path:");
boolean hasSubject = lowerContent.contains("subject:");
boolean hasMessageId = lowerContent.contains("message-id:");
boolean hasDate = lowerContent.contains("date:");
boolean hasTo =
lowerContent.contains("to:")
|| lowerContent.contains("cc:")
|| lowerContent.contains("bcc:");
boolean hasMimeStructure =
lowerContent.contains("multipart/")
|| lowerContent.contains("text/plain")
|| lowerContent.contains("text/html")
|| lowerContent.contains("boundary=");
int headerCount = 0;
if (hasFrom) headerCount++;
if (hasSubject) headerCount++;
if (hasMessageId) headerCount++;
if (hasDate) headerCount++;
if (hasTo) headerCount++;
return headerCount < MIN_HEADER_COUNT_FOR_VALID_EML && !hasMimeStructure;
} catch (RuntimeException e) {
return false;
}
}
public static String generateEnhancedEmailHtml(
EmlParser.EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer) {
StringBuilder html = new StringBuilder();
html.append(
String.format(
"""
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<title>%s</title>
<style>
""",
sanitizeText(content.getSubject(), customHtmlSanitizer)));
appendEnhancedStyles(html);
html.append(
"""
</style>
</head><body>
""");
html.append(
String.format(
"""
<div class="email-container">
<div class="email-header">
<h1>%s</h1>
<div class="email-meta">
<div><strong>From:</strong> %s</div>
<div><strong>To:</strong> %s</div>
""",
sanitizeText(content.getSubject(), customHtmlSanitizer),
sanitizeText(content.getFrom(), customHtmlSanitizer),
sanitizeText(content.getTo(), customHtmlSanitizer)));
if (content.getCc() != null && !content.getCc().trim().isEmpty()) {
html.append(
String.format(
"<div><strong>CC:</strong> %s</div>\n",
sanitizeText(content.getCc(), customHtmlSanitizer)));
}
if (content.getBcc() != null && !content.getBcc().trim().isEmpty()) {
html.append(
String.format(
"<div><strong>BCC:</strong> %s</div>\n",
sanitizeText(content.getBcc(), customHtmlSanitizer)));
}
if (content.getDate() != null) {
html.append(
String.format(
"<div><strong>Date:</strong> %s</div>\n",
PdfAttachmentHandler.formatEmailDate(content.getDate())));
} else if (content.getDateString() != null && !content.getDateString().trim().isEmpty()) {
html.append(
String.format(
"<div><strong>Date:</strong> %s</div>\n",
sanitizeText(content.getDateString(), customHtmlSanitizer)));
}
html.append("</div></div>\n");
html.append("<div class=\"email-body\">\n");
if (content.getHtmlBody() != null && !content.getHtmlBody().trim().isEmpty()) {
String processedHtml =
processEmailHtmlBody(content.getHtmlBody(), content, customHtmlSanitizer);
html.append(processedHtml);
} else if (content.getTextBody() != null && !content.getTextBody().trim().isEmpty()) {
html.append(
String.format(
"<div class=\"text-body\">%s</div>",
convertTextToHtml(content.getTextBody(), customHtmlSanitizer)));
} else {
html.append("<div class=\"no-content\"><p><em>No content available</em></p></div>");
}
html.append("</div>\n");
if (content.getAttachmentCount() > 0 || !content.getAttachments().isEmpty()) {
appendAttachmentsSection(html, content, request, customHtmlSanitizer);
}
html.append("</div>\n</body></html>");
return html.toString();
}
public static String processEmailHtmlBody(
String htmlBody,
EmlParser.EmailContent emailContent,
CustomHtmlSanitizer customHtmlSanitizer) {
if (htmlBody == null) return "";
String processed =
customHtmlSanitizer != null ? customHtmlSanitizer.sanitize(htmlBody) : htmlBody;
processed = processed.replaceAll("(?i)\\s*position\\s*:\\s*fixed[^;]*;?", "");
processed = processed.replaceAll("(?i)\\s*position\\s*:\\s*absolute[^;]*;?", "");
if (emailContent != null && !emailContent.getAttachments().isEmpty()) {
processed = PdfAttachmentHandler.processInlineImages(processed, emailContent);
}
return processed;
}
public static String convertTextToHtml(
String textBody, CustomHtmlSanitizer customHtmlSanitizer) {
if (textBody == null) return "";
String html =
customHtmlSanitizer != null
? customHtmlSanitizer.sanitize(textBody)
: escapeHtml(textBody);
html = html.replace("\r\n", "\n").replace("\r", "\n");
html = html.replace("\n", "<br>\n");
html =
html.replaceAll(
"(https?://[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+)",
"<a href=\"$1\" style=\"color: #1a73e8; text-decoration: underline;\">$1</a>");
html =
html.replaceAll(
"([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,63})",
"<a href=\"mailto:$1\" style=\"color: #1a73e8; text-decoration: underline;\">$1</a>");
return html;
}
private static void appendEnhancedStyles(StringBuilder html) {
String css =
String.format(
"""
body {
font-family: %s;
font-size: %dpx;
line-height: %s;
color: %s;
margin: 0;
padding: 16px;
background-color: %s;
}
.email-container {
width: 100%%;
max-width: 100%%;
margin: 0 auto;
}
.email-header {
padding-bottom: 10px;
border-bottom: 1px solid %s;
margin-bottom: 10px;
}
.email-header h1 {
margin: 0 0 10px 0;
font-size: %dpx;
font-weight: bold;
}
.email-meta div {
margin-bottom: 2px;
font-size: %dpx;
}
.email-body {
word-wrap: break-word;
}
.attachment-section {
margin-top: 15px;
padding: 10px;
background-color: %s;
border: 1px solid %s;
border-radius: 3px;
}
.attachment-section h3 {
margin: 0 0 8px 0;
font-size: %dpx;
}
.attachment-item {
padding: 5px 0;
}
.attachment-icon {
margin-right: 5px;
}
.attachment-details, .attachment-type {
font-size: %dpx;
color: #555555;
}
.attachment-inclusion-note, .attachment-info-note {
margin-top: 8px;
padding: 6px;
font-size: %dpx;
border-radius: 3px;
}
.attachment-inclusion-note {
background-color: #e6ffed;
border: 1px solid #d4f7dc;
color: #006420;
}
.attachment-info-note {
background-color: #fff9e6;
border: 1px solid #fff0c2;
color: #664d00;
}
.attachment-link-container {
display: flex;
align-items: center;
padding: 8px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
margin: 4px 0;
}
.attachment-link-container:hover {
background-color: #e9ecef;
}
.attachment-note {
font-size: %dpx;
color: #6c757d;
font-style: italic;
margin-left: 8px;
}
.no-content {
padding: 20px;
text-align: center;
color: #666;
font-style: italic;
}
.text-body {
white-space: pre-wrap;
}
img {
max-width: 100%%;
height: auto;
display: block;
}
""",
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_LINE_HEIGHT,
DEFAULT_TEXT_COLOR,
DEFAULT_BACKGROUND_COLOR,
DEFAULT_BORDER_COLOR,
DEFAULT_FONT_SIZE + 4,
DEFAULT_FONT_SIZE - 1,
ATTACHMENT_BACKGROUND_COLOR,
ATTACHMENT_BORDER_COLOR,
DEFAULT_FONT_SIZE + 1,
DEFAULT_FONT_SIZE - 2,
DEFAULT_FONT_SIZE - 2,
DEFAULT_FONT_SIZE - 3);
html.append(css);
}
private static void appendAttachmentsSection(
StringBuilder html,
EmlParser.EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer) {
html.append("<div class=\"attachment-section\">\n");
int displayedAttachmentCount =
content.getAttachmentCount() > 0
? content.getAttachmentCount()
: content.getAttachments().size();
html.append("<h3>Attachments (").append(displayedAttachmentCount).append(")</h3>\n");
if (!content.getAttachments().isEmpty()) {
for (int i = 0; i < content.getAttachments().size(); i++) {
EmlParser.EmailAttachment attachment = content.getAttachments().get(i);
String embeddedFilename =
attachment.getFilename() != null
? attachment.getFilename()
: ("attachment_" + i);
attachment.setEmbeddedFilename(embeddedFilename);
String sizeStr = GeneralUtils.formatBytes(attachment.getSizeBytes());
String contentType =
attachment.getContentType() != null
&& !attachment.getContentType().isEmpty()
? ", " + escapeHtml(attachment.getContentType())
: "";
String attachmentId = "attachment_" + i;
html.append(
String.format(
"""
<div class="attachment-item" id="%s">
<span class="attachment-icon" data-filename="%s">@</span>
<span class="attachment-name">%s</span>
<span class="attachment-details">(%s%s)</span>
</div>
""",
attachmentId,
escapeHtml(embeddedFilename),
escapeHtml(EmlParser.safeMimeDecode(attachment.getFilename())),
sizeStr,
contentType));
}
}
if (request != null && request.isIncludeAttachments()) {
html.append(
"""
<div class="attachment-info-note">
<p><em>Attachments are embedded in the file.</em></p>
</div>
""");
} else {
html.append(
"""
<div class="attachment-info-note">
<p><em>Attachment information displayed - files not included in PDF.</em></p>
</div>
""");
}
html.append("</div>\n");
}
public static HTMLToPdfRequest createHtmlRequest(EmlToPdfRequest request) {
HTMLToPdfRequest htmlRequest = new HTMLToPdfRequest();
if (request != null) {
htmlRequest.setFileInput(request.getFileInput());
}
htmlRequest.setZoom(Float.parseFloat(DEFAULT_ZOOM));
return htmlRequest;
}
public static String detectMimeType(String filename, String existingMimeType) {
if (existingMimeType != null && !existingMimeType.isEmpty()) {
return existingMimeType;
}
if (filename != null) {
String lowerFilename = filename.toLowerCase();
for (Map.Entry<String, String> entry : EXTENSION_TO_MIME_TYPE.entrySet()) {
if (lowerFilename.endsWith(entry.getKey())) {
return entry.getValue();
}
}
}
return "image/png";
}
public static String decodeUrlEncoded(String encoded) {
try {
return java.net.URLDecoder.decode(encoded, StandardCharsets.UTF_8);
} catch (Exception e) {
return encoded; // Return original if decoding fails
}
}
public static String decodeMimeHeader(String encodedText) {
if (encodedText == null || encodedText.trim().isEmpty()) {
return encodedText;
}
try {
StringBuilder result = new StringBuilder();
Pattern concatenatedPattern =
Pattern.compile(
"(=\\?[^?]+\\?[BbQq]\\?[^?]*\\?=)(\\s*=\\?[^?]+\\?[BbQq]\\?[^?]*\\?=)+");
Matcher concatenatedMatcher = concatenatedPattern.matcher(encodedText);
String processedText =
concatenatedMatcher.replaceAll(
match -> match.group().replaceAll("\\s+(?==\\?)", ""));
Pattern mimePattern = Pattern.compile("=\\?([^?]+)\\?([BbQq])\\?([^?]*)\\?=");
Matcher matcher = mimePattern.matcher(processedText);
int lastEnd = 0;
while (matcher.find()) {
result.append(processedText, lastEnd, matcher.start());
String charset = matcher.group(1);
String encoding = matcher.group(2).toUpperCase();
String encodedValue = matcher.group(3);
try {
String decodedValue =
switch (encoding) {
case "B" -> {
String cleanBase64 = encodedValue.replaceAll("\\s", "");
byte[] decodedBytes = Base64.getDecoder().decode(cleanBase64);
Charset targetCharset;
try {
targetCharset = Charset.forName(charset);
} catch (Exception e) {
targetCharset = StandardCharsets.UTF_8;
}
yield new String(decodedBytes, targetCharset);
}
case "Q" -> decodeQuotedPrintable(encodedValue, charset);
default -> matcher.group(0); // Return original if unknown encoding
};
result.append(decodedValue);
} catch (RuntimeException e) {
result.append(matcher.group(0)); // Keep original on decode error
}
lastEnd = matcher.end();
}
result.append(processedText.substring(lastEnd));
return result.toString();
} catch (Exception e) {
return encodedText; // Return original on any parsing error
}
}
private static String decodeQuotedPrintable(String encodedText, String charset) {
StringBuilder result = new StringBuilder();
for (int i = 0; i < encodedText.length(); i++) {
char c = encodedText.charAt(i);
switch (c) {
case '=' -> {
if (i + 2 < encodedText.length()) {
String hex = encodedText.substring(i + 1, i + 3);
try {
int value = Integer.parseInt(hex, 16);
result.append((char) value);
i += 2;
} catch (NumberFormatException e) {
result.append(c);
}
} else if (i + 1 == encodedText.length()
|| (i + 2 == encodedText.length()
&& encodedText.charAt(i + 1) == '\n')) {
if (i + 1 < encodedText.length() && encodedText.charAt(i + 1) == '\n') {
i++; // Skip the newline too
}
} else {
result.append(c);
}
}
case '_' -> result.append(' '); // Space encoding in Q encoding
default -> result.append(c);
}
}
byte[] bytes = result.toString().getBytes(StandardCharsets.ISO_8859_1);
try {
Charset targetCharset = Charset.forName(charset);
return new String(bytes, targetCharset);
} catch (Exception e) {
try {
return new String(bytes, StandardCharsets.UTF_8);
} catch (Exception fallbackException) {
return new String(bytes, StandardCharsets.ISO_8859_1);
}
}
}
public static String escapeHtml(String text) {
if (text == null) return "";
return text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
public static String sanitizeText(String text, CustomHtmlSanitizer customHtmlSanitizer) {
if (customHtmlSanitizer != null) {
return customHtmlSanitizer.sanitize(text);
} else {
return escapeHtml(text);
}
}
public static String simplifyHtmlContent(String htmlContent) {
String simplified = htmlContent.replaceAll("(?i)<script[^>]*>.*?</script>", "");
simplified = simplified.replaceAll("(?i)<style[^>]*>.*?</style>", "");
return simplified;
}
}
@@ -1,103 +0,0 @@
package stirling.software.common.util;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import lombok.experimental.UtilityClass;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
@UtilityClass
public class EmlToPdf {
public static String convertEmlToHtml(byte[] emlBytes, EmlToPdfRequest request)
throws IOException {
EmlProcessingUtils.validateEmlInput(emlBytes);
EmlParser.EmailContent emailContent =
EmlParser.extractEmailContent(emlBytes, request, null);
return EmlProcessingUtils.generateEnhancedEmailHtml(emailContent, request, null);
}
public static byte[] convertEmlToPdf(
String weasyprintPath,
EmlToPdfRequest request,
byte[] emlBytes,
String fileName,
CustomPDFDocumentFactory pdfDocumentFactory,
TempFileManager tempFileManager,
CustomHtmlSanitizer customHtmlSanitizer)
throws IOException, InterruptedException {
EmlProcessingUtils.validateEmlInput(emlBytes);
try {
EmlParser.EmailContent emailContent =
EmlParser.extractEmailContent(emlBytes, request, customHtmlSanitizer);
String htmlContent =
EmlProcessingUtils.generateEnhancedEmailHtml(
emailContent, request, customHtmlSanitizer);
byte[] pdfBytes =
convertHtmlToPdf(
weasyprintPath,
request,
htmlContent,
tempFileManager,
customHtmlSanitizer);
if (shouldAttachFiles(emailContent, request)) {
pdfBytes =
PdfAttachmentHandler.attachFilesToPdf(
pdfBytes, emailContent.getAttachments(), pdfDocumentFactory);
}
return pdfBytes;
} catch (IOException | InterruptedException e) {
throw e;
} catch (Exception e) {
throw new IOException("Error converting EML to PDF", e);
}
}
private static boolean shouldAttachFiles(
EmlParser.EmailContent emailContent, EmlToPdfRequest request) {
return emailContent != null
&& request != null
&& request.isIncludeAttachments()
&& !emailContent.getAttachments().isEmpty();
}
private static byte[] convertHtmlToPdf(
String weasyprintPath,
EmlToPdfRequest request,
String htmlContent,
TempFileManager tempFileManager,
CustomHtmlSanitizer customHtmlSanitizer)
throws IOException, InterruptedException {
var htmlRequest = EmlProcessingUtils.createHtmlRequest(request);
try {
return FileToPdf.convertHtmlToPdf(
weasyprintPath,
htmlRequest,
htmlContent.getBytes(StandardCharsets.UTF_8),
"email.html",
tempFileManager,
customHtmlSanitizer);
} catch (IOException | InterruptedException e) {
String simplifiedHtml = EmlProcessingUtils.simplifyHtmlContent(htmlContent);
return FileToPdf.convertHtmlToPdf(
weasyprintPath,
htmlRequest,
simplifiedHtml.getBytes(StandardCharsets.UTF_8),
"email.html",
tempFileManager,
customHtmlSanitizer);
}
}
}
@@ -1,327 +0,0 @@
package stirling.software.common.util;
import java.io.IOException;
import java.text.MessageFormat;
import lombok.extern.slf4j.Slf4j;
/**
* Utility class for handling exceptions with internationalized error messages. Provides consistent
* error handling and user-friendly messages across the application.
*/
@Slf4j
public class ExceptionUtils {
/**
* Create an IOException with internationalized message for PDF corruption.
*
* @param cause the original exception
* @return IOException with user-friendly message
*/
public static IOException createPdfCorruptedException(Exception cause) {
return createPdfCorruptedException(null, cause);
}
/**
* Create an IOException with internationalized message for PDF corruption with context.
*
* @param context additional context (e.g., "during merge", "during image extraction")
* @param cause the original exception
* @return IOException with user-friendly message
*/
public static IOException createPdfCorruptedException(String context, Exception cause) {
String message;
if (context != null && !context.isEmpty()) {
message =
String.format(
"Error %s: PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation.",
context);
} else {
message =
"PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation.";
}
return new IOException(message, cause);
}
/**
* Create an IOException with internationalized message for multiple corrupted PDFs.
*
* @param cause the original exception
* @return IOException with user-friendly message
*/
public static IOException createMultiplePdfCorruptedException(Exception cause) {
String message =
"One or more PDF files appear to be corrupted or damaged. Please try using the 'Repair PDF' feature on each file first before attempting to merge them.";
return new IOException(message, cause);
}
/**
* Create an IOException with internationalized message for PDF encryption issues.
*
* @param cause the original exception
* @return IOException with user-friendly message
*/
public static IOException createPdfEncryptionException(Exception cause) {
String message =
"The PDF appears to have corrupted encryption data. This can happen when the PDF was created with incompatible encryption methods. Please try using the 'Repair PDF' feature first, or contact the document creator for a new copy.";
return new IOException(message, cause);
}
/**
* Create an IOException with internationalized message for PDF password issues.
*
* @param cause the original exception
* @return IOException with user-friendly message
*/
public static IOException createPdfPasswordException(Exception cause) {
String message =
"The PDF Document is passworded and either the password was not provided or was incorrect";
return new IOException(message, cause);
}
/**
* Create an IOException with internationalized message for file processing errors.
*
* @param operation the operation being performed (e.g., "merge", "split", "convert")
* @param cause the original exception
* @return IOException with user-friendly message
*/
public static IOException createFileProcessingException(String operation, Exception cause) {
String message =
String.format(
"An error occurred while processing the file during %s operation: %s",
operation, cause.getMessage());
return new IOException(message, cause);
}
/**
* Create a generic IOException with internationalized message.
*
* @param messageKey the i18n message key
* @param defaultMessage the default message if i18n is not available
* @param cause the original exception
* @param args optional arguments for the message
* @return IOException with user-friendly message
*/
public static IOException createIOException(
String messageKey, String defaultMessage, Exception cause, Object... args) {
String message = MessageFormat.format(defaultMessage, args);
return new IOException(message, cause);
}
/**
* Create a generic RuntimeException with internationalized message.
*
* @param messageKey the i18n message key
* @param defaultMessage the default message if i18n is not available
* @param cause the original exception
* @param args optional arguments for the message
* @return RuntimeException with user-friendly message
*/
public static RuntimeException createRuntimeException(
String messageKey, String defaultMessage, Exception cause, Object... args) {
String message = MessageFormat.format(defaultMessage, args);
return new RuntimeException(message, cause);
}
/**
* Create an IllegalArgumentException with internationalized message.
*
* @param messageKey the i18n message key
* @param defaultMessage the default message if i18n is not available
* @param args optional arguments for the message
* @return IllegalArgumentException with user-friendly message
*/
public static IllegalArgumentException createIllegalArgumentException(
String messageKey, String defaultMessage, Object... args) {
String message = MessageFormat.format(defaultMessage, args);
return new IllegalArgumentException(message);
}
/** Create file validation exceptions. */
public static IllegalArgumentException createHtmlFileRequiredException() {
return createIllegalArgumentException(
"error.fileFormatRequired", "File must be in {0} format", "HTML or ZIP");
}
public static IllegalArgumentException createPdfFileRequiredException() {
return createIllegalArgumentException(
"error.fileFormatRequired", "File must be in {0} format", "PDF");
}
public static IllegalArgumentException createInvalidPageSizeException(String size) {
return createIllegalArgumentException(
"error.invalidFormat", "Invalid {0} format: {1}", "page size", size);
}
/** Create OCR-related exceptions. */
public static IOException createOcrLanguageRequiredException() {
return createIOException(
"error.optionsNotSpecified", "{0} options are not specified", null, "OCR language");
}
public static IOException createOcrInvalidLanguagesException() {
return createIOException(
"error.invalidFormat",
"Invalid {0} format: {1}",
null,
"OCR languages",
"none of the selected languages are valid");
}
public static IOException createOcrToolsUnavailableException() {
return createIOException(
"error.toolNotInstalled", "{0} is not installed", null, "OCR tools");
}
/** Create system requirement exceptions. */
public static IOException createPythonRequiredForWebpException() {
return createIOException(
"error.toolRequired", "{0} is required for {1}", null, "Python", "WebP conversion");
}
/** Create file operation exceptions. */
public static IOException createFileNotFoundException(String fileId) {
return createIOException("error.fileNotFound", "File not found with ID: {0}", null, fileId);
}
public static RuntimeException createPdfaConversionFailedException() {
return createRuntimeException(
"error.conversionFailed", "{0} conversion failed", null, "PDF/A");
}
public static IllegalArgumentException createInvalidComparatorException() {
return createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"comparator",
"only 'greater', 'equal', and 'less' are supported");
}
/** Create compression-related exceptions. */
public static RuntimeException createMd5AlgorithmException(Exception cause) {
return createRuntimeException(
"error.algorithmNotAvailable", "{0} algorithm not available", cause, "MD5");
}
public static IllegalArgumentException createCompressionOptionsException() {
return createIllegalArgumentException(
"error.optionsNotSpecified",
"{0} options are not specified",
"compression (expected output size and optimize level)");
}
public static IOException createGhostscriptCompressionException() {
return createIOException(
"error.commandFailed", "{0} command failed", null, "Ghostscript compression");
}
public static IOException createGhostscriptCompressionException(Exception cause) {
return createIOException(
"error.commandFailed", "{0} command failed", cause, "Ghostscript compression");
}
public static IOException createQpdfCompressionException(Exception cause) {
return createIOException("error.commandFailed", "{0} command failed", cause, "QPDF");
}
/**
* Check if an exception indicates a corrupted PDF and wrap it with appropriate message.
*
* @param e the exception to check
* @return the original exception if not PDF corruption, or a new IOException with user-friendly
* message
*/
public static IOException handlePdfException(IOException e) {
return handlePdfException(e, null);
}
/**
* Check if an exception indicates a corrupted PDF and wrap it with appropriate message.
*
* @param e the exception to check
* @param context additional context for the error
* @return the original exception if not PDF corruption, or a new IOException with user-friendly
* message
*/
public static IOException handlePdfException(IOException e, String context) {
if (PdfErrorUtils.isCorruptedPdfError(e)) {
return createPdfCorruptedException(context, e);
}
if (isEncryptionError(e)) {
return createPdfEncryptionException(e);
}
if (isPasswordError(e)) {
return createPdfPasswordException(e);
}
return e; // Return original exception if no specific handling needed
}
/**
* Check if an exception indicates a PDF encryption/decryption error.
*
* @param e the exception to check
* @return true if it's an encryption error, false otherwise
*/
public static boolean isEncryptionError(IOException e) {
String message = e.getMessage();
if (message == null) return false;
return message.contains("BadPaddingException")
|| message.contains("Given final block not properly padded")
|| message.contains("AES initialization vector not fully read")
|| message.contains("Failed to decrypt");
}
/**
* Check if an exception indicates a PDF password error.
*
* @param e the exception to check
* @return true if it's a password error, false otherwise
*/
public static boolean isPasswordError(IOException e) {
String message = e.getMessage();
if (message == null) return false;
return message.contains("password is incorrect")
|| message.contains("Password is not provided")
|| message.contains("PDF contains an encryption dictionary");
}
/**
* Log an exception with appropriate level based on its type.
*
* @param operation the operation being performed
* @param e the exception that occurred
*/
public static void logException(String operation, Exception e) {
if (PdfErrorUtils.isCorruptedPdfError(e)) {
log.warn("PDF corruption detected during {}: {}", operation, e.getMessage());
} else if (e instanceof IOException
&& (isEncryptionError((IOException) e) || isPasswordError((IOException) e))) {
log.info("PDF security issue during {}: {}", operation, e.getMessage());
} else {
log.error("Unexpected error during {}", operation, e);
}
}
/** Create common validation exceptions. */
public static IllegalArgumentException createInvalidArgumentException(String argumentName) {
return createIllegalArgumentException(
"error.invalidArgument", "Invalid argument: {0}", argumentName);
}
public static IllegalArgumentException createInvalidArgumentException(
String argumentName, String value) {
return createIllegalArgumentException(
"error.invalidFormat", "Invalid {0} format: {1}", argumentName, value);
}
public static IllegalArgumentException createNullArgumentException(String argumentName) {
return createIllegalArgumentException(
"error.argumentRequired", "{0} must not be null", argumentName);
}
}
@@ -1,31 +0,0 @@
package stirling.software.common.util;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class ExecutorFactory {
/**
* Creates an ExecutorService using virtual threads if available (Java 21+), or falls back to a
* cached thread pool on older Java versions.
*/
public static ExecutorService newVirtualOrCachedThreadExecutor() {
try {
ExecutorService executor =
(ExecutorService)
Executors.class
.getMethod("newVirtualThreadPerTaskExecutor")
.invoke(null);
return executor;
} catch (NoSuchMethodException e) {
log.debug("Virtual threads not available; falling back to cached thread pool.");
} catch (Exception e) {
log.debug("Error initializing virtual thread executor: {}", e.getMessage(), e);
}
return Executors.newCachedThreadPool();
}
}
@@ -1,680 +0,0 @@
package stirling.software.common.util;
import static stirling.software.common.util.AttachmentUtils.setCatalogViewerPreferences;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary;
import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PageMode;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationFileAttachment;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
import org.jetbrains.annotations.NotNull;
import org.springframework.web.multipart.MultipartFile;
import lombok.Data;
import lombok.Getter;
import lombok.experimental.UtilityClass;
import stirling.software.common.service.CustomPDFDocumentFactory;
@UtilityClass
public class PdfAttachmentHandler {
// Note: This class is designed for EML attachments, not general PDF attachments.
private static final String ATTACHMENT_MARKER = "@";
private static final float ATTACHMENT_ICON_WIDTH = 12f;
private static final float ATTACHMENT_ICON_HEIGHT = 14f;
private static final float ANNOTATION_X_OFFSET = 2f;
private static final float ANNOTATION_Y_OFFSET = 10f;
public static byte[] attachFilesToPdf(
byte[] pdfBytes,
List<EmlParser.EmailAttachment> attachments,
CustomPDFDocumentFactory pdfDocumentFactory)
throws IOException {
if (attachments == null || attachments.isEmpty()) {
return pdfBytes;
}
try (PDDocument document = pdfDocumentFactory.load(pdfBytes);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
List<MultipartFile> multipartAttachments = new ArrayList<>(attachments.size());
for (int i = 0; i < attachments.size(); i++) {
EmlParser.EmailAttachment attachment = attachments.get(i);
if (attachment.getData() != null && attachment.getData().length > 0) {
String embeddedFilename =
attachment.getFilename() != null
? attachment.getFilename()
: ("attachment_" + i);
attachment.setEmbeddedFilename(embeddedFilename);
multipartAttachments.add(createMultipartFile(attachment));
}
}
if (!multipartAttachments.isEmpty()) {
Map<Integer, String> indexToFilenameMap =
addAttachmentsToDocumentWithMapping(
document, multipartAttachments, attachments);
setCatalogViewerPreferences(document, PageMode.USE_ATTACHMENTS);
addAttachmentAnnotationsToDocumentWithMapping(
document, attachments, indexToFilenameMap);
}
document.save(outputStream);
return outputStream.toByteArray();
} catch (RuntimeException e) {
throw new IOException(
"Invalid PDF structure or processing error: " + e.getMessage(), e);
} catch (Exception e) {
throw new IOException("Error attaching files to PDF: " + e.getMessage(), e);
}
}
private static MultipartFile createMultipartFile(EmlParser.EmailAttachment attachment) {
return new MultipartFile() {
@Override
public @NotNull String getName() {
return "attachment";
}
@Override
public String getOriginalFilename() {
return attachment.getFilename() != null
? attachment.getFilename()
: "attachment_" + System.currentTimeMillis();
}
@Override
public String getContentType() {
return attachment.getContentType() != null
? attachment.getContentType()
: "application/octet-stream";
}
@Override
public boolean isEmpty() {
return attachment.getData() == null || attachment.getData().length == 0;
}
@Override
public long getSize() {
return attachment.getData() != null ? attachment.getData().length : 0;
}
@Override
public byte @NotNull [] getBytes() {
return attachment.getData() != null ? attachment.getData() : new byte[0];
}
@Override
public @NotNull InputStream getInputStream() {
byte[] data = attachment.getData();
return new ByteArrayInputStream(data != null ? data : new byte[0]);
}
@Override
public void transferTo(@NotNull File dest) throws IOException, IllegalStateException {
try (FileOutputStream fos = new FileOutputStream(dest)) {
byte[] data = attachment.getData();
if (data != null) {
fos.write(data);
}
}
}
};
}
private static String ensureUniqueFilename(String filename, Set<String> existingNames) {
if (!existingNames.contains(filename)) {
return filename;
}
String baseName;
String extension = "";
int lastDot = filename.lastIndexOf('.');
if (lastDot > 0) {
baseName = filename.substring(0, lastDot);
extension = filename.substring(lastDot);
} else {
baseName = filename;
}
int counter = 1;
String uniqueName;
do {
uniqueName = baseName + "_" + counter + extension;
counter++;
} while (existingNames.contains(uniqueName));
return uniqueName;
}
private static @NotNull PDRectangle calculateAnnotationRectangle(
PDPage page, float x, float y) {
PDRectangle cropBox = page.getCropBox();
// ISO 32000-1:2008 Section 8.3: PDF coordinate system transforms
int rotation = page.getRotation();
float pdfX = x;
float pdfY = cropBox.getHeight() - y;
switch (rotation) {
case 90 -> {
float temp = pdfX;
pdfX = pdfY;
pdfY = cropBox.getWidth() - temp;
}
case 180 -> {
pdfX = cropBox.getWidth() - pdfX;
pdfY = y;
}
case 270 -> {
float temp = pdfX;
pdfX = cropBox.getHeight() - pdfY;
pdfY = temp;
}
default -> {}
}
float iconHeight = ATTACHMENT_ICON_HEIGHT;
float paddingX = 2.0f;
float paddingY = 2.0f;
PDRectangle rect =
new PDRectangle(
pdfX + ANNOTATION_X_OFFSET + paddingX,
pdfY - iconHeight + ANNOTATION_Y_OFFSET + paddingY,
ATTACHMENT_ICON_WIDTH,
iconHeight);
PDRectangle mediaBox = page.getMediaBox();
if (rect.getLowerLeftX() < mediaBox.getLowerLeftX()
|| rect.getLowerLeftY() < mediaBox.getLowerLeftY()
|| rect.getUpperRightX() > mediaBox.getUpperRightX()
|| rect.getUpperRightY() > mediaBox.getUpperRightY()) {
float adjustedX =
Math.max(
mediaBox.getLowerLeftX(),
Math.min(
rect.getLowerLeftX(),
mediaBox.getUpperRightX() - rect.getWidth()));
float adjustedY =
Math.max(
mediaBox.getLowerLeftY(),
Math.min(
rect.getLowerLeftY(),
mediaBox.getUpperRightY() - rect.getHeight()));
rect = new PDRectangle(adjustedX, adjustedY, rect.getWidth(), rect.getHeight());
}
return rect;
}
public static String processInlineImages(
String htmlContent, EmlParser.EmailContent emailContent) {
if (htmlContent == null || emailContent == null) return htmlContent;
Map<String, EmlParser.EmailAttachment> contentIdMap = new HashMap<>();
for (EmlParser.EmailAttachment attachment : emailContent.getAttachments()) {
if (attachment.isEmbedded()
&& attachment.getContentId() != null
&& attachment.getData() != null) {
contentIdMap.put(attachment.getContentId(), attachment);
}
}
if (contentIdMap.isEmpty()) return htmlContent;
Pattern cidPattern =
Pattern.compile(
"(?i)<img[^>]*\\ssrc\\s*=\\s*['\"]cid:([^'\"]+)['\"][^>]*>",
Pattern.CASE_INSENSITIVE);
Matcher matcher = cidPattern.matcher(htmlContent);
StringBuilder result = new StringBuilder();
while (matcher.find()) {
String contentId = matcher.group(1);
EmlParser.EmailAttachment attachment = contentIdMap.get(contentId);
if (attachment != null && attachment.getData() != null) {
String mimeType =
EmlProcessingUtils.detectMimeType(
attachment.getFilename(), attachment.getContentType());
String base64Data = Base64.getEncoder().encodeToString(attachment.getData());
String dataUri = "data:" + mimeType + ";base64," + base64Data;
String replacement =
matcher.group(0).replaceFirst("cid:" + Pattern.quote(contentId), dataUri);
matcher.appendReplacement(result, Matcher.quoteReplacement(replacement));
} else {
matcher.appendReplacement(result, Matcher.quoteReplacement(matcher.group(0)));
}
}
matcher.appendTail(result);
return result.toString();
}
public static String formatEmailDate(Date date) {
if (date == null) return "";
SimpleDateFormat formatter =
new SimpleDateFormat("EEE, MMM d, yyyy 'at' h:mm a z", Locale.ENGLISH);
formatter.setTimeZone(TimeZone.getTimeZone("UTC"));
return formatter.format(date);
}
@Data
public static class MarkerPosition {
private int pageIndex;
private float x;
private float y;
private String character;
private String filename;
public MarkerPosition(int pageIndex, float x, float y, String character, String filename) {
this.pageIndex = pageIndex;
this.x = x;
this.y = y;
this.character = character;
this.filename = filename;
}
}
public static class AttachmentMarkerPositionFinder extends PDFTextStripper {
@Getter private final List<MarkerPosition> positions = new ArrayList<>();
private int currentPageIndex;
protected boolean sortByPosition;
private boolean isInAttachmentSection;
private boolean attachmentSectionFound;
private final StringBuilder currentText = new StringBuilder();
private static final Pattern ATTACHMENT_SECTION_PATTERN =
Pattern.compile("attachments\\s*\\(\\d+\\)", Pattern.CASE_INSENSITIVE);
private static final Pattern FILENAME_PATTERN =
Pattern.compile("@\\s*([^\\s\\(]+(?:\\.[a-zA-Z0-9]+)?)");
public AttachmentMarkerPositionFinder() {
super();
this.currentPageIndex = 0;
this.sortByPosition = false; // Disable sorting to preserve document order
this.isInAttachmentSection = false;
this.attachmentSectionFound = false;
}
@Override
public String getText(PDDocument document) throws IOException {
super.getText(document);
if (sortByPosition) {
positions.sort(
(a, b) -> {
int pageCompare = Integer.compare(a.getPageIndex(), b.getPageIndex());
if (pageCompare != 0) return pageCompare;
return Float.compare(
b.getY(), a.getY()); // Descending Y per PDF coordinate system
});
}
return ""; // Return empty string as we only need positions
}
@Override
protected void startPage(PDPage page) throws IOException {
super.startPage(page);
}
@Override
protected void endPage(PDPage page) throws IOException {
currentPageIndex++;
super.endPage(page);
}
@Override
protected void writeString(String string, List<TextPosition> textPositions)
throws IOException {
String lowerString = string.toLowerCase();
if (ATTACHMENT_SECTION_PATTERN.matcher(lowerString).find()) {
isInAttachmentSection = true;
attachmentSectionFound = true;
}
if (isInAttachmentSection
&& (lowerString.contains("</body>")
|| lowerString.contains("</html>")
|| (attachmentSectionFound
&& lowerString.trim().isEmpty()
&& string.length() > 50))) {
isInAttachmentSection = false;
}
if (isInAttachmentSection) {
currentText.append(string);
for (int i = 0; (i = string.indexOf(ATTACHMENT_MARKER, i)) != -1; i++) {
if (i < textPositions.size()) {
TextPosition textPosition = textPositions.get(i);
String filename = extractFilenameAfterMarker(string, i);
MarkerPosition position =
new MarkerPosition(
currentPageIndex,
textPosition.getXDirAdj(),
textPosition.getYDirAdj(),
ATTACHMENT_MARKER,
filename);
positions.add(position);
}
}
}
super.writeString(string, textPositions);
}
@Override
public void setSortByPosition(boolean sortByPosition) {
this.sortByPosition = sortByPosition;
}
private String extractFilenameAfterMarker(String text, int markerIndex) {
String afterMarker = text.substring(markerIndex + 1);
Matcher matcher = FILENAME_PATTERN.matcher("@" + afterMarker);
if (matcher.find()) {
return matcher.group(1);
}
String[] parts = afterMarker.split("[\\s\\(\\)]+");
for (String part : parts) {
part = part.trim();
if (part.length() > 3 && part.contains(".")) {
return part;
}
}
return null;
}
}
private static Map<Integer, String> addAttachmentsToDocumentWithMapping(
PDDocument document,
List<MultipartFile> attachments,
List<EmlParser.EmailAttachment> originalAttachments)
throws IOException {
PDDocumentCatalog catalog = document.getDocumentCatalog();
if (catalog == null) {
throw new IOException("PDF document catalog is not accessible");
}
PDDocumentNameDictionary documentNames = catalog.getNames();
if (documentNames == null) {
documentNames = new PDDocumentNameDictionary(catalog);
catalog.setNames(documentNames);
}
PDEmbeddedFilesNameTreeNode embeddedFilesTree = documentNames.getEmbeddedFiles();
if (embeddedFilesTree == null) {
embeddedFilesTree = new PDEmbeddedFilesNameTreeNode();
documentNames.setEmbeddedFiles(embeddedFilesTree);
}
Map<String, PDComplexFileSpecification> existingNames = embeddedFilesTree.getNames();
if (existingNames == null) {
existingNames = new HashMap<>();
}
Map<Integer, String> indexToFilenameMap = new HashMap<>();
for (int i = 0; i < attachments.size(); i++) {
MultipartFile attachment = attachments.get(i);
String filename = attachment.getOriginalFilename();
if (filename == null || filename.trim().isEmpty()) {
filename = "attachment_" + i;
}
String normalizedFilename =
isAscii(filename)
? filename
: java.text.Normalizer.normalize(
filename, java.text.Normalizer.Form.NFC);
String uniqueFilename =
ensureUniqueFilename(normalizedFilename, existingNames.keySet());
indexToFilenameMap.put(i, uniqueFilename);
PDEmbeddedFile embeddedFile = new PDEmbeddedFile(document, attachment.getInputStream());
embeddedFile.setSize((int) attachment.getSize());
GregorianCalendar currentTime = new GregorianCalendar();
embeddedFile.setCreationDate(currentTime);
embeddedFile.setModDate(currentTime);
String contentType = attachment.getContentType();
if (contentType != null && !contentType.trim().isEmpty()) {
embeddedFile.setSubtype(contentType);
}
PDComplexFileSpecification fileSpecification = new PDComplexFileSpecification();
fileSpecification.setFile(uniqueFilename);
fileSpecification.setFileUnicode(uniqueFilename);
fileSpecification.setEmbeddedFile(embeddedFile);
fileSpecification.setEmbeddedFileUnicode(embeddedFile);
existingNames.put(uniqueFilename, fileSpecification);
}
embeddedFilesTree.setNames(existingNames);
documentNames.setEmbeddedFiles(embeddedFilesTree);
catalog.setNames(documentNames);
return indexToFilenameMap;
}
private static void addAttachmentAnnotationsToDocumentWithMapping(
PDDocument document,
List<EmlParser.EmailAttachment> attachments,
Map<Integer, String> indexToFilenameMap)
throws IOException {
if (document.getNumberOfPages() == 0 || attachments == null || attachments.isEmpty()) {
return;
}
AttachmentMarkerPositionFinder finder = new AttachmentMarkerPositionFinder();
finder.setSortByPosition(false); // Keep document order to maintain pairing
finder.getText(document);
List<MarkerPosition> markerPositions = finder.getPositions();
int annotationsToAdd = Math.min(markerPositions.size(), attachments.size());
for (int i = 0; i < annotationsToAdd; i++) {
MarkerPosition position = markerPositions.get(i);
String filenameNearMarker = position.getFilename();
EmlParser.EmailAttachment matchingAttachment =
findAttachmentByFilename(attachments, filenameNearMarker);
if (matchingAttachment != null) {
String embeddedFilename =
findEmbeddedFilenameForAttachment(matchingAttachment, indexToFilenameMap);
if (embeddedFilename != null) {
PDPage page = document.getPage(position.getPageIndex());
addAttachmentAnnotationToPageWithMapping(
document,
page,
matchingAttachment,
embeddedFilename,
position.getX(),
position.getY(),
i);
} else {
// No embedded filename found for attachment
}
} else {
// No matching attachment found for filename near marker
}
}
}
private static EmlParser.EmailAttachment findAttachmentByFilename(
List<EmlParser.EmailAttachment> attachments, String targetFilename) {
if (targetFilename == null || targetFilename.trim().isEmpty()) {
return null;
}
String normalizedTarget = normalizeFilename(targetFilename);
// First try exact match
for (EmlParser.EmailAttachment attachment : attachments) {
if (attachment.getFilename() != null) {
String normalizedAttachment = normalizeFilename(attachment.getFilename());
if (normalizedAttachment.equals(normalizedTarget)) {
return attachment;
}
}
}
// Then try contains match
for (EmlParser.EmailAttachment attachment : attachments) {
if (attachment.getFilename() != null) {
String normalizedAttachment = normalizeFilename(attachment.getFilename());
if (normalizedAttachment.contains(normalizedTarget)
|| normalizedTarget.contains(normalizedAttachment)) {
return attachment;
}
}
}
return null;
}
private static String findEmbeddedFilenameForAttachment(
EmlParser.EmailAttachment attachment, Map<Integer, String> indexToFilenameMap) {
String attachmentFilename = attachment.getFilename();
if (attachmentFilename == null) {
return null;
}
for (Map.Entry<Integer, String> entry : indexToFilenameMap.entrySet()) {
String embeddedFilename = entry.getValue();
if (embeddedFilename != null
&& (embeddedFilename.equals(attachmentFilename)
|| embeddedFilename.contains(attachmentFilename)
|| attachmentFilename.contains(embeddedFilename))) {
return embeddedFilename;
}
}
return null;
}
private static String normalizeFilename(String filename) {
if (filename == null) return "";
return filename.toLowerCase()
.trim()
.replaceAll("\\s+", " ")
.replaceAll("[^a-zA-Z0-9._-]", "");
}
private static void addAttachmentAnnotationToPageWithMapping(
PDDocument document,
PDPage page,
EmlParser.EmailAttachment attachment,
String embeddedFilename,
float x,
float y,
int attachmentIndex)
throws IOException {
PDAnnotationFileAttachment fileAnnotation = new PDAnnotationFileAttachment();
PDRectangle rect = calculateAnnotationRectangle(page, x, y);
fileAnnotation.setRectangle(rect);
fileAnnotation.setPrinted(false);
fileAnnotation.setHidden(false);
fileAnnotation.setNoView(false);
fileAnnotation.setNoZoom(true);
fileAnnotation.setNoRotate(true);
try {
PDAppearanceDictionary appearance = new PDAppearanceDictionary();
PDAppearanceStream normalAppearance = new PDAppearanceStream(document);
normalAppearance.setBBox(new PDRectangle(0, 0, rect.getWidth(), rect.getHeight()));
appearance.setNormalAppearance(normalAppearance);
fileAnnotation.setAppearance(appearance);
} catch (RuntimeException e) {
fileAnnotation.setAppearance(null);
}
PDEmbeddedFilesNameTreeNode efTree =
document.getDocumentCatalog().getNames().getEmbeddedFiles();
if (efTree != null) {
Map<String, PDComplexFileSpecification> efMap = efTree.getNames();
if (efMap != null) {
PDComplexFileSpecification fileSpec = efMap.get(embeddedFilename);
if (fileSpec != null) {
fileAnnotation.setFile(fileSpec);
} else {
// Could not find embedded file
}
}
}
fileAnnotation.setContents(
"Attachment " + (attachmentIndex + 1) + ": " + attachment.getFilename());
fileAnnotation.setAnnotationName(
"EmbeddedFile_" + attachmentIndex + "_" + embeddedFilename);
page.getAnnotations().add(fileAnnotation);
}
private static boolean isAscii(String str) {
if (str == null) return true;
for (int i = 0; i < str.length(); i++) {
if (str.charAt(i) > 127) {
return false;
}
}
return true;
}
}
@@ -1,55 +0,0 @@
package stirling.software.common.util;
import java.io.IOException;
/** Utility class for detecting and handling PDF-related errors. */
public class PdfErrorUtils {
/**
* Checks if an IOException indicates a corrupted PDF file.
*
* @param e the IOException to check
* @return true if the error indicates PDF corruption, false otherwise
*/
public static boolean isCorruptedPdfError(IOException e) {
return isCorruptedPdfError(e.getMessage());
}
/**
* Checks if any Exception indicates a corrupted PDF file.
*
* @param e the Exception to check
* @return true if the error indicates PDF corruption, false otherwise
*/
public static boolean isCorruptedPdfError(Exception e) {
return isCorruptedPdfError(e.getMessage());
}
/**
* Checks if an error message indicates a corrupted PDF file.
*
* @param message the error message to check
* @return true if the message indicates PDF corruption, false otherwise
*/
private static boolean isCorruptedPdfError(String message) {
if (message == null) return false;
// Check for common corruption indicators
return message.contains("Missing root object specification")
|| message.contains("Header doesn't contain versioninfo")
|| message.contains("Expected trailer")
|| message.contains("Invalid PDF")
|| message.contains("Corrupted")
|| message.contains("damaged")
|| message.contains("Unknown dir object")
|| message.contains("Can't dereference COSObject")
|| message.contains("parseCOSString string should start with")
|| message.contains("ICCBased colorspace array must have a stream")
|| message.contains("1-based index not found")
|| message.contains("Invalid dictionary, found:")
|| message.contains("AES initialization vector not fully read")
|| message.contains("BadPaddingException")
|| message.contains("Given final block not properly padded")
|| message.contains("End-of-File, expected line");
}
}
@@ -1,82 +0,0 @@
package stirling.software.common.util;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
/**
* Utility class to access Spring managed beans from non-Spring managed classes. This is especially
* useful for classes that are instantiated by frameworks or created dynamically.
*/
@Component
@Slf4j
public class SpringContextHolder implements ApplicationContextAware {
private static ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
SpringContextHolder.applicationContext = applicationContext;
log.debug("Spring context holder initialized");
}
/**
* Get a Spring bean by class type
*
* @param <T> The bean type
* @param beanClass The bean class
* @return The bean instance, or null if not found
*/
public static <T> T getBean(Class<T> beanClass) {
if (applicationContext == null) {
log.warn(
"Application context not initialized when attempting to get bean of type {}",
beanClass.getName());
return null;
}
try {
return applicationContext.getBean(beanClass);
} catch (BeansException e) {
log.error("Error getting bean of type {}: {}", beanClass.getName(), e.getMessage());
return null;
}
}
/**
* Get a Spring bean by name
*
* @param <T> The bean type
* @param beanName The bean name
* @return The bean instance, or null if not found
*/
public static <T> T getBean(String beanName) {
if (applicationContext == null) {
log.warn(
"Application context not initialized when attempting to get bean '{}'",
beanName);
return null;
}
try {
@SuppressWarnings("unchecked")
T bean = (T) applicationContext.getBean(beanName);
return bean;
} catch (BeansException e) {
log.error("Error getting bean '{}': {}", beanName, e.getMessage());
return null;
}
}
/**
* Check if the application context is initialized
*
* @return true if initialized, false otherwise
*/
public static boolean isInitialized() {
return applicationContext != null;
}
}
@@ -1,44 +0,0 @@
package stirling.software.common.util;
import java.io.IOException;
import java.nio.file.Path;
import lombok.extern.slf4j.Slf4j;
/**
* A wrapper class for a temporary directory that implements AutoCloseable. Can be used with
* try-with-resources for automatic cleanup.
*/
@Slf4j
public class TempDirectory implements AutoCloseable {
private final TempFileManager manager;
private final Path directory;
public TempDirectory(TempFileManager manager) throws IOException {
this.manager = manager;
this.directory = manager.createTempDirectory();
}
public Path getPath() {
return directory;
}
public String getAbsolutePath() {
return directory.toAbsolutePath().toString();
}
public boolean exists() {
return java.nio.file.Files.exists(directory);
}
@Override
public void close() {
manager.deleteTempDirectory(directory);
}
@Override
public String toString() {
return "TempDirectory{" + directory.toAbsolutePath() + "}";
}
}
@@ -1,49 +0,0 @@
package stirling.software.common.util;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import lombok.extern.slf4j.Slf4j;
/**
* A wrapper class for a temporary file that implements AutoCloseable. Can be used with
* try-with-resources for automatic cleanup.
*/
@Slf4j
public class TempFile implements AutoCloseable {
private final TempFileManager manager;
private final File file;
public TempFile(TempFileManager manager, String suffix) throws IOException {
this.manager = manager;
this.file = manager.createTempFile(suffix);
}
public File getFile() {
return file;
}
public Path getPath() {
return file.toPath();
}
public String getAbsolutePath() {
return file.getAbsolutePath();
}
public boolean exists() {
return file.exists();
}
@Override
public void close() {
manager.deleteTempFile(file);
}
@Override
public String toString() {
return "TempFile{" + file.getAbsolutePath() + "}";
}
}
@@ -1,249 +0,0 @@
package stirling.software.common.util;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Set;
import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
/**
* Service for managing temporary files in Stirling-PDF. Provides methods for creating, tracking,
* and cleaning up temporary files.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TempFileManager {
private final TempFileRegistry registry;
private final ApplicationProperties applicationProperties;
/**
* Create a temporary file with the Stirling-PDF prefix. The file is automatically registered
* with the registry.
*
* @param suffix The suffix for the temporary file
* @return The created temporary file
* @throws IOException If an I/O error occurs
*/
public File createTempFile(String suffix) throws IOException {
ApplicationProperties.TempFileManagement tempFiles =
applicationProperties.getSystem().getTempFileManagement();
Path tempFilePath;
String customTempDirectory = tempFiles.getBaseTmpDir();
if (customTempDirectory != null && !customTempDirectory.isEmpty()) {
Path tempDir = Path.of(customTempDirectory);
if (!Files.exists(tempDir)) {
Files.createDirectories(tempDir);
}
tempFilePath = Files.createTempFile(tempDir, tempFiles.getPrefix(), suffix);
} else {
tempFilePath = Files.createTempFile(tempFiles.getPrefix(), suffix);
}
File tempFile = tempFilePath.toFile();
return registry.register(tempFile);
}
/**
* Create a temporary directory with the Stirling-PDF prefix. The directory is automatically
* registered with the registry.
*
* @return The created temporary directory
* @throws IOException If an I/O error occurs
*/
public Path createTempDirectory() throws IOException {
ApplicationProperties.TempFileManagement tempFiles =
applicationProperties.getSystem().getTempFileManagement();
Path tempDirPath;
String customTempDirectory = tempFiles.getBaseTmpDir();
if (customTempDirectory != null && !customTempDirectory.isEmpty()) {
Path tempDir = Path.of(customTempDirectory);
if (!Files.exists(tempDir)) {
Files.createDirectories(tempDir);
}
tempDirPath = Files.createTempDirectory(tempDir, tempFiles.getPrefix());
} else {
tempDirPath = Files.createTempDirectory(tempFiles.getPrefix());
}
return registry.registerDirectory(tempDirPath);
}
/**
* Convert a MultipartFile to a temporary File and register it. This is a wrapper around
* GeneralUtils.convertMultipartFileToFile that ensures the created temp file is registered.
*
* @param multipartFile The MultipartFile to convert
* @return The created temporary file
* @throws IOException If an I/O error occurs
*/
public File convertMultipartFileToFile(MultipartFile multipartFile) throws IOException {
File tempFile = GeneralUtils.convertMultipartFileToFile(multipartFile);
return registry.register(tempFile);
}
/**
* Delete a temporary file and unregister it from the registry.
*
* @param file The file to delete
* @return true if the file was deleted successfully, false otherwise
*/
public boolean deleteTempFile(File file) {
if (file != null && file.exists()) {
boolean deleted = file.delete();
if (deleted) {
registry.unregister(file);
log.debug("Deleted temp file: {}", file.getAbsolutePath());
} else {
log.warn("Failed to delete temp file: {}", file.getAbsolutePath());
}
return deleted;
}
return false;
}
/**
* Delete a temporary file and unregister it from the registry.
*
* @param path The path to delete
* @return true if the file was deleted successfully, false otherwise
*/
public boolean deleteTempFile(Path path) {
if (path != null) {
try {
boolean deleted = Files.deleteIfExists(path);
if (deleted) {
registry.unregister(path);
log.debug("Deleted temp file: {}", path.toString());
} else {
log.debug("Temp file already deleted or does not exist: {}", path.toString());
}
return deleted;
} catch (IOException e) {
log.warn("Failed to delete temp file: {}", path.toString(), e);
return false;
}
}
return false;
}
/**
* Delete a temporary directory and all its contents.
*
* @param directory The directory to delete
*/
public void deleteTempDirectory(Path directory) {
if (directory != null && Files.isDirectory(directory)) {
try {
GeneralUtils.deleteDirectory(directory);
log.debug("Deleted temp directory: {}", directory.toString());
} catch (IOException e) {
log.warn("Failed to delete temp directory: {}", directory.toString(), e);
}
}
}
/**
* Register an existing file with the registry.
*
* @param file The file to register
* @return The same file for method chaining
*/
public File register(File file) {
if (file != null && file.exists()) {
return registry.register(file);
}
return file;
}
/**
* Clean up old temporary files based on age.
*
* @param maxAgeMillis Maximum age in milliseconds for temp files
* @return Number of files deleted
*/
public int cleanupOldTempFiles(long maxAgeMillis) {
int deletedCount = 0;
// Get files older than max age
Set<Path> oldFiles = registry.getFilesOlderThan(maxAgeMillis);
// Delete each old file
for (Path file : oldFiles) {
if (deleteTempFile(file)) {
deletedCount++;
}
}
log.info("Cleaned up {} old temporary files", deletedCount);
return deletedCount;
}
/**
* Get the maximum age for temporary files in milliseconds.
*
* @return Maximum age in milliseconds
*/
public long getMaxAgeMillis() {
long maxAgeHours =
applicationProperties.getSystem().getTempFileManagement().getMaxAgeHours();
return Duration.ofHours(maxAgeHours).toMillis();
}
/**
* Generate a unique temporary file name with the Stirling-PDF prefix.
*
* @param type Type identifier for the temp file
* @param extension File extension (without the dot)
* @return A unique temporary file name
*/
public String generateTempFileName(String type, String extension) {
String tempFilePrefix =
applicationProperties.getSystem().getTempFileManagement().getPrefix();
String uuid = UUID.randomUUID().toString().substring(0, 8);
return tempFilePrefix + type + "-" + uuid + "." + extension;
}
/**
* Register a known LibreOffice temporary directory. This is used when integrating with
* LibreOffice for file conversions.
*
* @return The LibreOffice temp directory
* @throws IOException If directory creation fails
*/
public Path registerLibreOfficeTempDir() throws IOException {
ApplicationProperties.TempFileManagement tempFiles =
applicationProperties.getSystem().getTempFileManagement();
Path loTempDir;
String libreOfficeTempDir = tempFiles.getLibreofficeDir();
String customTempDirectory = tempFiles.getBaseTmpDir();
// First check if explicitly configured
if (libreOfficeTempDir != null && !libreOfficeTempDir.isEmpty()) {
loTempDir = Path.of(libreOfficeTempDir);
}
// Next check if we have a custom temp directory
else if (customTempDirectory != null && !customTempDirectory.isEmpty()) {
loTempDir = Path.of(customTempDirectory, "libreoffice");
}
// Fall back to system temp dir with our application prefix
else {
loTempDir = Path.of(System.getProperty("java.io.tmpdir"), "stirling-pdf-libreoffice");
}
if (!Files.exists(loTempDir)) {
Files.createDirectories(loTempDir);
}
return registry.registerDirectory(loTempDir);
}
}
@@ -1,174 +0,0 @@
package stirling.software.common.util;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.stream.Collectors;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
/**
* Central registry for tracking temporary files created by Stirling-PDF. Maintains a thread-safe
* collection of paths with their creation timestamps.
*/
@Slf4j
@Component
public class TempFileRegistry {
private final ConcurrentMap<Path, Instant> registeredFiles = new ConcurrentHashMap<>();
private final Set<Path> thirdPartyTempFiles =
Collections.newSetFromMap(new ConcurrentHashMap<>());
private final Set<Path> tempDirectories = Collections.newSetFromMap(new ConcurrentHashMap<>());
/**
* Register a temporary file with the registry.
*
* @param file The temporary file to track
* @return The same file for method chaining
*/
public File register(File file) {
if (file != null) {
registeredFiles.put(file.toPath(), Instant.now());
log.debug("Registered temp file: {}", file.getAbsolutePath());
}
return file;
}
/**
* Register a temporary path with the registry.
*
* @param path The temporary path to track
* @return The same path for method chaining
*/
public Path register(Path path) {
if (path != null) {
registeredFiles.put(path, Instant.now());
log.debug("Registered temp path: {}", path.toString());
}
return path;
}
/**
* Register a temporary directory to be cleaned up.
*
* @param directory Directory to register
* @return The same directory for method chaining
*/
public Path registerDirectory(Path directory) {
if (directory != null && Files.isDirectory(directory)) {
tempDirectories.add(directory);
log.debug("Registered temp directory: {}", directory.toString());
}
return directory;
}
/**
* Register a third-party temporary file that requires special handling.
*
* @param file The third-party temp file
* @return The same file for method chaining
*/
public File registerThirdParty(File file) {
if (file != null) {
thirdPartyTempFiles.add(file.toPath());
log.debug("Registered third-party temp file: {}", file.getAbsolutePath());
}
return file;
}
/**
* Unregister a file from the registry.
*
* @param file The file to unregister
*/
public void unregister(File file) {
if (file != null) {
registeredFiles.remove(file.toPath());
thirdPartyTempFiles.remove(file.toPath());
log.debug("Unregistered temp file: {}", file.getAbsolutePath());
}
}
/**
* Unregister a path from the registry.
*
* @param path The path to unregister
*/
public void unregister(Path path) {
if (path != null) {
registeredFiles.remove(path);
thirdPartyTempFiles.remove(path);
log.debug("Unregistered temp path: {}", path.toString());
}
}
/**
* Get all registered temporary files.
*
* @return Set of registered file paths
*/
public Set<Path> getAllRegisteredFiles() {
return registeredFiles.keySet();
}
/**
* Get temporary files older than the specified duration in milliseconds.
*
* @param maxAgeMillis Maximum age in milliseconds
* @return Set of paths older than the specified age
*/
public Set<Path> getFilesOlderThan(long maxAgeMillis) {
Instant cutoffTime = Instant.now().minusMillis(maxAgeMillis);
return registeredFiles.entrySet().stream()
.filter(entry -> entry.getValue().isBefore(cutoffTime))
.map(Map.Entry::getKey)
.collect(Collectors.toSet());
}
/**
* Get all registered third-party temporary files.
*
* @return Set of third-party file paths
*/
public Set<Path> getThirdPartyTempFiles() {
return thirdPartyTempFiles;
}
/**
* Get all registered temporary directories.
*
* @return Set of temporary directory paths
*/
public Set<Path> getTempDirectories() {
return tempDirectories;
}
/**
* Check if a file is registered in the registry.
*
* @param file The file to check
* @return True if the file is registered, false otherwise
*/
public boolean contains(File file) {
if (file == null) {
return false;
}
Path path = file.toPath();
return registeredFiles.containsKey(path) || thirdPartyTempFiles.contains(path);
}
/** Clear all registry data. */
public void clear() {
registeredFiles.clear();
thirdPartyTempFiles.clear();
tempDirectories.clear();
}
}
@@ -1,135 +0,0 @@
package stirling.software.common.util;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import lombok.extern.slf4j.Slf4j;
/**
* Utility class for handling temporary files with proper cleanup. Provides helper methods and
* wrappers to ensure temp files are properly cleaned up.
*/
@Slf4j
public class TempFileUtil {
/**
* A collection of temporary files that implements AutoCloseable. All files in the collection
* are cleaned up when close() is called.
*/
public static class TempFileCollection implements AutoCloseable {
private final TempFileManager manager;
private final List<File> tempFiles = new ArrayList<>();
public TempFileCollection(TempFileManager manager) {
this.manager = manager;
}
public File addTempFile(String suffix) throws IOException {
File file = manager.createTempFile(suffix);
tempFiles.add(file);
return file;
}
public List<File> getFiles() {
return new ArrayList<>(tempFiles);
}
@Override
public void close() {
for (File file : tempFiles) {
manager.deleteTempFile(file);
}
}
}
/**
* Execute a function with a temporary file, ensuring cleanup in a finally block.
*
* @param <R> The return type of the function
* @param tempFileManager The temp file manager
* @param suffix File suffix (e.g., ".pdf")
* @param function The function to execute with the temp file
* @return The result of the function
* @throws IOException If an I/O error occurs
*/
public static <R> R withTempFile(
TempFileManager tempFileManager, String suffix, Function<File, R> function)
throws IOException {
File tempFile = tempFileManager.createTempFile(suffix);
try {
return function.apply(tempFile);
} finally {
tempFileManager.deleteTempFile(tempFile);
}
}
/**
* Execute a function with multiple temporary files, ensuring cleanup in a finally block.
*
* @param <R> The return type of the function
* @param tempFileManager The temp file manager
* @param count Number of temp files to create
* @param suffix File suffix (e.g., ".pdf")
* @param function The function to execute with the temp files
* @return The result of the function
* @throws IOException If an I/O error occurs
*/
public static <R> R withMultipleTempFiles(
TempFileManager tempFileManager,
int count,
String suffix,
Function<List<File>, R> function)
throws IOException {
List<File> tempFiles = new ArrayList<>(count);
try {
for (int i = 0; i < count; i++) {
tempFiles.add(tempFileManager.createTempFile(suffix));
}
return function.apply(tempFiles);
} finally {
for (File file : tempFiles) {
tempFileManager.deleteTempFile(file);
}
}
}
/**
* Safely delete a list of temporary files, logging any errors.
*
* @param files The list of files to delete
*/
public static void safeDeleteFiles(List<Path> files) {
if (files == null) return;
for (Path file : files) {
if (file == null) continue;
try {
Files.deleteIfExists(file);
log.debug("Deleted temp file: {}", file);
} catch (IOException e) {
log.warn("Failed to delete temp file: {}", file, e);
}
}
}
/**
* Register an already created temp file with the registry. Use this for files created outside
* of TempFileManager.
*
* @param tempFileManager The temp file manager
* @param file The file to register
* @return The registered file
*/
public static File registerExistingTempFile(TempFileManager tempFileManager, File file) {
if (tempFileManager != null && file != null && file.exists()) {
return tempFileManager.register(file);
}
return file;
}
}
@@ -1,14 +0,0 @@
package stirling.software.common.util;
import java.util.Collection;
public class ValidationUtil {
public static boolean isStringEmpty(String input) {
return input == null || input.isBlank();
}
public static boolean isCollectionEmpty(Collection<String> input) {
return input == null || input.isEmpty();
}
}
@@ -1,14 +0,0 @@
package stirling.software.common.util;
import java.util.Collection;
public class ValidationUtils {
public static boolean isStringEmpty(String input) {
return input == null || input.isBlank();
}
public static boolean isCollectionEmpty(Collection<String> input) {
return input == null || input.isEmpty();
}
}
@@ -1,188 +0,0 @@
package stirling.software.common.annotations;
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.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
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 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.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.common.aop.AutoJobAspect;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.FileOrUploadService;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.JobExecutorService;
import stirling.software.common.service.JobQueue;
import stirling.software.common.service.ResourceMonitor;
@ExtendWith(MockitoExtension.class)
class AutoJobPostMappingIntegrationTest {
private AutoJobAspect autoJobAspect;
@Mock private JobExecutorService jobExecutorService;
@Mock private HttpServletRequest request;
@Mock private FileOrUploadService fileOrUploadService;
@Mock private FileStorage fileStorage;
@Mock private ResourceMonitor resourceMonitor;
@Mock private JobQueue jobQueue;
@BeforeEach
void setUp() {
autoJobAspect =
new AutoJobAspect(jobExecutorService, request, fileOrUploadService, fileStorage);
}
@Mock private ProceedingJoinPoint joinPoint;
@Mock private AutoJobPostMapping autoJobPostMapping;
@Captor private ArgumentCaptor<Supplier<Object>> workCaptor;
@Captor private ArgumentCaptor<Boolean> asyncCaptor;
@Captor private ArgumentCaptor<Long> timeoutCaptor;
@Captor private ArgumentCaptor<Boolean> queueableCaptor;
@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};
when(joinPoint.getArgs()).thenReturn(args);
when(request.getParameter("async")).thenReturn("true");
when(autoJobPostMapping.timeout()).thenReturn(60000L);
when(autoJobPostMapping.retryCount()).thenReturn(3);
when(autoJobPostMapping.trackProgress()).thenReturn(true);
when(autoJobPostMapping.queueable()).thenReturn(true);
when(autoJobPostMapping.resourceWeight()).thenReturn(75);
MultipartFile mockFile = mock(MultipartFile.class);
when(fileStorage.retrieveFile("test-file-id")).thenReturn(mockFile);
when(jobExecutorService.runJobGeneric(
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
.thenReturn(ResponseEntity.ok("success"));
// When
Object result = autoJobAspect.wrapWithJobExecution(joinPoint, autoJobPostMapping);
// Then
assertEquals(ResponseEntity.ok("success"), result);
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");
assertTrue(queueableCaptor.getValue(), "Queueable should be true");
assertEquals(75, resourceWeightCaptor.getValue(), "Resource weight should be 75");
// Test that file was resolved
assertNotNull(pdfFile.getFileInput(), "File input should be set");
}
@Test
void shouldRetryOnError() throws Throwable {
// Given
when(joinPoint.getArgs()).thenReturn(new Object[0]);
when(request.getParameter("async")).thenReturn("false");
when(autoJobPostMapping.timeout()).thenReturn(-1L);
when(autoJobPostMapping.retryCount()).thenReturn(2);
when(autoJobPostMapping.trackProgress()).thenReturn(false);
when(autoJobPostMapping.queueable()).thenReturn(false);
when(autoJobPostMapping.resourceWeight()).thenReturn(50);
// First call throws exception, second succeeds
when(joinPoint.proceed(any()))
.thenThrow(new RuntimeException("First attempt failed"))
.thenReturn(ResponseEntity.ok("retry succeeded"));
// 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();
});
// When
Object result = autoJobAspect.wrapWithJobExecution(joinPoint, autoJobPostMapping);
// Then
assertEquals(ResponseEntity.ok("retry succeeded"), result);
// Verify that proceed was called twice (initial attempt + 1 retry)
verify(joinPoint, times(2)).proceed(any());
}
@Test
void shouldHandlePDFFileWithAsyncRequests() throws Throwable {
// Given
PDFFile pdfFile = new PDFFile();
pdfFile.setFileInput(mock(MultipartFile.class));
Object[] args = new Object[] {pdfFile};
when(joinPoint.getArgs()).thenReturn(args);
when(request.getParameter("async")).thenReturn("true");
when(autoJobPostMapping.retryCount()).thenReturn(1);
when(fileStorage.storeFile(any(MultipartFile.class))).thenReturn("stored-file-id");
when(fileStorage.retrieveFile("stored-file-id")).thenReturn(mock(MultipartFile.class));
// Mock job executor to return a successful response
when(jobExecutorService.runJobGeneric(
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
.thenReturn(ResponseEntity.ok("success"));
// When
autoJobAspect.wrapWithJobExecution(joinPoint, autoJobPostMapping);
// Then
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");
// Verify storage operations
verify(fileStorage).storeFile(any(MultipartFile.class));
verify(fileStorage).retrieveFile("stored-file-id");
}
}
@@ -1,59 +0,0 @@
package stirling.software.common.model;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.StandardEnvironment;
import stirling.software.common.configuration.InstallationPathConfig;
class ApplicationPropertiesDynamicYamlPropertySourceTest {
@Test
void loads_yaml_into_environment() throws Exception {
// YAML-Config in Temp-Datei schreiben
String yaml =
""
+ "ui:\n"
+ " appName: \"My App\"\n"
+ "system:\n"
+ " enableAnalytics: true\n";
Path tmp = Files.createTempFile("spdf-settings-", ".yml");
Files.writeString(tmp, yaml);
// Pfad per statischem Mock liefern
try (MockedStatic<InstallationPathConfig> mocked =
Mockito.mockStatic(InstallationPathConfig.class)) {
mocked.when(InstallationPathConfig::getSettingsPath).thenReturn(tmp.toString());
ConfigurableEnvironment env = new StandardEnvironment();
ApplicationProperties props = new ApplicationProperties();
props.dynamicYamlPropertySource(env); // fügt PropertySource an erster Stelle ein
assertEquals("My App", env.getProperty("ui.appName"));
assertEquals("true", env.getProperty("system.enableAnalytics"));
}
}
@Test
void throws_when_settings_file_missing() throws Exception {
String missing = "/path/does/not/exist/spdf.yml";
try (MockedStatic<InstallationPathConfig> mocked =
Mockito.mockStatic(InstallationPathConfig.class)) {
mocked.when(InstallationPathConfig::getSettingsPath).thenReturn(missing);
ConfigurableEnvironment env = new StandardEnvironment();
ApplicationProperties props = new ApplicationProperties();
assertThrows(IOException.class, () -> props.dynamicYamlPropertySource(env));
}
}
}
@@ -1,248 +0,0 @@
package stirling.software.common.model;
import static org.junit.jupiter.api.Assertions.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties.Driver;
import stirling.software.common.model.ApplicationProperties.Premium;
import stirling.software.common.model.ApplicationProperties.Security;
import stirling.software.common.model.exception.UnsupportedProviderException;
class ApplicationPropertiesLogicTest {
@Test
void system_isAnalyticsEnabled_null_false_true() {
ApplicationProperties.System sys = new ApplicationProperties.System();
sys.setEnableAnalytics(null);
assertFalse(sys.isAnalyticsEnabled());
sys.setEnableAnalytics(Boolean.FALSE);
assertFalse(sys.isAnalyticsEnabled());
sys.setEnableAnalytics(Boolean.TRUE);
assertTrue(sys.isAnalyticsEnabled());
}
@Test
void tempFileManagement_defaults_and_overrides() {
ApplicationProperties.TempFileManagement tfm =
new ApplicationProperties.TempFileManagement();
String expectedBase =
java.lang.System.getProperty("java.io.tmpdir").replaceAll("/+$", "")
+ "/stirling-pdf";
assertEquals(expectedBase, tfm.getBaseTmpDir());
String expectedLibre = expectedBase + "/libreoffice";
assertEquals(expectedLibre, tfm.getLibreofficeDir());
tfm.setBaseTmpDir("/custom/base");
assertEquals("/custom/base", tfm.getBaseTmpDir());
tfm.setLibreofficeDir("/opt/libre");
assertEquals("/opt/libre", tfm.getLibreofficeDir());
}
@Test
void oauth2_scope_parsing_and_validity() {
Security.OAUTH2 oauth2 = new Security.OAUTH2();
oauth2.setIssuer("https://issuer");
oauth2.setClientId("client");
oauth2.setClientSecret("secret");
oauth2.setUseAsUsername("email");
oauth2.setScopes("openid, profile ,email");
assertTrue(oauth2.isSettingsValid());
}
@Test
void security_login_method_flags() {
Security sec = new Security();
sec.getOauth2().setEnabled(true);
sec.getSaml2().setEnabled(true);
assertTrue(sec.isUserPass());
assertTrue(sec.isOauth2Active());
assertTrue(sec.isSaml2Active());
sec.setLoginMethod(Security.LoginMethods.NORMAL.toString());
assertTrue(sec.isUserPass());
assertFalse(sec.isOauth2Active());
assertFalse(sec.isSaml2Active());
}
@Test
void security_isAltLogin_reflects_oauth2_or_saml2() {
Security sec = new Security();
assertFalse(sec.isAltLogin());
sec.getOauth2().setEnabled(true);
sec.getSaml2().setEnabled(false);
assertTrue(sec.isAltLogin());
sec.getOauth2().setEnabled(false);
sec.getSaml2().setEnabled(true);
assertTrue(sec.isAltLogin());
sec.getOauth2().setEnabled(true);
sec.getSaml2().setEnabled(true);
assertTrue(sec.isAltLogin());
}
@Test
void oauth2_client_provider_mapping_and_unsupported() throws UnsupportedProviderException {
Security.OAUTH2.Client client = new Security.OAUTH2.Client();
assertNotNull(client.get("google"));
assertNotNull(client.get("github"));
assertNotNull(client.get("keycloak"));
UnsupportedProviderException ex =
assertThrows(UnsupportedProviderException.class, () -> client.get("unknown"));
assertTrue(ex.getMessage().toLowerCase().contains("not supported"));
}
@Test
void premium_google_drive_getters_return_empty_string_on_null_or_blank() {
Premium.ProFeatures.GoogleDrive gd = new Premium.ProFeatures.GoogleDrive();
assertEquals("", gd.getClientId());
assertEquals("", gd.getApiKey());
assertEquals("", gd.getAppId());
gd.setClientId(" id ");
gd.setApiKey(" key ");
gd.setAppId(" app ");
assertEquals(" id ", gd.getClientId());
assertEquals(" key ", gd.getApiKey());
assertEquals(" app ", gd.getAppId());
}
@Test
void ui_getters_return_null_for_blank() {
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
ui.setAppName(" ");
ui.setHomeDescription("");
ui.setAppNameNavbar(null);
assertNull(ui.getAppName());
assertNull(ui.getHomeDescription());
assertNull(ui.getAppNameNavbar());
ui.setAppName("Stirling-PDF");
ui.setHomeDescription("Home");
ui.setAppNameNavbar("Nav");
assertEquals("Stirling-PDF", ui.getAppName());
assertEquals("Home", ui.getHomeDescription());
assertEquals("Nav", ui.getAppNameNavbar());
}
@Test
void driver_toString_contains_driver_name() {
assertTrue(Driver.H2.toString().contains("h2"));
assertTrue(Driver.POSTGRESQL.toString().contains("postgresql"));
}
@Test
void session_limits_and_timeouts_have_reasonable_defaults() {
ApplicationProperties.ProcessExecutor pe = new ApplicationProperties.ProcessExecutor();
ApplicationProperties.ProcessExecutor.SessionLimit s = pe.getSessionLimit();
assertEquals(2, s.getQpdfSessionLimit());
assertEquals(1, s.getTesseractSessionLimit());
assertEquals(1, s.getLibreOfficeSessionLimit());
assertEquals(1, s.getPdfToHtmlSessionLimit());
assertEquals(8, s.getPythonOpenCvSessionLimit());
assertEquals(16, s.getWeasyPrintSessionLimit());
assertEquals(1, s.getInstallAppSessionLimit());
assertEquals(1, s.getCalibreSessionLimit());
assertEquals(8, s.getGhostscriptSessionLimit());
assertEquals(2, s.getOcrMyPdfSessionLimit());
ApplicationProperties.ProcessExecutor.TimeoutMinutes t = pe.getTimeoutMinutes();
assertEquals(30, t.getTesseractTimeoutMinutes());
assertEquals(30, t.getQpdfTimeoutMinutes());
assertEquals(30, t.getLibreOfficeTimeoutMinutes());
assertEquals(20, t.getPdfToHtmlTimeoutMinutes());
assertEquals(30, t.getPythonOpenCvTimeoutMinutes());
assertEquals(30, t.getWeasyPrintTimeoutMinutes());
assertEquals(60, t.getInstallAppTimeoutMinutes());
assertEquals(30, t.getCalibreTimeoutMinutes());
assertEquals(30, t.getGhostscriptTimeoutMinutes());
assertEquals(30, t.getOcrMyPdfTimeoutMinutes());
}
@Deprecated
@Test
void enterprise_metadata_defaults() {
ApplicationProperties.EnterpriseEdition ee = new ApplicationProperties.EnterpriseEdition();
ApplicationProperties.EnterpriseEdition.CustomMetadata eMeta = ee.getCustomMetadata();
eMeta.setCreator(" ");
eMeta.setProducer(null);
assertEquals("Stirling-PDF", eMeta.getCreator());
assertEquals("Stirling-PDF", eMeta.getProducer());
}
@Test
void premium_metadata_defaults() {
Premium.ProFeatures pf = new Premium.ProFeatures();
Premium.ProFeatures.CustomMetadata pMeta = pf.getCustomMetadata();
pMeta.setCreator("");
pMeta.setProducer("");
assertEquals("Stirling-PDF", pMeta.getCreator());
assertEquals("Stirling-PDF", pMeta.getProducer());
}
@Test
void premium_metadata_awesome() {
Premium.ProFeatures pf = new Premium.ProFeatures();
Premium.ProFeatures.CustomMetadata pMeta = pf.getCustomMetadata();
pMeta.setCreator("Awesome PDF Tool");
pMeta.setProducer("Awesome PDF Tool");
assertEquals("Awesome PDF Tool", pMeta.getCreator());
assertEquals("Awesome PDF Tool", pMeta.getProducer());
}
@Test
void string_isValid_handles_null_empty_blank_and_trimmed() {
ApplicationProperties.Security.OAUTH2 oauth2 = new ApplicationProperties.Security.OAUTH2();
assertFalse(oauth2.isValid((String) null, "issuer"));
assertFalse(oauth2.isValid("", "issuer"));
assertFalse(oauth2.isValid(" ", "issuer"));
assertTrue(oauth2.isValid("x", "issuer"));
assertTrue(oauth2.isValid(" x ", "issuer")); // trimmt intern
}
@Test
void collection_isValid_handles_null_and_empty() {
ApplicationProperties.Security.OAUTH2 oauth2 = new ApplicationProperties.Security.OAUTH2();
Collection<String> nullColl = null;
Collection<String> empty = List.of();
assertFalse(oauth2.isValid(nullColl, "scopes"));
assertFalse(oauth2.isValid(empty, "scopes"));
}
@Test
void collection_isValid_true_when_non_empty_even_if_element_is_blank() {
ApplicationProperties.Security.OAUTH2 oauth2 = new ApplicationProperties.Security.OAUTH2();
// Aktuelles Verhalten: prüft NUR !isEmpty(), nicht Inhalt
Collection<String> oneBlank = new ArrayList<>();
oneBlank.add(" ");
assertTrue(
oauth2.isValid(oneBlank, "scopes"),
"Dokumentiert aktuelles Verhalten: nicht-leere Liste gilt als gültig, auch wenn Element leer/blank ist");
}
}

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