diff --git a/.gitattributes b/.gitattributes
index c498408ab..f72c204bd 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,10 +1,10 @@
* text=auto eol=lf
# Ignore all JavaScript files in a directory
-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
+stirling-pdf/src/main/resources/static/pdfjs/* linguist-vendored
+stirling-pdf/src/main/resources/static/pdfjs/** linguist-vendored
+stirling-pdf/src/main/resources/static/pdfjs-legacy/* linguist-vendored
+stirling-pdf/src/main/resources/static/pdfjs-legacy/** linguist-vendored
+stirling-pdf/src/main/resources/static/css/bootstrap-icons.css linguist-vendored
+stirling-pdf/src/main/resources/static/css/bootstrap.min.css linguist-vendored
+stirling-pdf/src/main/resources/static/css/fonts/* linguist-vendored
diff --git a/.github/labeler-config-srvaroa.yml b/.github/labeler-config-srvaroa.yml
new file mode 100644
index 000000000..2fb9365e4
--- /dev/null
+++ b/.github/labeler-config-srvaroa.yml
@@ -0,0 +1,139 @@
+version: 1
+labels:
+
+ - label: "Bugfix"
+ title: '^fix:.*'
+
+ - label: "enhancement"
+ title: '^feat:.*'
+
+ - label: "build"
+ title: '^build:.*'
+
+ - label: "chore"
+ title: '^chore:.*'
+
+ - label: "ci"
+ title: '^ci:.*'
+
+ - label: "perf"
+ title: '^perf:.*'
+
+ - label: "refactor"
+ title: '^refactor:.*'
+
+ - label: "revert"
+ title: '^revert:.*'
+
+ - label: "style"
+ title: '^style:.*'
+
+ - label: "Documentation"
+ title: '^docs:.*'
+
+ - label: 'API'
+ title: '.*openapi.*'
+
+ - label: 'Translation'
+ files:
+ - 'stirling-pdf/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}.properties'
+ - 'scripts/ignore_translation.toml'
+ - 'stirling-pdf/src/main/resources/templates/fragments/languages.html'
+ - '.github/scripts/check_language_properties.py'
+
+ - label: 'Front End'
+ files:
+ - 'stirling-pdf/src/main/resources/templates/.*'
+ - 'proprietary/src/main/resources/templates/.*'
+ - 'stirling-pdf/src/main/resources/static/.*'
+ - 'proprietary/src/main/resources/static/.*'
+ - 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/web/.*'
+ - 'stirling-pdf/src/main/java/stirling/software/SPDF/UI/.*'
+
+ - label: 'Java'
+ files:
+ - 'common/src/main/java/.*.java'
+ - 'proprietary/src/main/java/.*.java'
+ - 'stirling-pdf/src/main/java/.*.java'
+
+ - label: 'Back End'
+ files:
+ - 'stirling-pdf/src/main/java/stirling/software/SPDF/config/.*'
+ - 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/.*'
+ - 'stirling-pdf/src/main/resources/settings.yml.template'
+ - 'stirling-pdf/src/main/resources/application.properties'
+ - 'stirling-pdf/src/main/resources/banner.txt'
+ - 'scripts/png_to_webp.py'
+ - 'split_photos.py'
+ - 'application.properties'
+
+ - label: 'Security'
+ files:
+ - '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:
+ - 'stirling-pdf/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java'
+ - 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java'
+ - 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/.*'
+ - 'stirling-pdf/src/main/java/stirling/software/SPDF/model/api/.*'
+ - 'scripts/png_to_webp.py'
+ - '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'
+
+ - label: 'Devtools'
+ files:
+ - '.devcontainer/.*'
+ - 'Dockerfile.dev'
+ - '.vscode/.*'
+ - '.editorconfig'
+ - '.pre-commit-config'
+ - '.github/workflows/pre_commit.yml'
+ - 'HowToAddNewLanguage.md'
+
+ - label: 'Test'
+ files:
+ - 'common/src/test/.*'
+ - 'proprietary/src/test/.*'
+ - 'stirling-pdf/src/test/.*'
+ - 'testing/.*'
+ - '.github/workflows/scorecards.yml'
+
+ - label: 'Github'
+ files:
+ - '.github/.*'
+
+ - label: 'Gradle'
+ files:
+ - 'gradle/.*'
+ - 'gradlew'
+ - 'gradlew.bat'
+ - 'settings.gradle'
+ - 'build.gradle'
+ - 'common/build.gradle'
+ - 'proprietary/build.gradle'
+ - 'stirling-pdf/build.gradle'
diff --git a/.github/labeler-config.yml b/.github/labeler-config.yml
index bb52c7b85..d1a340065 100644
--- a/.github/labeler-config.yml
+++ b/.github/labeler-config.yml
@@ -1,60 +1,45 @@
Translation:
- changed-files:
- - any-glob-to-any-file: 'src/main/resources/messages_*_*.properties'
+ - any-glob-to-any-file: 'stirling-pdf/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'
+ - any-glob-to-any-file: 'stirling-pdf/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/**/*'
+ - any-glob-to-any-file: 'stirling-pdf/src/main/resources/templates/**/*'
+ - any-glob-to-any-file: 'stirling-pdf/src/main/resources/static/**/*'
+ - any-glob-to-any-file: 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/web/**'
+ - any-glob-to-any-file: 'stirling-pdf/src/main/java/stirling/software/SPDF/UI/**/*'
Java:
- changed-files:
- - any-glob-to-any-file: 'src/main/java/**/*.java'
+ - any-glob-to-any-file: 'common/src/main/java/**/*.java'
+ - any-glob-to-any-file: 'proprietary/src/main/java/**/*.java'
+ - any-glob-to-any-file: 'stirling-pdf/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: 'stirling-pdf/src/main/java/stirling/software/SPDF/config/**/*'
+ - any-glob-to-any-file: 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/**/*'
+ - any-glob-to-any-file: 'stirling-pdf/src/main/resources/settings.yml.template'
+ - any-glob-to-any-file: 'stirling-pdf/src/main/resources/application.properties'
+ - any-glob-to-any-file: 'stirling-pdf/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: 'proprietary/src/main/java/stirling/software/proprietary/security/**/*'
- 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: 'stirling-pdf/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java'
+ - any-glob-to-any-file: 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java'
+ - any-glob-to-any-file: 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/**/*'
+ - any-glob-to-any-file: 'stirling-pdf/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'
@@ -88,7 +73,9 @@ Devtools:
Test:
- changed-files:
- any-glob-to-any-file: 'cucumber/**/*'
- - any-glob-to-any-file: 'src/test/**/*'
+ - any-glob-to-any-file: 'common/src/test/**/*'
+ - any-glob-to-any-file: 'proprietary/src/test/**/*'
+ - any-glob-to-any-file: 'stirling-pdf/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'
diff --git a/.github/labels.yml b/.github/labels.yml
index f4e077f0a..b7f5642e7 100644
--- a/.github/labels.yml
+++ b/.github/labels.yml
@@ -111,3 +111,67 @@
- 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"
diff --git a/.github/scripts/check_language_properties.py b/.github/scripts/check_language_properties.py
index 10e6fb650..659ff7027 100644
--- a/.github/scripts/check_language_properties.py
+++ b/.github/scripts/check_language_properties.py
@@ -196,7 +196,9 @@ 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(), "src", "main", "resources"))
+ base_dir = os.path.abspath(
+ os.path.join(os.getcwd(), "stirling-pdf", "src", "main", "resources")
+ )
for file_path in file_arr:
file_normpath = os.path.normpath(file_path)
@@ -216,10 +218,19 @@ def check_for_differences(reference_file, file_list, branch, actor):
or (
# only local windows command
not file_normpath.startswith(
- os.path.join("", "src", "main", "resources", "messages_")
+ os.path.join(
+ "", "stirling-pdf", "src", "main", "resources", "messages_"
+ )
)
and not file_normpath.startswith(
- os.path.join(os.getcwd(), "src", "main", "resources", "messages_")
+ os.path.join(
+ os.getcwd(),
+ "stirling-pdf",
+ "src",
+ "main",
+ "resources",
+ "messages_",
+ )
)
)
or not file_normpath.endswith(".properties")
@@ -317,7 +328,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/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/stirling-pdf/src/main/resources/messages_en_GB.properties)"
)
else:
report.append("## ✅ Overall Check Status: **_Success_**")
@@ -377,7 +388,12 @@ if __name__ == "__main__":
else:
file_list = glob.glob(
os.path.join(
- os.getcwd(), "src", "main", "resources", "messages_*.properties"
+ os.getcwd(),
+ "stirling-pdf",
+ "src",
+ "main",
+ "resources",
+ "messages_*.properties",
)
)
update_missing_keys(args.reference_file, file_list)
diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml
index 14566855b..874081068 100644
--- a/.github/workflows/PR-Demo-Comment-with-react.yml
+++ b/.github/workflows/PR-Demo-Comment-with-react.yml
@@ -37,11 +37,12 @@ jobs:
pr_repository: ${{ steps.get-pr-info.outputs.repository }}
pr_ref: ${{ steps.get-pr-info.outputs.ref }}
comment_id: ${{ github.event.comment.id }}
- enable_security: ${{ steps.check-security-flag.outputs.enable_security }}
-
+ 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@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -84,7 +85,7 @@ jobs:
core.setOutput('repository', repository);
core.setOutput('ref', pr.head.ref);
-
+
- name: Check for security/login flag
id: check-security-flag
env:
@@ -92,10 +93,29 @@ jobs:
run: |
if [[ "$COMMENT_BODY" == *"security"* ]] || [[ "$COMMENT_BODY" == *"login"* ]]; then
echo "Security flags detected in comment"
- echo "enable_security=true" >> $GITHUB_OUTPUT
+ echo "disable_security=false" >> $GITHUB_OUTPUT
else
echo "No security flags detected in comment"
- echo "enable_security=false" >> $GITHUB_OUTPUT
+ 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
@@ -129,7 +149,7 @@ jobs:
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -155,17 +175,17 @@ jobs:
- name: Run Gradle Command
run: |
- if [ "${{ needs.check-comment.outputs.enable_security }}" == "true" ]; then
- export DOCKER_ENABLE_SECURITY=true
+ if [ "${{ needs.check-comment.outputs.disable_security }}" == "true" ]; then
+ export DISABLE_ADDITIONAL_FEATURES=true
else
- export DOCKER_ENABLE_SECURITY=false
+ 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@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
+ uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Get version number
id: versionNumber
@@ -180,7 +200,7 @@ jobs:
password: ${{ secrets.DOCKER_HUB_API }}
- name: Build and push PR-specific image
- uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
+ uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./Dockerfile
@@ -199,16 +219,31 @@ jobs:
id: deploy
run: |
# Set security settings based on flags
- if [ "${{ needs.check-comment.outputs.enable_security }}" == "true" ]; then
- DOCKER_SECURITY="true"
+ if [ "${{ needs.check-comment.outputs.disable_security }}" == "false" ]; then
+ DISABLE_ADDITIONAL_FEATURES="false"
LOGIN_SECURITY="true"
SECURITY_STATUS="🔒 Security Enabled"
else
- DOCKER_SECURITY="false"
+ 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'
@@ -223,7 +258,7 @@ jobs:
- /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/config:/configs:rw
- /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/logs:/logs:rw
environment:
- DOCKER_ENABLE_SECURITY: "${DOCKER_SECURITY}"
+ 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 }}"
@@ -232,6 +267,9 @@ jobs:
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
@@ -250,7 +288,7 @@ jobs:
docker-compose pull
docker-compose up -d
ENDSSH
-
+
# Set output for use in PR comment
echo "security_status=${SECURITY_STATUS}" >> $GITHUB_ENV
diff --git a/.github/workflows/PR-Demo-cleanup.yml b/.github/workflows/PR-Demo-cleanup.yml
index 1962bb83d..ae17ee7c8 100644
--- a/.github/workflows/PR-Demo-cleanup.yml
+++ b/.github/workflows/PR-Demo-cleanup.yml
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
diff --git a/.github/workflows/auto-labeler.yml b/.github/workflows/auto-labeler.yml
index 5f350d2d4..5828a2556 100644
--- a/.github/workflows/auto-labeler.yml
+++ b/.github/workflows/auto-labeler.yml
@@ -13,7 +13,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
diff --git a/.github/workflows/auto-labelerV2.yml b/.github/workflows/auto-labelerV2.yml
new file mode 100644
index 000000000..dec73ddac
--- /dev/null
+++ b/.github/workflows/auto-labelerV2.yml
@@ -0,0 +1,35 @@
+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@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
+ with:
+ egress-policy: audit
+
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+
+ - name: Setup GitHub App Bot
+ id: setup-bot
+ uses: ./.github/actions/setup-bot
+ with:
+ app-id: ${{ secrets.GH_APP_ID }}
+ private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
+
+ - uses: srvaroa/labeler@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 }}"
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 1d5016ca8..365676294 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -21,10 +21,11 @@ jobs:
fail-fast: false
matrix:
jdk-version: [17, 21]
+ spring-security: [true, false]
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -37,32 +38,59 @@ jobs:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
- - name: Build with Gradle and no spring security
+ - name: Build with Gradle and spring security ${{ matrix.spring-security }}
run: ./gradlew clean build
env:
- DOCKER_ENABLE_SECURITY: false
+ DISABLE_ADDITIONAL_FEATURES: ${{ matrix.spring-security }}
- - name: Build with Gradle and with spring security
- run: ./gradlew clean build
- env:
- DOCKER_ENABLE_SECURITY: true
+ - name: Check Test Reports Exist
+ id: check-reports
+ if: always()
+ run: |
+ declare -a dirs=(
+ "stirling-pdf/build/reports/tests/"
+ "stirling-pdf/build/test-results/"
+ "common/build/reports/tests/"
+ "common/build/test-results/"
+ "proprietary/build/reports/tests/"
+ "proprietary/build/test-results/"
+ )
+ missing_reports=()
+ for dir in "${dirs[@]}"; do
+ if [ ! -d "$dir" ]; then
+ missing_reports+=("$dir")
+ fi
+ done
+ if [ ${#missing_reports[@]} -gt 0 ]; then
+ echo "ERROR: The following required test report directories are missing:"
+ printf '%s\n' "${missing_reports[@]}"
+ exit 1
+ fi
+ echo "All required test report directories are present"
- name: Upload Test Reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
- name: test-reports-jdk-${{ matrix.jdk-version }}
+ name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
path: |
- build/reports/tests/
- build/test-results/
- build/reports/problems/
+ stirling-pdf/build/reports/tests/
+ stirling-pdf/build/test-results/
+ stirling-pdf/build/reports/problems/
+ common/build/reports/tests/
+ common/build/test-results/
+ common/build/reports/problems/
+ proprietary/build/reports/tests/
+ proprietary/build/test-results/
+ proprietary/build/reports/problems/
retention-days: 3
+ if-no-files-found: warn
check-licence:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -106,7 +134,7 @@ jobs:
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -120,7 +148,7 @@ jobs:
distribution: "adopt"
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
+ uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Install Docker Compose
run: |
diff --git a/.github/workflows/check_properties.yml b/.github/workflows/check_properties.yml
index c8640ff37..c1032d00c 100644
--- a/.github/workflows/check_properties.yml
+++ b/.github/workflows/check_properties.yml
@@ -4,7 +4,7 @@ on:
pull_request_target:
types: [opened, synchronize, reopened]
paths:
- - "src/main/resources/messages_*.properties"
+ - "stirling-pdf/src/main/resources/messages_*.properties"
permissions:
contents: read # Allow read access to repository content
@@ -18,7 +18,7 @@ jobs:
pull-requests: write # Allow writing to pull requests
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -36,6 +36,7 @@ jobs:
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;
@@ -56,16 +57,30 @@ jobs:
- name: Fetch PR changed files
id: fetch-pr-changes
env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
run: |
echo "Fetching PR changed files..."
echo "Getting list of changed files from PR..."
- gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$' > changed_files.txt # Filter only matching property files
+ # 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 '^stirling-pdf/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");
@@ -100,8 +115,11 @@ jobs:
// Filter for relevant files based on the PR changes
const changedFiles = files
- .map(file => file.filename)
- .filter(file => /^src\/main\/resources\/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$/.test(file));
+ .filter(file =>
+ file.status !== "removed" &&
+ /^stirling-pdf\/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);
@@ -139,12 +157,12 @@ jobs:
// Determine reference file
let referenceFilePath;
- if (changedFiles.includes("src/main/resources/messages_en_GB.properties")) {
+ if (changedFiles.includes("stirling-pdf/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: "src/main/resources/messages_en_GB.properties",
+ path: "stirling-pdf/src/main/resources/messages_en_GB.properties",
ref: branch,
});
@@ -156,7 +174,7 @@ jobs:
const { data: fileContent } = await github.rest.repos.getContent({
owner: repoOwner,
repo: repoName,
- path: "src/main/resources/messages_en_GB.properties",
+ path: "stirling-pdf/src/main/resources/messages_en_GB.properties",
ref: "main",
});
@@ -206,6 +224,7 @@ jobs:
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('/');
diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml
index 5a662f423..4f44295f7 100644
--- a/.github/workflows/dependency-review.yml
+++ b/.github/workflows/dependency-review.yml
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
diff --git a/.github/workflows/licenses-update.yml b/.github/workflows/licenses-update.yml
index e040e5436..227948288 100644
--- a/.github/workflows/licenses-update.yml
+++ b/.github/workflows/licenses-update.yml
@@ -19,7 +19,7 @@ jobs:
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -42,7 +42,7 @@ jobs:
distribution: "adopt"
- name: Setup Gradle
- uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
+ uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
- name: Check licenses for compatibility
run: ./gradlew clean checkLicense
@@ -57,11 +57,11 @@ jobs:
- name: Move and rename license file
run: |
- mv build/reports/dependency-license/index.json src/main/resources/static/3rdPartyLicenses.json
+ mv build/reports/dependency-license/index.json stirling-pdf/src/main/resources/static/3rdPartyLicenses.json
- name: Commit changes
run: |
- git add src/main/resources/static/3rdPartyLicenses.json
+ git add stirling-pdf/src/main/resources/static/3rdPartyLicenses.json
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
- name: Create Pull Request
@@ -78,7 +78,7 @@ jobs:
title: "Update 3rd Party Licenses"
body: |
Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
- labels: licenses,github-actions
+ labels: Licenses,github-actions
draft: false
delete-branch: true
sign-commits: true
diff --git a/.github/workflows/manage-label.yml b/.github/workflows/manage-label.yml
index 73ece41ae..3f123afbd 100644
--- a/.github/workflows/manage-label.yml
+++ b/.github/workflows/manage-label.yml
@@ -15,7 +15,7 @@ jobs:
issues: write
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml
index dd8f54a9b..e2f33fae0 100644
--- a/.github/workflows/multiOSReleases.yml
+++ b/.github/workflows/multiOSReleases.yml
@@ -21,7 +21,7 @@ jobs:
versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }}
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -48,15 +48,15 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- enable_security: [true, false]
+ disable_security: [true, false]
include:
- - enable_security: true
+ - disable_security: false
file_suffix: "-with-login"
- - enable_security: false
+ - disable_security: true
file_suffix: ""
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -68,14 +68,14 @@ jobs:
java-version: "21"
distribution: "temurin"
- - uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
+ - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
gradle-version: 8.14
- - name: Generate jar (With Security=${{ matrix.enable_security }})
+ - name: Generate jar (Disable Security=${{ matrix.disable_security }})
run: ./gradlew clean createExe
env:
- DOCKER_ENABLE_SECURITY: ${{ matrix.enable_security }}
+ DISABLE_ADDITIONAL_FEATURES: ${{ matrix.disable_security }}
STIRLING_PDF_DESKTOP_UI: false
- name: Rename binaries
@@ -98,15 +98,15 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- enable_security: [true, false]
+ disable_security: [true, false]
include:
- - enable_security: true
+ - disable_security: false
file_suffix: "with-login-"
- - enable_security: false
+ - disable_security: true
file_suffix: ""
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -144,7 +144,7 @@ jobs:
contents: write
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -156,7 +156,7 @@ jobs:
java-version: "21"
distribution: "temurin"
- - uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
+ - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
gradle-version: 8.14
@@ -171,7 +171,7 @@ jobs:
- name: Build Installer
run: ./gradlew build jpackage -x test --info
env:
- DOCKER_ENABLE_SECURITY: false
+ DISABLE_ADDITIONAL_FEATURES: true
STIRLING_PDF_DESKTOP_UI: true
BROWSER_OPEN: true
@@ -234,7 +234,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -248,7 +248,7 @@ jobs:
- name: Install Cosign
if: matrix.os == 'windows-latest'
- uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
+ uses: sigstore/cosign-installer@fb28c2b6339dcd94da6e4cbcbc5e888961f6f8c3 # v3.9.0
- name: Generate key pair
if: matrix.os == 'windows-latest'
@@ -297,7 +297,7 @@ jobs:
contents: write
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -306,7 +306,7 @@ jobs:
- 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@01570a1f39cb168c169c802c3bceb9e93fb10974 # v2.1.0
+ uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
with:
tag_name: v${{ needs.read_versions.outputs.version }}
generate_release_notes: true
diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml
index 5cca4e76e..1190c49cd 100644
--- a/.github/workflows/pre_commit.yml
+++ b/.github/workflows/pre_commit.yml
@@ -16,7 +16,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml
index ab45d3a52..39f022586 100644
--- a/.github/workflows/push-docker.yml
+++ b/.github/workflows/push-docker.yml
@@ -18,7 +18,7 @@ jobs:
id-token: write
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -30,25 +30,25 @@ jobs:
java-version: "17"
distribution: "temurin"
- - uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
+ - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
gradle-version: 8.14
- name: Run Gradle Command
run: ./gradlew clean build
env:
- DOCKER_ENABLE_SECURITY: false
+ DISABLE_ADDITIONAL_FEATURES: true
STIRLING_PDF_DESKTOP_UI: false
- name: Install cosign
if: github.ref == 'refs/heads/master'
- uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
+ uses: sigstore/cosign-installer@fb28c2b6339dcd94da6e4cbcbc5e888961f6f8c3 # v3.9.0
with:
cosign-release: "v2.4.1"
- name: Set up Docker Buildx
id: buildx
- uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
+ uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Get version number
id: versionNumber
@@ -90,7 +90,7 @@ jobs:
- name: Build and push main Dockerfile
id: build-push-regular
- uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
+ uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
@@ -135,7 +135,7 @@ jobs:
- name: Build and push Dockerfile-ultra-lite
id: build-push-lite
- uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
+ uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
if: github.ref != 'refs/heads/main'
with:
context: .
@@ -166,7 +166,7 @@ jobs:
- name: Build and push main Dockerfile fat
id: build-push-fat
- uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
+ uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
if: github.ref != 'refs/heads/main'
with:
builder: ${{ steps.buildx.outputs.name }}
diff --git a/.github/workflows/releaseArtifacts.yml b/.github/workflows/releaseArtifacts.yml
index 71be7b03a..76c711734 100644
--- a/.github/workflows/releaseArtifacts.yml
+++ b/.github/workflows/releaseArtifacts.yml
@@ -13,17 +13,17 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- enable_security: [true, false]
+ disable_security: [true, false]
include:
- - enable_security: true
+ - disable_security: false
file_suffix: "-with-login"
- - enable_security: false
+ - disable_security: true
file_suffix: ""
outputs:
version: ${{ steps.versionNumber.outputs.versionNumber }}
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -35,14 +35,14 @@ jobs:
java-version: "17"
distribution: "temurin"
- - uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
+ - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
gradle-version: 8.14
- - name: Generate jar (With Security=${{ matrix.enable_security }})
+ - name: Generate jar (Disable Security=${{ matrix.disable_security }})
run: ./gradlew clean createExe
env:
- DOCKER_ENABLE_SECURITY: ${{ matrix.enable_security }}
+ DISABLE_ADDITIONAL_FEATURES: ${{ matrix.disable_security }}
STIRLING_PDF_DESKTOP_UI: false
- name: Get version number
@@ -75,15 +75,15 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- enable_security: [true, false]
+ disable_security: [true, false]
include:
- - enable_security: true
+ - disable_security: false
file_suffix: "-with-login"
- - enable_security: false
+ - disable_security: true
file_suffix: ""
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -95,7 +95,7 @@ jobs:
run: ls -R
- name: Install Cosign
- uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
+ uses: sigstore/cosign-installer@fb28c2b6339dcd94da6e4cbcbc5e888961f6f8c3 # v3.9.0
- name: Generate key pair
run: cosign generate-key-pair
@@ -153,15 +153,15 @@ jobs:
contents: write
strategy:
matrix:
- enable_security: [true, false]
+ disable_security: [true, false]
include:
- - enable_security: true
+ - disable_security: false
file_suffix: "-with-login"
- - enable_security: false
+ - disable_security: true
file_suffix: ""
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -171,7 +171,7 @@ jobs:
name: signed${{ matrix.file_suffix }}
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
- uses: softprops/action-gh-release@01570a1f39cb168c169c802c3bceb9e93fb10974 # v2.1.0
+ uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
with:
tag_name: v${{ needs.build.outputs.version }}
generate_release_notes: true
diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml
index 8c6485b7b..a79dc0ec2 100644
--- a/.github/workflows/scorecards.yml
+++ b/.github/workflows/scorecards.yml
@@ -34,7 +34,7 @@ jobs:
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -44,7 +44,7 @@ jobs:
persist-credentials: false
- name: "Run analysis"
- uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1
+ uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2
with:
results_file: results.sarif
results_format: sarif
@@ -74,6 +74,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
- uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
+ uses: github/codeql-action/upload-sarif@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0
with:
sarif_file: results.sarif
diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml
index f9ab27ecc..187e823ae 100644
--- a/.github/workflows/sonarqube.yml
+++ b/.github/workflows/sonarqube.yml
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -27,13 +27,13 @@ jobs:
fetch-depth: 0
- name: Setup Gradle
- uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
+ uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
- name: Build and analyze with Gradle
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
- DOCKER_ENABLE_SECURITY: true
+ DISABLE_ADDITIONAL_FEATURES: false
STIRLING_PDF_DESKTOP_UI: true
run: |
./gradlew clean build sonar \
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index 4000f0e6f..17d81412a 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -16,7 +16,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml
index 0e06cb1ee..6b9307887 100644
--- a/.github/workflows/swagger.yml
+++ b/.github/workflows/swagger.yml
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -26,7 +26,7 @@ jobs:
java-version: "17"
distribution: "temurin"
- - uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
+ - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
- name: Generate Swagger documentation
run: ./gradlew generateOpenApiDocs
diff --git a/.github/workflows/sync_files.yml b/.github/workflows/sync_files.yml
index 72aff82f1..f89f36b2a 100644
--- a/.github/workflows/sync_files.yml
+++ b/.github/workflows/sync_files.yml
@@ -8,8 +8,8 @@ on:
paths:
- "build.gradle"
- "README.md"
- - "src/main/resources/messages_*.properties"
- - "src/main/resources/static/3rdPartyLicenses.json"
+ - "stirling-pdf/src/main/resources/messages_*.properties"
+ - "stirling-pdf/src/main/resources/static/3rdPartyLicenses.json"
- "scripts/ignore_translation.toml"
permissions:
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -30,7 +30,7 @@ jobs:
id: setup-bot
uses: ./.github/actions/setup-bot
with:
- app-id: ${{ vars.GH_APP_ID }}
+ app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python
@@ -41,11 +41,11 @@ jobs:
- name: Sync translation property files
run: |
- python .github/scripts/check_language_properties.py --reference-file "src/main/resources/messages_en_GB.properties" --branch main
+ python .github/scripts/check_language_properties.py --reference-file "stirling-pdf/src/main/resources/messages_en_GB.properties" --branch main
- name: Commit translation files
run: |
- git add src/main/resources/messages_*.properties
+ git add stirling-pdf/src/main/resources/messages_*.properties
git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "No changes detected"
- name: Install dependencies
@@ -101,4 +101,4 @@ jobs:
sign-commits: true
add-paths: |
README.md
- src/main/resources/messages_*.properties
+ stirling-pdf/src/main/resources/messages_*.properties
diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml
index 07a23defe..d0244619d 100644
--- a/.github/workflows/testdriver.yml
+++ b/.github/workflows/testdriver.yml
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -28,10 +28,10 @@ jobs:
- name: Build with Gradle
run: ./gradlew clean build
env:
- DOCKER_ENABLE_SECURITY: false
+ DISABLE_ADDITIONAL_FEATURES: true
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
+ uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Get version number
id: versionNumber
@@ -46,7 +46,7 @@ jobs:
password: ${{ secrets.DOCKER_HUB_API }}
- name: Build and push test image
- uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
+ uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./Dockerfile
@@ -76,7 +76,7 @@ jobs:
- /stirling/test-${{ github.sha }}/config:/configs:rw
- /stirling/test-${{ github.sha }}/logs:/logs:rw
environment:
- DOCKER_ENABLE_SECURITY: "false"
+ DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false"
SYSTEM_DEFAULTLOCALE: en-GB
UI_APPNAME: "Stirling-PDF Test"
@@ -105,7 +105,7 @@ jobs:
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
@@ -134,7 +134,7 @@ jobs:
steps:
- name: Harden Runner
- uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
diff --git a/.gitignore b/.gitignore
index 90d48ccea..ca949e769 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,7 @@ local.properties
.recommenders
.classpath
.project
+*.local.json
version.properties
#### Stirling-PDF Files ###
@@ -124,6 +125,9 @@ SwaggerDoc.json
*.rar
*.db
/build
+/stirling-pdf/build
+/common/build
+/proprietary/build
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -193,4 +197,3 @@ id_ed25519.pub
# node_modules
node_modules/
-*.mjs
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index beec5eb99..b4b3841e6 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -20,7 +20,7 @@ repos:
- --skip="./.*,*.csv,*.json,*.ambr"
- --quiet-level=2
files: \.(html|css|js|py|md)$
- exclude: (.vscode|.devcontainer|src/main/resources|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
+ exclude: (.vscode|.devcontainer|stirling-pdf/src/main/resources|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
- repo: https://github.com/gitleaks/gitleaks
rev: v8.26.0
hooks:
diff --git a/src/main/java/stirling/software/SPDF/UnoconvServer.java b/.src/main/java/stirling/software/SPDF/UnoconvServer.java
similarity index 100%
rename from src/main/java/stirling/software/SPDF/UnoconvServer.java
rename to .src/main/java/stirling/software/SPDF/UnoconvServer.java
diff --git a/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java b/.src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java
similarity index 100%
rename from src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java
rename to .src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java
diff --git a/src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java b/.src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java
similarity index 100%
rename from src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java
rename to .src/main/java/stirling/software/SPDF/controller/api/misc/StampController.java
diff --git a/src/main/resources/settings.yml.template b/.src/main/resources/settings.yml.template
similarity index 100%
rename from src/main/resources/settings.yml.template
rename to .src/main/resources/settings.yml.template
diff --git a/.vscode/settings.json b/.vscode/settings.json
index e45bc4dd9..a2f0da613 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -50,8 +50,10 @@
".vscode/",
"bin/",
"common/bin/",
+ "proprietary/bin/",
"build/",
"common/build/",
+ "proprietary/build/",
"configs/",
"customFiles/",
"docs/",
@@ -66,6 +68,7 @@
".gitattributes",
".gitignore",
"common/.gitignore",
+ "proprietary/.gitignore",
".pre-commit-config.yaml",
],
// Enables signature help in Java.
@@ -83,4 +86,9 @@
"spring.initializr.defaultLanguage": "Java",
"spring.initializr.defaultGroupId": "stirling.software.SPDF",
"spring.initializr.defaultArtifactId": "SPDF",
+ "java.project.sourcePaths": [
+ "stirling-pdf/src/main/java",
+ "common/src/main/java",
+ "proprietary/src/main/java"
+ ],
}
diff --git a/DeveloperGuide.md b/DeveloperGuide.md
index 32d480f5c..d2c9ddb2a 100644
--- a/DeveloperGuide.md
+++ b/DeveloperGuide.md
@@ -55,7 +55,7 @@ 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 DOCKER_ENABLE_SECURITY=true to your system and/or IDE build/run step.
+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.
## 4. Project Structure
@@ -114,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 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
+- `docker-compose-latest.yml`: Latest version without login and security features
+- `docker-compose-latest-security.yml`: Latest version with login and security features enabled
+- `docker-compose-latest-fat-security.yml`: Fat version with login and security features enabled
These files provide pre-configured setups for different scenarios. For example, here's a snippet from `docker-compose-latest-security.yml`:
@@ -137,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:
- DOCKER_ENABLE_SECURITY: "true"
+ DISABLE_ADDITIONAL_FEATURES: "false"
SECURITY_ENABLELOGIN: "true"
PUID: 1002
PGID: 1002
@@ -170,7 +170,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
1. Set the security environment variable:
```bash
- export DOCKER_ENABLE_SECURITY=false # or true for security-enabled builds
+ export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds
```
2. Build the project with Gradle:
@@ -193,10 +193,10 @@ 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 security enabled):
+ For the fat version (with login and security features enabled):
```bash
- export DOCKER_ENABLE_SECURITY=true
+ export DISABLE_ADDITIONAL_FEATURES=false
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .
```
@@ -332,7 +332,7 @@ Thymeleaf is a server-side Java HTML template engine. It is used in Stirling-PDF
### 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 `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 `stirling-pdf/src/main/resources/templates` directory. Thymeleaf templates use a combination of HTML and special Thymeleaf attributes to dynamically generate content.
Some examples of this are:
@@ -384,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 `src/main/java/stirling/software/SPDF/controller/api` directory.
+ - Create a new Java class in the `stirling-pdf/src/main/java/stirling/software/SPDF/controller/api` directory.
- Annotate the class with `@RestController` and `@RequestMapping` to define the API endpoint.
- Ensure to add API documentation annotations like `@Tag(name = "General", description = "General APIs")` and `@Operation(summary = "Crops a PDF document", description = "This operation takes an input PDF file and crops it according to the given coordinates. Input:PDF Output:PDF Type:SISO")`.
@@ -411,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 `src/main/java/stirling/software/SPDF/service` directory.
+ - Create a new service class in the `stirling-pdf/src/main/java/stirling/software/SPDF/service` directory.
- Implement the business logic for the new feature.
```java
@@ -463,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 `src/main/resources/templates` directory.
+ - Create a new HTML file in the `stirling-pdf/src/main/resources/templates` directory.
- Use Thymeleaf attributes to dynamically generate content.
- Use `extract-page.html` as a base example for the HTML template, which is useful to ensure importing of the general layout, navbar, and footer.
@@ -507,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 `src/main/java/stirling/software/SPDF/controller/ui` directory.
+ - Create a new Java class in the `stirling-pdf/src/main/java/stirling/software/SPDF/controller/ui` directory.
- Annotate the class with `@Controller` and `@RequestMapping` to define the UI endpoint.
```java
@@ -537,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 `src/main/resources/templates/fragments/navbar.html` file.
+ - Update the `stirling-pdf/src/main/resources/templates/fragments/navbar.html` file.
```html
@@ -551,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 `src/main/resources` directory. You'll see files like:
+Find the existing `messages.properties` files in the `stirling-pdf/src/main/resources` directory. You'll see files like:
- `messages.properties` (default, usually English)
- `messages_en_GB.properties`
diff --git a/Dockerfile b/Dockerfile
index 68c50976f..cbb20111c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,12 +1,11 @@
# Main stage
-FROM alpine:3.21.3@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c
+FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
# Copy necessary files
COPY scripts /scripts
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
+COPY stirling-pdf/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
+COPY stirling-pdf/build/libs/*.jar app.jar
ARG VERSION_TAG
@@ -23,7 +22,7 @@ LABEL org.opencontainers.image.version="${VERSION_TAG}"
LABEL org.opencontainers.image.keywords="PDF, manipulation, merge, split, convert, OCR, watermark"
# Set Environment Variables
-ENV DOCKER_ENABLE_SECURITY=false \
+ENV DISABLE_ADDITIONAL_FEATURES=true \
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="" \
diff --git a/Dockerfile.fat b/Dockerfile.fat
index 6d23809a8..682fac663 100644
--- a/Dockerfile.fat
+++ b/Dockerfile.fat
@@ -5,6 +5,9 @@ COPY build.gradle .
COPY settings.gradle .
COPY gradlew .
COPY gradle gradle/
+COPY stirling-pdf/build.gradle stirling-pdf/.
+COPY common/build.gradle common/.
+COPY proprietary/build.gradle proprietary/.
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
# Set the working directory
@@ -13,24 +16,24 @@ WORKDIR /app
# Copy the entire project to the working directory
COPY . .
-# Build the application with DOCKER_ENABLE_SECURITY=false
-RUN DOCKER_ENABLE_SECURITY=true \
+# 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.21.3@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c
+FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
# Copy necessary files
COPY scripts /scripts
COPY pipeline /pipeline
-COPY src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
-COPY --from=build /app/build/libs/*.jar app.jar
+COPY stirling-pdf/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
+COPY --from=build /app/stirling-pdf/build/libs/*.jar app.jar
ARG VERSION_TAG
# Set Environment Variables
-ENV DOCKER_ENABLE_SECURITY=false \
+ENV DISABLE_ADDITIONAL_FEATURES=true \
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="" \
diff --git a/Dockerfile.ultra-lite b/Dockerfile.ultra-lite
index 0ea37f704..83cd5e9c3 100644
--- a/Dockerfile.ultra-lite
+++ b/Dockerfile.ultra-lite
@@ -1,10 +1,10 @@
# use alpine
-FROM alpine:3.21.3@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c
+FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
ARG VERSION_TAG
# Set Environment Variables
-ENV DOCKER_ENABLE_SECURITY=false \
+ENV DISABLE_ADDITIONAL_FEATURES=true \
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" \
@@ -18,7 +18,7 @@ COPY scripts/download-security-jar.sh /scripts/download-security-jar.sh
COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
COPY scripts/installFonts.sh /scripts/installFonts.sh
COPY pipeline /pipeline
-COPY build/libs/*.jar app.jar
+COPY stirling-pdf/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 && \
diff --git a/HowToAddNewLanguage.md b/HowToAddNewLanguage.md
index c1fb7c127..94caca12a 100644
--- a/HowToAddNewLanguage.md
+++ b/HowToAddNewLanguage.md
@@ -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/src/main/resources/templates/fragments/languages.html)
+- Edit the file: [languages.html](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/stirling-pdf/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/src/main/resources/messages_en_GB.properties)
+- [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/stirling-pdf/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,8 +61,16 @@ Make sure to place the entry under the correct language section. This helps main
#### Windows command
-```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
+```powershell
+python .github/scripts/check_language_properties.py --reference-file stirling-pdf\src\main\resources\messages_en_GB.properties --branch "" --files stirling-pdf\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
+python .github/scripts/check_language_properties.py --reference-file stirling-pdf\src\main\resources\messages_en_GB.properties --branch "" --check-file stirling-pdf\src\main\resources\messages_pl_PL.properties
+```
+
+#### Linux command
+
+```bash
+python3 .github/scripts/check_language_properties.py --reference-file stirling-pdf/src/main/resources/messages_en_GB.properties --branch "" --files stirling-pdf/src/main/resources/messages_pl_PL.properties
+
+python3 .github/scripts/check_language_properties.py --reference-file stirling-pdf/src/main/resources/messages_en_GB.properties --branch "" --check-file stirling-pdf/src/main/resources/messages_pl_PL.properties
```
diff --git a/LICENSE b/LICENSE
index 10633f4a3..877663171 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,13 @@
MIT License
-Copyright (c) 2024 Stirling Tools
+Copyright (c) 2025 Stirling PDF Inc.
+
+Portions of this software are licensed as follows:
+
+* All content that resides under the "proprietary/" directory of this repository,
+if that directory exists, is licensed under the license defined in "proprietary/LICENSE".
+* Content outside of the above mentioned directories or restrictions above is
+available under the MIT License as defined below.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 30335755d..1c67d5d9e 100644
--- a/README.md
+++ b/README.md
@@ -116,47 +116,47 @@ Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
-| Arabic (العربية) (ar_AR) |  |
-| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
-| Basque (Euskara) (eu_ES) |  |
-| Bulgarian (Български) (bg_BG) |  |
-| Catalan (Català) (ca_CA) |  |
-| Croatian (Hrvatski) (hr_HR) |  |
-| Czech (Česky) (cs_CZ) |  |
-| Danish (Dansk) (da_DK) |  |
-| Dutch (Nederlands) (nl_NL) |  |
+| Arabic (العربية) (ar_AR) |  |
+| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
+| Basque (Euskara) (eu_ES) |  |
+| Bulgarian (Български) (bg_BG) |  |
+| Catalan (Català) (ca_CA) |  |
+| Croatian (Hrvatski) (hr_HR) |  |
+| Czech (Česky) (cs_CZ) |  |
+| Danish (Dansk) (da_DK) |  |
+| Dutch (Nederlands) (nl_NL) |  |
| English (English) (en_GB) |  |
| English (US) (en_US) |  |
-| French (Français) (fr_FR) |  |
-| German (Deutsch) (de_DE) |  |
-| Greek (Ελληνικά) (el_GR) |  |
-| Hindi (हिंदी) (hi_IN) |  |
-| Hungarian (Magyar) (hu_HU) |  |
-| Indonesian (Bahasa Indonesia) (id_ID) |  |
-| Irish (Gaeilge) (ga_IE) |  |
-| Italian (Italiano) (it_IT) |  |
-| Japanese (日本語) (ja_JP) |  |
-| Korean (한국어) (ko_KR) |  |
-| Norwegian (Norsk) (no_NB) |  |
-| Persian (فارسی) (fa_IR) |  |
-| Polish (Polski) (pl_PL) |  |
-| Portuguese (Português) (pt_PT) |  |
-| Portuguese Brazilian (Português) (pt_BR) |  |
-| Romanian (Română) (ro_RO) |  |
-| Russian (Русский) (ru_RU) |  |
-| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
-| Simplified Chinese (简体中文) (zh_CN) |  |
-| Slovakian (Slovensky) (sk_SK) |  |
-| Slovenian (Slovenščina) (sl_SI) |  |
-| Spanish (Español) (es_ES) |  |
-| Swedish (Svenska) (sv_SE) |  |
-| Thai (ไทย) (th_TH) |  |
-| Tibetan (བོད་ཡིག་) (zh_BO) |  |
-| Traditional Chinese (繁體中文) (zh_TW) |  |
-| Turkish (Türkçe) (tr_TR) |  |
-| Ukrainian (Українська) (uk_UA) |  |
-| Vietnamese (Tiếng Việt) (vi_VN) |  |
-| Malayalam (മലയാളം) (ml_IN) |  |
+| French (Français) (fr_FR) |  |
+| German (Deutsch) (de_DE) |  |
+| Greek (Ελληνικά) (el_GR) |  |
+| Hindi (हिंदी) (hi_IN) |  |
+| Hungarian (Magyar) (hu_HU) |  |
+| Indonesian (Bahasa Indonesia) (id_ID) |  |
+| Irish (Gaeilge) (ga_IE) |  |
+| Italian (Italiano) (it_IT) |  |
+| Japanese (日本語) (ja_JP) |  |
+| Korean (한국어) (ko_KR) |  |
+| Norwegian (Norsk) (no_NB) |  |
+| Persian (فارسی) (fa_IR) |  |
+| Polish (Polski) (pl_PL) |  |
+| Portuguese (Português) (pt_PT) |  |
+| Portuguese Brazilian (Português) (pt_BR) |  |
+| Romanian (Română) (ro_RO) |  |
+| Russian (Русский) (ru_RU) |  |
+| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
+| Simplified Chinese (简体中文) (zh_CN) |  |
+| Slovakian (Slovensky) (sk_SK) |  |
+| Slovenian (Slovenščina) (sl_SI) |  |
+| Spanish (Español) (es_ES) |  |
+| Swedish (Svenska) (sv_SE) |  |
+| Thai (ไทย) (th_TH) |  |
+| Tibetan (བོད་ཡིག་) (bo_CN) |  |
+| Traditional Chinese (繁體中文) (zh_TW) |  |
+| Turkish (Türkçe) (tr_TR) |  |
+| Ukrainian (Українська) (uk_UA) |  |
+| Vietnamese (Tiếng Việt) (vi_VN) |  |
+| Malayalam (മലയാളം) (ml_IN) |  |
## Stirling PDF Enterprise
diff --git a/allowed-licenses.json b/allowed-licenses.json
index 12d82d48a..80e919439 100644
--- a/allowed-licenses.json
+++ b/allowed-licenses.json
@@ -124,10 +124,18 @@
"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"
diff --git a/build.gradle b/build.gradle
index 5e53275c8..76c96b13f 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,15 +1,15 @@
plugins {
id "java"
id "jacoco"
- id "org.springframework.boot" version "3.5.0"
id "io.spring.dependency-management" version "1.1.7"
+ id "org.springframework.boot" version "3.5.3"
id "org.springdoc.openapi-gradle-plugin" version "1.9.0"
id "io.swagger.swaggerhub" version "1.3.2"
id "edu.sc.seis.launch4j" version "3.0.6"
- id "com.diffplug.spotless" version "7.0.3"
+ id "com.diffplug.spotless" version "7.0.4"
id "com.github.jk1.dependency-license-report" version "2.9"
//id "nebula.lint" version "19.0.3"
- id("org.panteleyev.jpackageplugin") version "1.6.1"
+ id "org.panteleyev.jpackageplugin" version "1.6.1"
id "org.sonarqube" version "6.2.0.5505"
}
@@ -19,28 +19,166 @@ import java.nio.file.Files
import java.time.Year
ext {
- springBootVersion = "3.5.0"
+ springBootVersion = "3.5.3"
pdfboxVersion = "3.0.5"
imageioVersion = "3.12.0"
lombokVersion = "1.18.38"
- bouncycastleVersion = "1.80"
- springSecuritySamlVersion = "6.5.0"
+ bouncycastleVersion = "1.81"
+ springSecuritySamlVersion = "6.5.1"
openSamlVersion = "4.3.2"
+ commonmarkVersion = "0.24.0"
+ googleJavaFormatVersion = "1.27.0"
tempJrePath = null
}
-group = "stirling.software"
-version = "0.46.2"
-
-java {
- // 17 is lowest but we support and recommend 21
- sourceCompatibility = JavaVersion.VERSION_17
+jar {
+ enabled = false
+ manifest {
+ attributes "Implementation-Title": "Stirling-PDF",
+ "Implementation-Version": project.version
+ }
}
-repositories {
- mavenCentral()
- maven { url = "https://build.shibboleth.net/maven/releases" }
- maven { url = "https://maven.pkg.github.com/jcefmaven/jcefmaven" }
+bootJar {
+ enabled = false
+}
+
+sourceSets {
+ main {
+ java {
+ if (System.getenv('DOCKER_ENABLE_SECURITY') == 'false' || System.getenv('DISABLE_ADDITIONAL_FEATURES') == 'true'
+ || (project.hasProperty('DISABLE_ADDITIONAL_FEATURES')
+ && System.getProperty('DISABLE_ADDITIONAL_FEATURES') == 'true')) {
+ exclude 'stirling/software/proprietary/security/**'
+ }
+ if (System.getenv('STIRLING_PDF_DESKTOP_UI') == 'false') {
+ exclude 'stirling/software/SPDF/UI/impl/**'
+ }
+
+ }
+ }
+
+ test {
+ java {
+ if (System.getenv('DOCKER_ENABLE_SECURITY') == 'false' || System.getenv('DISABLE_ADDITIONAL_FEATURES') == 'true'
+ || (project.hasProperty('DISABLE_ADDITIONAL_FEATURES')
+ && System.getProperty('DISABLE_ADDITIONAL_FEATURES') == 'true')) {
+ exclude 'stirling/software/proprietary/security/**'
+ }
+
+ if (System.getenv('STIRLING_PDF_DESKTOP_UI') == 'false') {
+ exclude 'stirling/software/SPDF/UI/impl/**'
+ }
+ }
+ }
+}
+
+allprojects {
+ group = 'stirling.software'
+ version = '1.0.0'
+
+ configurations.configureEach {
+ exclude group: 'commons-logging', module: 'commons-logging'
+ exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
+ }
+}
+
+
+tasks.register('writeVersion') {
+ def propsFile = file("$projectDir/common/src/main/resources/version.properties")
+ def propsDir = propsFile.parentFile
+
+ doLast {
+ if (propsDir.exists()) {
+ if (propsFile.exists()) {
+ println "File exists: $propsFile"
+ } else {
+ println "$propsFile does not exist. Creating file."
+ propsFile.createNewFile()
+ }
+ } else {
+ println "Creating directory: $propsDir"
+ propsDir.mkdirs()
+ propsFile.createNewFile()
+ }
+
+ def props = new Properties()
+ props.setProperty("version", version)
+ props.store(propsFile.newWriter(), null)
+ }
+}
+
+subprojects {
+ apply plugin: 'java'
+ apply plugin: 'java-library'
+ apply plugin: 'com.diffplug.spotless'
+ apply plugin: 'org.springframework.boot'
+ apply plugin: 'io.spring.dependency-management'
+
+ java {
+ // 17 is lowest but we support and recommend 21
+ sourceCompatibility = JavaVersion.VERSION_17
+ }
+
+ bootJar {
+ enabled = false
+ }
+
+ repositories {
+ mavenCentral()
+ }
+
+ configurations.configureEach {
+ exclude group: 'commons-logging', module: 'commons-logging'
+ exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat'
+ // Exclude vulnerable BouncyCastle version used in tableau
+ exclude group: 'org.bouncycastle', module: 'bcpkix-jdk15on'
+ exclude group: 'org.bouncycastle', module: 'bcutil-jdk15on'
+ exclude group: 'org.bouncycastle', module: 'bcmail-jdk15on'
+ }
+
+ dependencyManagement {
+ imports {
+ mavenBom "org.springframework.boot:spring-boot-dependencies:$springBootVersion"
+ }
+ }
+
+ dependencies {
+ implementation 'org.springframework.boot:spring-boot-starter-actuator'
+ implementation 'io.github.pixee:java-security-toolkit:1.2.2'
+
+ //tmp for security bumps
+ implementation 'ch.qos.logback:logback-core:1.5.18'
+ implementation 'ch.qos.logback:logback-classic:1.5.18'
+ compileOnly "org.projectlombok:lombok:$lombokVersion"
+ annotationProcessor "org.projectlombok:lombok:$lombokVersion"
+
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testRuntimeOnly 'org.mockito:mockito-inline:5.2.0'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.12.2'
+ }
+
+ tasks.withType(JavaCompile).configureEach {
+ options.encoding = "UTF-8"
+ dependsOn "spotlessApply"
+ }
+
+ compileJava {
+ options.compilerArgs << "-parameters"
+ }
+
+ test {
+ useJUnitPlatform()
+ }
+
+ tasks.named("processResources") {
+ dependsOn(rootProject.tasks.writeVersion)
+ }
+}
+
+tasks.withType(JavaCompile).configureEach {
+ options.encoding = "UTF-8"
+ dependsOn "spotlessApply"
}
licenseReport {
@@ -51,29 +189,14 @@ licenseReport {
sourceSets {
main {
java {
- if (System.getenv("DOCKER_ENABLE_SECURITY") == "false") {
- exclude "stirling/software/SPDF/config/interfaces/DatabaseInterface.java"
- exclude "stirling/software/SPDF/config/security/**"
- exclude "stirling/software/SPDF/controller/api/DatabaseController.java"
- exclude "stirling/software/SPDF/controller/api/EmailController.java"
- exclude "stirling/software/SPDF/controller/api/H2SQLCondition.java"
- exclude "stirling/software/SPDF/controller/api/UserController.java"
- exclude "stirling/software/SPDF/controller/web/AccountWebController.java"
- exclude "stirling/software/SPDF/controller/web/DatabaseWebController.java"
- exclude "stirling/software/SPDF/model/api/Email.java"
- exclude "stirling/software/SPDF/model/ApiKeyAuthenticationToken.java"
- exclude "stirling/software/SPDF/model/AttemptCounter.java"
- exclude "stirling/software/SPDF/model/Authority.java"
- exclude "stirling/software/SPDF/model/exception/BackupNotFoundException.java"
- exclude "stirling/software/SPDF/model/exception/NoProviderFoundException.java"
- exclude "stirling/software/SPDF/model/PersistentLogin.java"
- exclude "stirling/software/SPDF/model/SessionEntity.java"
- exclude "stirling/software/SPDF/model/User.java"
- exclude "stirling/software/SPDF/repository/**"
+ if (System.getenv('DOCKER_ENABLE_SECURITY') == 'false' || System.getenv('DISABLE_ADDITIONAL_FEATURES') == 'true'
+ || (project.hasProperty('DISABLE_ADDITIONAL_FEATURES')
+ && System.getProperty('DISABLE_ADDITIONAL_FEATURES') == 'true')) {
+ exclude 'stirling/software/proprietary/security/**'
}
- if (System.getenv("STIRLING_PDF_DESKTOP_UI") == "false") {
- exclude "stirling/software/SPDF/UI/impl/**"
+ if (System.getenv('STIRLING_PDF_DESKTOP_UI') == 'false') {
+ exclude 'stirling/software/SPDF/UI/impl/**'
}
}
@@ -81,15 +204,14 @@ sourceSets {
test {
java {
- if (System.getenv("DOCKER_ENABLE_SECURITY") == "false") {
- exclude "stirling/software/SPDF/config/security/**"
- exclude "stirling/software/SPDF/model/ApiKeyAuthenticationTokenTest.java"
- exclude "stirling/software/SPDF/controller/api/EmailControllerTest.java"
- exclude "stirling/software/SPDF/repository/**"
+ if (System.getenv('DOCKER_ENABLE_SECURITY') == 'false' || System.getenv('DISABLE_ADDITIONAL_FEATURES') == 'true'
+ || (project.hasProperty('DISABLE_ADDITIONAL_FEATURES')
+ && System.getProperty('DISABLE_ADDITIONAL_FEATURES') == 'true')) {
+ exclude 'stirling/software/proprietary/security/**'
}
- if (System.getenv("STIRLING_PDF_DESKTOP_UI") == "false") {
- exclude "stirling/software/SPDF/UI/impl/**"
+ if (System.getenv('STIRLING_PDF_DESKTOP_UI') == 'false') {
+ exclude 'stirling/software/SPDF/UI/impl/**'
}
}
}
@@ -115,10 +237,9 @@ jpackage {
mainJar = "Stirling-PDF-${project.version}.jar"
appName = "Stirling PDF"
appVersion = project.version
-// appVersion = "2005.45.1"
vendor = "Stirling PDF Inc"
appDescription = "Stirling PDF - Your Local PDF Editor"
- icon = "src/main/resources/static/favicon.ico"
+ icon = "stirling-pdf/src/main/resources/static/favicon.ico"
verbose = true
// mainClass = "org.springframework.boot.loader.launch.JarLauncher"
@@ -126,6 +247,7 @@ jpackage {
javaOptions = [
"-DBROWSER_OPEN=true",
"-DSTIRLING_PDF_DESKTOP_UI=true",
+ "-DDISABLE_ADDITIONAL_FEATURES=false",
"-Djava.awt.headless=false",
"-Dapple.awt.UIElement=true",
"--add-opens=java.base/java.lang=ALL-UNNAMED",
@@ -156,10 +278,10 @@ jpackage {
installDir = "C:/Program Files/Stirling-PDF"
}
- // macOS-specific configuration
+ // MacOS-specific configuration
mac {
appVersion = getMacVersion(project.version.toString())
- icon = "src/main/resources/static/favicon.icns"
+ icon = "stirling-pdf/src/main/resources/static/favicon.icns"
type = "dmg"
macPackageIdentifier = "Stirling PDF"
macPackageName = "Stirling PDF"
@@ -181,7 +303,7 @@ jpackage {
// Linux-specific configuration
linux {
appVersion = project.version
- icon = "src/main/resources/static/favicon.png"
+ icon = "stirling-pdf/src/main/resources/static/favicon.png"
type = "deb" // Can also use "rpm" for Red Hat-based systems
// Debian package configuration
@@ -217,10 +339,15 @@ jpackage {
]*/
// Add copyright and license information
- copyright = "Copyright © 2024 Stirling Software"
+ copyright = "Copyright © 2025 Stirling PDF Inc."
licenseFile = "LICENSE"
}
+//tasks.wrapper {
+// gradleVersion = "8.14"
+// distributionType = Wrapper.DistributionType.ALL
+//}
+
tasks.register('jpackageMacX64') {
group = 'distribution'
description = 'Packages app for MacOS x86_64'
@@ -253,7 +380,7 @@ tasks.register('jpackageMacX64') {
'--main-class', 'org.springframework.boot.loader.launch.JarLauncher',
'--runtime-image', file(jrePath + "/zulu-17.jre/Contents/Home"),
'--dest', 'build/jpackage/x86_64',
- '--icon', 'src/main/resources/static/favicon.icns',
+ '--icon', 'stirling-pdf/src/main/resources/static/favicon.icns',
'--app-version', getMacVersion(project.version.toString()),
'--mac-package-name', 'Stirling PDF (x86_64)',
'--mac-package-identifier', 'Stirling PDF (x86_64)',
@@ -262,6 +389,7 @@ tasks.register('jpackageMacX64') {
// Java options
'--java-options', '-DBROWSER_OPEN=true',
'--java-options', '-DSTIRLING_PDF_DESKTOP_UI=true',
+ '--java-options', '-DDISABLE_ADDITIONAL_FEATURES=false',
'--java-options', '-Djava.awt.headless=false',
'--java-options', '-Dapple.awt.UIElement=true',
'--java-options', '--add-opens=java.base/java.lang=ALL-UNNAMED',
@@ -290,8 +418,6 @@ tasks.register('jpackageMacX64') {
}
}
-//jpackage.finalizedBy(jpackageMacX64)
-
tasks.register('downloadTempJre') {
group = 'distribution'
description = 'Downloads and extracts a temporary JRE'
@@ -303,18 +429,18 @@ tasks.register('downloadTempJre') {
def jreArchive = new File(tmpDir, 'jre.tar.gz')
def jreDir = new File(tmpDir, 'jre')
- println "🔽 Downloading JRE to $jreArchive..."
+ println "Downloading JRE to $jreArchive"
jreArchive.withOutputStream { out ->
new URI(jreUrl).toURL().withInputStream { from -> out << from }
}
- println "📦 Extracting JRE to $jreDir..."
+ println "Extracting JRE to $jreDir"
jreDir.mkdirs()
providers.exec {
commandLine 'tar', '-xzf', jreArchive.absolutePath, '-C', jreDir.absolutePath, '--strip-components=1'
}.result.get()
- println "✅ JRE ready at: $jreDir"
+ println "JRE ready at: $jreDir"
ext.tempJrePath = jreDir.absolutePath
project.ext.tempJrePath = jreDir.absolutePath
} catch (Exception e) {
@@ -340,7 +466,7 @@ tasks.register('cleanTempJre') {
}
launch4j {
- icon = "${projectDir}/src/main/resources/static/favicon.ico"
+ icon = "${projectDir}/stirling-pdf/src/main/resources/static/favicon.ico"
outfile="Stirling-PDF.exe"
@@ -351,7 +477,7 @@ launch4j {
}
jarTask = tasks.bootJar
- errTitle="Encountered error, Do you have Java 21?"
+ errTitle="Encountered error, do you have Java 21?"
downloadUrl="https://download.oracle.com/java/21/latest/jdk-21_windows-x64_bin.exe"
if(System.getenv("STIRLING_PDF_DESKTOP_UI") == 'true') {
@@ -376,8 +502,10 @@ spotless {
java {
target sourceSets.main.allJava
target project(':common').sourceSets.main.allJava
+ target project(':proprietary').sourceSets.main.allJava
+ target project(':stirling-pdf').sourceSets.main.allJava
- googleJavaFormat("1.27.0").aosp().reorderImports(false)
+ googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
toggleOffOn()
@@ -392,188 +520,12 @@ sonar {
property "sonar.projectKey", "Stirling-Tools_Stirling-PDF"
property "sonar.organization", "stirling-tools"
- property "sonar.exclusions", "**/build-wrapper-dump.json, src/main/java/org/apache/**, src/main/resources/static/pdfjs/**, src/main/resources/static/pdfjs-legacy/**, src/main/resources/static/js/thirdParty/**"
- property "sonar.coverage.exclusions", "src/main/java/org/apache/**, src/main/resources/static/pdfjs/**, src/main/resources/static/pdfjs-legacy/**, src/main/resources/static/js/thirdParty/**"
- property "sonar.cpd.exclusions", "src/main/java/org/apache/**, src/main/resources/static/pdfjs/**, src/main/resources/static/pdfjs-legacy/**, src/main/resources/static/js/thirdParty/**"
+ property "sonar.exclusions", "**/build-wrapper-dump.json, **/src/main/java/org/apache/**, **/src/main/resources/static/pdfjs/**, **/src/main/resources/static/pdfjs-legacy/**, **/src/main/resources/static/js/thirdParty/**"
+ property "sonar.coverage.exclusions", "**/src/main/java/org/apache/**, **/src/main/resources/static/pdfjs/**, **/src/main/resources/static/pdfjs-legacy/**, **/src/main/resources/static/js/thirdParty/**"
+ property "sonar.cpd.exclusions", "**/src/main/java/org/apache/**, **/src/main/resources/static/pdfjs/**, **/src/main/resources/static/pdfjs-legacy/**, **/src/main/resources/static/js/thirdParty/**"
}
}
-//gradleLint {
-// rules=['unused-dependency']
-// }
-tasks.wrapper {
- gradleVersion = "8.14"
- distributionType = Wrapper.DistributionType.ALL
-}
-//tasks.withType(JavaCompile) {
-// options.compilerArgs << "-Xlint:deprecation"
-//}
-configurations.all {
- // Remove all commons-logging dependencies so that only spring-jcl is used
- exclude group: 'commons-logging', module: 'commons-logging'
- // Exclude Tomcat
- exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
-}
-
-dependencies {
- implementation project(':common')
-
- //tmp for security bumps
- implementation 'ch.qos.logback:logback-core:1.5.18'
- implementation 'ch.qos.logback:logback-classic:1.5.18'
-
- // Exclude vulnerable BouncyCastle version used in tableau
- configurations.all {
- exclude group: 'org.bouncycastle', module: 'bcpkix-jdk15on'
- exclude group: 'org.bouncycastle', module: 'bcutil-jdk15on'
- exclude group: 'org.bouncycastle', module: 'bcmail-jdk15on'
- }
-
- if (System.getenv("STIRLING_PDF_DESKTOP_UI") != "false") {
- implementation "me.friwi:jcefmaven:132.3.1"
- implementation "org.openjfx:javafx-controls:21"
- implementation "org.openjfx:javafx-swing:21"
- }
-
- //security updates
- implementation "org.springframework:spring-webmvc:6.2.7"
-
- implementation("io.github.pixee:java-security-toolkit:1.2.1")
-
- // Exclude Tomcat and include Jetty
- implementation("org.springframework.boot:spring-boot-starter-web:$springBootVersion")
- implementation "org.springframework.boot:spring-boot-starter-jetty:$springBootVersion"
-
- implementation "org.springframework.boot:spring-boot-starter-thymeleaf:$springBootVersion"
- implementation 'com.posthog.java:posthog:1.2.0'
- implementation 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1'
-
-
- if (System.getenv("DOCKER_ENABLE_SECURITY") != "false") {
- implementation 'io.micrometer:micrometer-registry-prometheus'
-
- implementation "org.springframework.boot:spring-boot-starter-security:$springBootVersion"
- implementation "org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.3.RELEASE"
- implementation "org.springframework.boot:spring-boot-starter-data-jpa:$springBootVersion"
- implementation "org.springframework.boot:spring-boot-starter-oauth2-client:$springBootVersion"
- implementation "org.springframework.boot:spring-boot-starter-mail:$springBootVersion"
-
- implementation "org.springframework.session:spring-session-core:3.5.0"
- implementation "org.springframework:spring-jdbc:6.2.7"
-
- implementation 'com.unboundid.product.scim2:scim2-sdk-client:2.3.5'
- // Don't upgrade h2database
- runtimeOnly "com.h2database:h2:2.3.232"
- runtimeOnly "org.postgresql:postgresql:42.7.5"
- constraints {
- implementation "org.opensaml:opensaml-core:$openSamlVersion"
- implementation "org.opensaml:opensaml-saml-api:$openSamlVersion"
- implementation "org.opensaml:opensaml-saml-impl:$openSamlVersion"
- }
- implementation "org.springframework.security:spring-security-saml2-service-provider:$springSecuritySamlVersion"
- // implementation 'org.springframework.security:spring-security-core:$springSecuritySamlVersion'
- implementation 'com.coveo:saml-client:5.0.0'
-
- }
- implementation 'org.snakeyaml:snakeyaml-engine:2.9'
-
- testImplementation "org.springframework.boot:spring-boot-starter-test:$springBootVersion"
-
- // Batik
- implementation "org.apache.xmlgraphics:batik-all:1.19"
-
- // TwelveMonkeys
- runtimeOnly "com.twelvemonkeys.imageio:imageio-batik:$imageioVersion"
- runtimeOnly "com.twelvemonkeys.imageio:imageio-bmp:$imageioVersion"
- // runtimeOnly "com.twelvemonkeys.imageio:imageio-hdr:$imageioVersion"
- // runtimeOnly "com.twelvemonkeys.imageio:imageio-icns:$imageioVersion"
- // runtimeOnly "com.twelvemonkeys.imageio:imageio-iff:$imageioVersion"
- runtimeOnly "com.twelvemonkeys.imageio:imageio-jpeg:$imageioVersion"
- // runtimeOnly "com.twelvemonkeys.imageio:imageio-pcx:$imageioVersion@
- // runtimeOnly "com.twelvemonkeys.imageio:imageio-pict:$imageioVersion"
- // runtimeOnly "com.twelvemonkeys.imageio:imageio-pnm:$imageioVersion"
- // runtimeOnly "com.twelvemonkeys.imageio:imageio-psd:$imageioVersion"
- // runtimeOnly "com.twelvemonkeys.imageio:imageio-sgi:$imageioVersion"
- // runtimeOnly "com.twelvemonkeys.imageio:imageio-tga:$imageioVersion"
- // runtimeOnly "com.twelvemonkeys.imageio:imageio-thumbsdb:$imageioVersion"
- runtimeOnly "com.twelvemonkeys.imageio:imageio-tiff:$imageioVersion"
- runtimeOnly "com.twelvemonkeys.imageio:imageio-webp:$imageioVersion"
- // runtimeOnly "com.twelvemonkeys.imageio:imageio-xwd:$imageioVersion"
-
- // Image metadata extractor
- implementation "com.drewnoakes:metadata-extractor:2.19.0"
-
- implementation "commons-io:commons-io:2.19.0"
- implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8"
- //general PDF
-
- // https://mvnrepository.com/artifact/com.opencsv/opencsv
- implementation ("com.opencsv:opencsv:5.11")
-
- implementation ("org.apache.pdfbox:pdfbox:$pdfboxVersion")
- implementation "org.apache.pdfbox:preflight:$pdfboxVersion"
-
-
- implementation ("org.apache.pdfbox:xmpbox:$pdfboxVersion")
-
- // https://mvnrepository.com/artifact/technology.tabula/tabula
- implementation ('technology.tabula:tabula:1.0.5') {
- exclude group: "org.slf4j", module: "slf4j-simple"
- exclude group: "org.bouncycastle", module: "bcprov-jdk15on"
- exclude group: "com.google.code.gson", module: "gson"
- exclude group: "commons-io", module: "commons-io"
- }
-
- implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
-
- implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
- implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
- implementation "org.springframework.boot:spring-boot-starter-actuator:$springBootVersion"
- implementation "io.micrometer:micrometer-core:1.15.0"
- implementation group: "com.google.zxing", name: "core", version: "3.5.3"
- // https://mvnrepository.com/artifact/org.commonmark/commonmark
- implementation "org.commonmark:commonmark:0.24.0"
- implementation "org.commonmark:commonmark-ext-gfm-tables:0.24.0"
- // https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
- implementation "com.bucket4j:bucket4j_jdk17-core:8.14.0"
- implementation "com.fathzer:javaluator:3.0.6"
-
- implementation 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
-
- developmentOnly("org.springframework.boot:spring-boot-devtools:$springBootVersion")
- compileOnly "org.projectlombok:lombok:$lombokVersion"
- annotationProcessor "org.projectlombok:lombok:$lombokVersion"
-
- // Mockito (core)
- testImplementation 'org.mockito:mockito-core:5.18.0'
- testRuntimeOnly 'org.mockito:mockito-inline:5.2.0'
-}
-
-tasks.withType(JavaCompile).configureEach {
- options.encoding = "UTF-8"
- dependsOn "spotlessApply"
-}
-compileJava {
- options.compilerArgs << "-parameters"
-}
-
-task writeVersion {
- def propsFile = file("$projectDir/src/main/resources/version.properties")
- def propsDir = propsFile.parentFile
-
- doLast {
- if (!propsDir.exists()) {
- propsDir.mkdirs()
- }
-
- def props = new Properties()
- props.setProperty("version", version)
- props.store(propsFile.newWriter(), null)
- }
-}
-
-processResources.dependsOn(writeVersion)
-
swaggerhubUpload {
// dependsOn = generateOpenApiDocs // Depends on your task generating Swagger docs
api = "Stirling-PDF" // The name of your API on SwaggerHub
@@ -584,25 +536,26 @@ swaggerhubUpload {
oas = "3.0.0" // The version of the OpenAPI Specification you"re using
}
-jar {
- enabled = false
- manifest {
- attributes "Implementation-Title": "Stirling-PDF",
- "Implementation-Version": project.version
- }
+dependencies {
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.12.2'
}
tasks.named("test") {
useJUnitPlatform()
}
-task printVersion {
+
+// Make sure all relevant processes depend on writeVersion
+processResources.dependsOn(writeVersion)
+
+tasks.register('printVersion') {
doLast {
println project.version
}
}
-task printMacVersion {
+tasks.register('printMacVersion') {
doLast {
println getMacVersion(project.version.toString())
}
@@ -611,3 +564,22 @@ task printMacVersion {
tasks.named('generateOpenApiDocs') {
doNotTrackState("Tracking state is not supported for this task")
}
+tasks.named('bootRun') {
+ group = 'application'
+ description = 'Delegates to :stirling-pdf:bootRun'
+ dependsOn ':stirling-pdf:bootRun'
+
+ doFirst {
+ println "Delegating to :stirling-pdf:bootRun"
+ }
+}
+
+tasks.named('build') {
+ group = 'build'
+ description = 'Delegates to :stirling-pdf:bootJar'
+ dependsOn ':stirling-pdf:bootJar'
+
+ doFirst {
+ println "Delegating to :stirling-pdf:bootJar"
+ }
+}
diff --git a/common/.gitignore b/common/.gitignore
index 90d48ccea..17f0d4976 100644
--- a/common/.gitignore
+++ b/common/.gitignore
@@ -124,6 +124,7 @@ SwaggerDoc.json
*.rar
*.db
/build
+/common/build/
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -193,4 +194,3 @@ id_ed25519.pub
# node_modules
node_modules/
-*.mjs
diff --git a/common/build.gradle b/common/build.gradle
index 64b98b88b..cdfc11b8f 100644
--- a/common/build.gradle
+++ b/common/build.gradle
@@ -1,52 +1,32 @@
-plugins {
- id 'java-library'
- id 'io.spring.dependency-management' version '1.1.7'
+// Configure bootRun to disable it or point to a main class
+bootRun {
+ enabled = false
}
+spotless {
+ java {
+ target sourceSets.main.allJava
+ googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
-group = 'stirling.software'
-version = '0.46.2'
-
-ext {
- lombokVersion = "1.18.38"
-}
-
-java {
- sourceCompatibility = JavaVersion.VERSION_17
-}
-
-repositories {
- mavenCentral()
-}
-
-configurations.all {
- exclude group: 'commons-logging', module: 'commons-logging'
- exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
-}
-
-dependencyManagement {
- imports {
- mavenBom 'org.springframework.boot:spring-boot-dependencies:3.4.5'
+ importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
+ toggleOffOn()
+ trimTrailingWhitespace()
+ leadingTabsToSpaces()
+ endWithNewline()
}
}
-
dependencies {
- implementation 'org.springframework.boot:spring-boot-starter-web'
- implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
- implementation 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1'
- implementation 'com.fathzer:javaluator:3.0.6'
- implementation 'com.posthog.java:posthog:1.2.0'
- implementation 'io.github.pixee:java-security-toolkit:1.2.1'
- implementation 'org.apache.commons:commons-lang3:3.17.0'
- implementation 'com.drewnoakes:metadata-extractor:2.19.0' // Image metadata extractor
- implementation 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
- implementation "org.apache.pdfbox:pdfbox:$pdfboxVersion"
- implementation 'jakarta.servlet:jakarta.servlet-api:6.0.0'
- implementation 'org.snakeyaml:snakeyaml-engine:2.9'
- implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6"
-
- compileOnly "org.projectlombok:lombok:$lombokVersion"
- annotationProcessor "org.projectlombok:lombok:$lombokVersion"
-
- testImplementation "org.springframework.boot:spring-boot-starter-test"
- testRuntimeOnly 'org.mockito:mockito-inline:5.2.0'
+ api 'org.springframework.boot:spring-boot-starter-web'
+ api 'org.springframework.boot:spring-boot-starter-thymeleaf'
+ 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.17.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.9'
+ api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9"
+ api 'jakarta.mail:jakarta.mail-api:2.1.3'
+ api 'org.springframework.boot:spring-boot-starter-aop'
}
diff --git a/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java b/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java
new file mode 100644
index 000000000..062f3e0a1
--- /dev/null
+++ b/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java
@@ -0,0 +1,78 @@
+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 "auto‑job" framework.
+ *
+ * Behaviour notes:
+ *
+ * The endpoint is registered with {@code POST} and, by default, consumes
+ * {@code multipart/form-data} unless you override {@link #consumes()}.
+ * 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 time‑outs. For synchronous (default)
+ * invocations these advanced options are ignored.
+ * Progress information (see {@link #trackProgress()}) is stored in
+ * {@link stirling.software.common.service.TaskManager TaskManager} and can be
+ * polled via GET /api/v1/general/job/{id}
.
+ *
+ *
+ *
+ * Unless stated otherwise an attribute only affects async 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".
+ * Only honoured when {@code async=true}.
+ */
+ long timeout() default -1;
+
+ /**
+ * Total number of attempts (initial + retries). Must be at least 1.
+ * Retries are executed with exponential back‑off.
+ * Only honoured when {@code async=true}.
+ */
+ int retryCount() default 1;
+
+ /**
+ * Record percentage / note updates so they can be retrieved via the REST status endpoint.
+ * 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.
+ * Only honoured when {@code async=true}.
+ */
+ boolean queueable() default false;
+
+ /**
+ * Relative resource weight (1–100) used by the scheduler to prioritise / throttle jobs. Values
+ * below 1 are clamped to 1, values above 100 to 100.
+ */
+ int resourceWeight() default 50;
+}
diff --git a/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java b/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java
new file mode 100644
index 000000000..51c1882b6
--- /dev/null
+++ b/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java
@@ -0,0 +1,365 @@
+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"));
+ 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);
+
+ // Copy and process arguments
+ // In a test environment, we might need to update the original objects for verification
+ boolean isTestEnvironment = false;
+ try {
+ isTestEnvironment = Class.forName("org.junit.jupiter.api.Test") != null;
+ } catch (ClassNotFoundException e) {
+ // Not in a test environment
+ }
+
+ Object[] args =
+ isTestEnvironment
+ ? processArgsInPlace(joinPoint.getArgs(), async)
+ : copyAndProcessArgs(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 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 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);
+ }
+
+ /**
+ * Creates deep copies of arguments when needed to avoid mutating the original objects
+ * Particularly important for PDFFile objects that might be reused by Spring
+ *
+ * @param originalArgs The original arguments
+ * @param async Whether this is an async operation
+ * @return A new array with safely processed arguments
+ */
+ private Object[] copyAndProcessArgs(Object[] originalArgs, boolean async) {
+ if (originalArgs == null || originalArgs.length == 0) {
+ return originalArgs;
+ }
+
+ Object[] processedArgs = new Object[originalArgs.length];
+
+ // Copy all arguments
+ for (int i = 0; i < originalArgs.length; i++) {
+ Object arg = originalArgs[i];
+
+ if (arg instanceof PDFFile pdfFile) {
+ // Create a copy of PDFFile to avoid mutating the original
+ // Using direct property access instead of reflection for better performance
+ PDFFile pdfFileCopy = new PDFFile();
+ pdfFileCopy.setFileId(pdfFile.getFileId());
+ pdfFileCopy.setFileInput(pdfFile.getFileInput());
+
+ // Case 1: fileId is provided but no fileInput
+ if (pdfFileCopy.getFileInput() == null && pdfFileCopy.getFileId() != null) {
+ try {
+ log.debug("Using fileId {} to get file content", pdfFileCopy.getFileId());
+ MultipartFile file = fileStorage.retrieveFile(pdfFileCopy.getFileId());
+ pdfFileCopy.setFileInput(file);
+ } catch (Exception e) {
+ throw new RuntimeException(
+ "Failed to resolve file by ID: " + pdfFileCopy.getFileId(), e);
+ }
+ }
+ // Case 2: For async requests, we need to make a copy of the MultipartFile
+ else if (async && pdfFileCopy.getFileInput() != null) {
+ try {
+ log.debug("Making persistent copy of uploaded file for async processing");
+ MultipartFile originalFile = pdfFileCopy.getFileInput();
+ String fileId = fileStorage.storeFile(originalFile);
+
+ // Store the fileId for later reference
+ pdfFileCopy.setFileId(fileId);
+
+ // Replace the original MultipartFile with our persistent copy
+ MultipartFile persistentFile = fileStorage.retrieveFile(fileId);
+ pdfFileCopy.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);
+ }
+ }
+
+ processedArgs[i] = pdfFileCopy;
+ } else {
+ // For non-PDFFile objects, just pass the original reference
+ // If other classes need copy-on-write, add them here
+ processedArgs[i] = arg;
+ }
+ }
+
+ return processedArgs;
+ }
+
+ /**
+ * Processes arguments in-place for testing purposes This is similar to our original
+ * implementation before introducing copy-on-write It's only used in test environments to
+ * maintain test compatibility
+ *
+ * @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;
+ }
+ }
+}
diff --git a/common/src/main/java/stirling/software/common/configuration/AppConfig.java b/common/src/main/java/stirling/software/common/configuration/AppConfig.java
index 732a3b174..02614584b 100644
--- a/common/src/main/java/stirling/software/common/configuration/AppConfig.java
+++ b/common/src/main/java/stirling/software/common/configuration/AppConfig.java
@@ -15,6 +15,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
+import org.springframework.context.annotation.Profile;
import org.springframework.context.annotation.Scope;
import org.springframework.core.env.Environment;
import org.springframework.core.io.ClassPathResource;
@@ -146,10 +147,24 @@ public class AppConfig {
}
}
- @ConditionalOnMissingClass("stirling.software.SPDF.config.security.SecurityConfiguration")
@Bean(name = "activeSecurity")
+ public boolean activeSecurity() {
+ String disableAdditionalFeatures = env.getProperty("DISABLE_ADDITIONAL_FEATURES");
+
+ if (disableAdditionalFeatures != null) {
+ // DISABLE_ADDITIONAL_FEATURES=true means security OFF, so return false
+ // DISABLE_ADDITIONAL_FEATURES=false means security ON, so return true
+ return !Boolean.parseBoolean(disableAdditionalFeatures);
+ }
+
+ return env.getProperty("DOCKER_ENABLE_SECURITY", Boolean.class, true);
+ }
+
+ @Bean(name = "missingActiveSecurity")
+ @ConditionalOnMissingClass(
+ "stirling.software.proprietary.security.configuration.SecurityConfiguration")
public boolean missingActiveSecurity() {
- return false;
+ return true;
}
@Bean(name = "directoryFilter")
@@ -236,9 +251,33 @@ public class AppConfig {
return applicationProperties.getSystem().getDatasource();
}
+ @Bean(name = "runningProOrHigher")
+ @Profile("default")
+ public boolean runningProOrHigher() {
+ return false;
+ }
+
+ @Bean(name = "runningEE")
+ @Profile("default")
+ public boolean runningEnterprise() {
+ return false;
+ }
+
+ @Bean(name = "GoogleDriveEnabled")
+ @Profile("default")
+ public boolean googleDriveEnabled() {
+ return false;
+ }
+
+ @Bean(name = "license")
+ @Profile("default")
+ public String licenseType() {
+ return "NORMAL";
+ }
+
@Bean(name = "disablePixel")
public boolean disablePixel() {
- return Boolean.getBoolean(env.getProperty("DISABLE_PIXEL"));
+ return Boolean.parseBoolean(env.getProperty("DISABLE_PIXEL", "false"));
}
@Bean(name = "machineType")
diff --git a/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/common/src/main/java/stirling/software/common/model/ApplicationProperties.java
index 9472d40e4..f5b67c866 100644
--- a/common/src/main/java/stirling/software/common/model/ApplicationProperties.java
+++ b/common/src/main/java/stirling/software/common/model/ApplicationProperties.java
@@ -344,10 +344,10 @@ public class ApplicationProperties {
@Override
public String toString() {
return """
- Driver {
- driverName='%s'
- }
- """
+ Driver {
+ driverName='%s'
+ }
+ """
.formatted(driverName);
}
}
@@ -442,6 +442,7 @@ public class ApplicationProperties {
@Data
public static class ProFeatures {
private boolean ssoAutoLogin;
+ private boolean database;
private CustomMetadata customMetadata = new CustomMetadata();
private GoogleDrive googleDrive = new GoogleDrive();
@@ -487,6 +488,14 @@ public class ApplicationProperties {
@Data
public static class EnterpriseFeatures {
private PersistentMetrics persistentMetrics = new PersistentMetrics();
+ private Audit audit = new Audit();
+
+ @Data
+ public static class Audit {
+ private boolean enabled = true;
+ private int level = 2; // 0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE
+ private int retentionDays = 90;
+ }
@Data
public static class PersistentMetrics {
diff --git a/common/src/main/java/stirling/software/common/model/api/PDFFile.java b/common/src/main/java/stirling/software/common/model/api/PDFFile.java
index 8ea3f0456..cc564f81e 100644
--- a/common/src/main/java/stirling/software/common/model/api/PDFFile.java
+++ b/common/src/main/java/stirling/software/common/model/api/PDFFile.java
@@ -14,8 +14,12 @@ import lombok.NoArgsConstructor;
public class PDFFile {
@Schema(
description = "The input PDF file",
- requiredMode = Schema.RequiredMode.REQUIRED,
contentMediaType = "application/pdf",
format = "binary")
private MultipartFile fileInput;
+
+ @Schema(
+ description = "File ID for server-side files (can be used instead of fileInput)",
+ example = "a1b2c3d4-5678-90ab-cdef-ghijklmnopqr")
+ private String fileId;
}
diff --git a/common/src/main/java/stirling/software/common/model/api/converters/EmlToPdfRequest.java b/common/src/main/java/stirling/software/common/model/api/converters/EmlToPdfRequest.java
new file mode 100644
index 000000000..b6425a99f
--- /dev/null
+++ b/common/src/main/java/stirling/software/common/model/api/converters/EmlToPdfRequest.java
@@ -0,0 +1,41 @@
+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;
+}
diff --git a/common/src/main/java/stirling/software/common/model/job/JobProgress.java b/common/src/main/java/stirling/software/common/model/job/JobProgress.java
new file mode 100644
index 000000000..e8cbdb6ca
--- /dev/null
+++ b/common/src/main/java/stirling/software/common/model/job/JobProgress.java
@@ -0,0 +1,15 @@
+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;
+}
diff --git a/common/src/main/java/stirling/software/common/model/job/JobResponse.java b/common/src/main/java/stirling/software/common/model/job/JobResponse.java
new file mode 100644
index 000000000..bd98955f0
--- /dev/null
+++ b/common/src/main/java/stirling/software/common/model/job/JobResponse.java
@@ -0,0 +1,14 @@
+package stirling.software.common.model.job;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class JobResponse {
+ private boolean async;
+ private String jobId;
+ private T result;
+}
diff --git a/common/src/main/java/stirling/software/common/model/job/JobResult.java b/common/src/main/java/stirling/software/common/model/job/JobResult.java
new file mode 100644
index 000000000..a621f2db2
--- /dev/null
+++ b/common/src/main/java/stirling/software/common/model/job/JobResult.java
@@ -0,0 +1,121 @@
+package stirling.software.common.model.job;
+
+import java.time.LocalDateTime;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+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;
+
+ /** The file ID of the result file, if applicable */
+ private String fileId;
+
+ /** Original file name, if applicable */
+ private String originalFileName;
+
+ /** MIME type of the result, if applicable */
+ private String contentType;
+
+ /** 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 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 file result
+ *
+ * @param fileId The file ID of the result
+ * @param originalFileName The original file name
+ * @param contentType The content type of the file
+ */
+ public void completeWithFile(String fileId, String originalFileName, String contentType) {
+ this.complete = true;
+ this.fileId = fileId;
+ this.originalFileName = originalFileName;
+ this.contentType = contentType;
+ this.completedAt = LocalDateTime.now();
+ }
+
+ /**
+ * 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();
+ }
+
+ /**
+ * 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 getNotes() {
+ return Collections.unmodifiableList(notes);
+ }
+}
diff --git a/common/src/main/java/stirling/software/common/model/job/JobStats.java b/common/src/main/java/stirling/software/common/model/job/JobStats.java
new file mode 100644
index 000000000..d336b95d4
--- /dev/null
+++ b/common/src/main/java/stirling/software/common/model/job/JobStats.java
@@ -0,0 +1,43 @@
+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;
+}
diff --git a/common/src/main/java/stirling/software/common/service/FileOrUploadService.java b/common/src/main/java/stirling/software/common/service/FileOrUploadService.java
new file mode 100644
index 000000000..0b72d3dc8
--- /dev/null
+++ b/common/src/main/java/stirling/software/common/service/FileOrUploadService.java
@@ -0,0 +1,78 @@
+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);
+ }
+ }
+}
diff --git a/common/src/main/java/stirling/software/common/service/FileStorage.java b/common/src/main/java/stirling/software/common/service/FileStorage.java
new file mode 100644
index 000000000..e200ded8a
--- /dev/null
+++ b/common/src/main/java/stirling/software/common/service/FileStorage.java
@@ -0,0 +1,152 @@
+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 path for a file ID
+ *
+ * @param fileId The ID of the file
+ * @return The path to the file
+ */
+ private Path getFilePath(String fileId) {
+ return Path.of(tempDirPath).resolve(fileId);
+ }
+
+ /**
+ * Generate a unique file ID
+ *
+ * @return A unique file ID
+ */
+ private String generateFileId() {
+ return UUID.randomUUID().toString();
+ }
+}
diff --git a/common/src/main/java/stirling/software/common/service/JobExecutorService.java b/common/src/main/java/stirling/software/common/service/JobExecutorService.java
new file mode 100644
index 000000000..73afa22a0
--- /dev/null
+++ b/common/src/main/java/stirling/software/common/service/JobExecutorService.java
@@ -0,0 +1,476 @@
+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 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 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 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 userJobIds =
+ (java.util.Set) 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 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> 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 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 executeWithTimeout(Supplier 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 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);
+ }
+ }
+}
diff --git a/common/src/main/java/stirling/software/common/service/JobQueue.java b/common/src/main/java/stirling/software/common/service/JobQueue.java
new file mode 100644
index 000000000..df5394cee
--- /dev/null
+++ b/common/src/main/java/stirling/software/common/service/JobQueue.java
@@ -0,0 +1,495 @@
+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 jobQueue;
+ private final Map 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 work;
+ private final long timeoutMs;
+ private final Instant queuedAt;
+ private CompletableFuture> 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> queueJob(
+ String jobId, int resourceWeight, Supplier work, long timeoutMs) {
+
+ // Create a CompletableFuture to track this job's completion
+ CompletableFuture> 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) 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 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 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 executeWithTimeout(Supplier supplier, long timeoutMs) throws Exception {
+ CompletableFuture 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 getQueueStats() {
+ return Map.of(
+ "queuedJobs", jobQueue.size(),
+ "queueCapacity", getQueueCapacity(),
+ "totalQueuedJobs", totalQueuedJobs,
+ "rejectedJobs", rejectedJobs,
+ "resourceStatus", resourceMonitor.getCurrentStatus().get().name());
+ }
+}
diff --git a/common/src/main/java/stirling/software/common/service/PostHogService.java b/common/src/main/java/stirling/software/common/service/PostHogService.java
index 6965027dd..2bc219832 100644
--- a/common/src/main/java/stirling/software/common/service/PostHogService.java
+++ b/common/src/main/java/stirling/software/common/service/PostHogService.java
@@ -208,7 +208,7 @@ public class PostHogService {
// New environment variables
dockerMetrics.put("version_tag", System.getenv("VERSION_TAG"));
- dockerMetrics.put("without_enhanced_features", System.getenv("WITHOUT_ENHANCED_FEATURES"));
+ dockerMetrics.put("additional_features_off", System.getenv("ADDITIONAL_FEATURES_OFF"));
dockerMetrics.put("fat_docker", System.getenv("FAT_DOCKER"));
return dockerMetrics;
diff --git a/common/src/main/java/stirling/software/common/service/ResourceMonitor.java b/common/src/main/java/stirling/software/common/service/ResourceMonitor.java
new file mode 100644
index 000000000..2791fff90
--- /dev/null
+++ b/common/src/main/java/stirling/software/common/service/ResourceMonitor.java
@@ -0,0 +1,277 @@
+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 currentStatus =
+ new AtomicReference<>(ResourceStatus.OK);
+
+ @Getter
+ private final AtomicReference 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;
+ }
+}
diff --git a/common/src/main/java/stirling/software/common/service/TaskManager.java b/common/src/main/java/stirling/software/common/service/TaskManager.java
new file mode 100644
index 000000000..c2b3ba8a8
--- /dev/null
+++ b/common/src/main/java/stirling/software/common/service/TaskManager.java
@@ -0,0 +1,293 @@
+package stirling.software.common.service;
+
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+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 org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import jakarta.annotation.PreDestroy;
+
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.common.model.job.JobResult;
+import stirling.software.common.model.job.JobStats;
+
+/** Manages async tasks and their results */
+@Service
+@Slf4j
+public class TaskManager {
+ private final Map 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);
+ jobResult.completeWithFile(fileId, originalFileName, contentType);
+ log.debug("Set file result for job ID: {} with file ID: {}", jobId, fileId);
+ }
+
+ /**
+ * 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.getFileId() == null
+ && 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.getFileId() != null) {
+ 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 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)) {
+
+ // If the job has a file result, delete the file
+ if (result.getFileId() != null) {
+ try {
+ fileStorage.deleteFile(result.getFileId());
+ } catch (Exception e) {
+ log.warn(
+ "Failed to delete file for job {}: {}",
+ entry.getKey(),
+ e.getMessage());
+ }
+ }
+
+ // 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();
+ }
+ }
+}
diff --git a/common/src/main/java/stirling/software/common/util/EmlToPdf.java b/common/src/main/java/stirling/software/common/util/EmlToPdf.java
new file mode 100644
index 000000000..b08bc16a5
--- /dev/null
+++ b/common/src/main/java/stirling/software/common/util/EmlToPdf.java
@@ -0,0 +1,1750 @@
+package stirling.software.common.util;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Method;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+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.Properties;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+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.PDDocumentNameDictionary;
+import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode;
+import org.apache.pdfbox.pdmodel.PDPage;
+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.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import lombok.Data;
+import lombok.Getter;
+import lombok.experimental.UtilityClass;
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.common.model.api.converters.EmlToPdfRequest;
+
+@Slf4j
+@UtilityClass
+public class EmlToPdf {
+ private static final class StyleConstants {
+ // Font and layout constants
+ static final int DEFAULT_FONT_SIZE = 12;
+ static final String DEFAULT_FONT_FAMILY = "Helvetica, sans-serif";
+ static final float DEFAULT_LINE_HEIGHT = 1.4f;
+ static final String DEFAULT_ZOOM = "1.0";
+
+ // Color constants - aligned with application theme
+ static final String DEFAULT_TEXT_COLOR = "#202124";
+ static final String DEFAULT_BACKGROUND_COLOR = "#ffffff";
+ static final String DEFAULT_BORDER_COLOR = "#e8eaed";
+ static final String ATTACHMENT_BACKGROUND_COLOR = "#f9f9f9";
+ static final String ATTACHMENT_BORDER_COLOR = "#eeeeee";
+
+ // Size constants for PDF annotations
+ static final float ATTACHMENT_ICON_WIDTH = 12f;
+ static final float ATTACHMENT_ICON_HEIGHT = 14f;
+ static final float ANNOTATION_X_OFFSET = 2f;
+ static final float ANNOTATION_Y_OFFSET = 10f;
+
+ // Content validation constants
+ static final int EML_CHECK_LENGTH = 8192;
+ static final int MIN_HEADER_COUNT_FOR_VALID_EML = 2;
+
+ private StyleConstants() {}
+ }
+
+ private static final class MimeConstants {
+ static final Pattern MIME_ENCODED_PATTERN =
+ Pattern.compile("=\\?([^?]+)\\?([BbQq])\\?([^?]*)\\?=");
+ static final String ATTACHMENT_MARKER = "@";
+
+ private MimeConstants() {}
+ }
+
+ private static final class FileSizeConstants {
+ static final long BYTES_IN_KB = 1024L;
+ static final long BYTES_IN_MB = BYTES_IN_KB * 1024L;
+ static final long BYTES_IN_GB = BYTES_IN_MB * 1024L;
+
+ private FileSizeConstants() {}
+ }
+
+ // Cached Jakarta Mail availability check
+ private static Boolean jakartaMailAvailable = null;
+
+ private static boolean isJakartaMailAvailable() {
+ if (jakartaMailAvailable == null) {
+ try {
+ // Check for core Jakarta Mail classes
+ 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;
+ log.debug("Jakarta Mail libraries are available");
+ } catch (ClassNotFoundException e) {
+ jakartaMailAvailable = false;
+ log.debug("Jakarta Mail libraries are not available, using basic parsing");
+ }
+ }
+ return jakartaMailAvailable;
+ }
+
+ public static String convertEmlToHtml(byte[] emlBytes, EmlToPdfRequest request)
+ throws IOException {
+ validateEmlInput(emlBytes);
+
+ if (isJakartaMailAvailable()) {
+ return convertEmlToHtmlAdvanced(emlBytes, request);
+ } else {
+ return convertEmlToHtmlBasic(emlBytes, request);
+ }
+ }
+
+ public static byte[] convertEmlToPdf(
+ String weasyprintPath,
+ EmlToPdfRequest request,
+ byte[] emlBytes,
+ String fileName,
+ boolean disableSanitize,
+ stirling.software.common.service.CustomPDFDocumentFactory pdfDocumentFactory)
+ throws IOException, InterruptedException {
+
+ validateEmlInput(emlBytes);
+
+ try {
+ // Generate HTML representation
+ EmailContent emailContent = null;
+ String htmlContent;
+
+ if (isJakartaMailAvailable()) {
+ emailContent = extractEmailContentAdvanced(emlBytes, request);
+ htmlContent = generateEnhancedEmailHtml(emailContent, request);
+ } else {
+ htmlContent = convertEmlToHtmlBasic(emlBytes, request);
+ }
+
+ // Convert HTML to PDF
+ byte[] pdfBytes =
+ convertHtmlToPdf(weasyprintPath, request, htmlContent, disableSanitize);
+
+ // Attach files if available and requested
+ if (shouldAttachFiles(emailContent, request)) {
+ pdfBytes =
+ attachFilesToPdf(
+ pdfBytes, emailContent.getAttachments(), pdfDocumentFactory);
+ }
+
+ return pdfBytes;
+
+ } catch (IOException | InterruptedException e) {
+ log.error("Failed to convert EML to PDF for file: {}", fileName, e);
+ throw e;
+ } catch (Exception e) {
+ log.error("Unexpected error during EML to PDF conversion for file: {}", fileName, e);
+ throw new IOException("Conversion failed: " + e.getMessage(), e);
+ }
+ }
+
+ private 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 shouldAttachFiles(EmailContent emailContent, EmlToPdfRequest request) {
+ return emailContent != null
+ && request != null
+ && request.isIncludeAttachments()
+ && !emailContent.getAttachments().isEmpty();
+ }
+
+ private static byte[] convertHtmlToPdf(
+ String weasyprintPath,
+ EmlToPdfRequest request,
+ String htmlContent,
+ boolean disableSanitize)
+ throws IOException, InterruptedException {
+
+ stirling.software.common.model.api.converters.HTMLToPdfRequest htmlRequest =
+ createHtmlRequest(request);
+
+ try {
+ return FileToPdf.convertHtmlToPdf(
+ weasyprintPath,
+ htmlRequest,
+ htmlContent.getBytes(StandardCharsets.UTF_8),
+ "email.html",
+ disableSanitize);
+ } catch (IOException | InterruptedException e) {
+ log.warn("Initial HTML to PDF conversion failed, trying with simplified HTML");
+ String simplifiedHtml = simplifyHtmlContent(htmlContent);
+ return FileToPdf.convertHtmlToPdf(
+ weasyprintPath,
+ htmlRequest,
+ simplifiedHtml.getBytes(StandardCharsets.UTF_8),
+ "email.html",
+ disableSanitize);
+ }
+ }
+
+ private static String simplifyHtmlContent(String htmlContent) {
+ String simplified = htmlContent.replaceAll("(?i)", "");
+ simplified = simplified.replaceAll("(?i)", "");
+ return simplified;
+ }
+
+ private static String generateUniqueAttachmentId(String filename) {
+ return "attachment_" + filename.hashCode() + "_" + System.nanoTime();
+ }
+
+ private static String convertEmlToHtmlBasic(byte[] emlBytes, EmlToPdfRequest request) {
+ if (emlBytes == null || emlBytes.length == 0) {
+ throw new IllegalArgumentException("EML file is empty or null");
+ }
+
+ String emlContent = new String(emlBytes, StandardCharsets.UTF_8);
+
+ // Basic email parsing
+ String subject = extractBasicHeader(emlContent, "Subject:");
+ String from = extractBasicHeader(emlContent, "From:");
+ String to = extractBasicHeader(emlContent, "To:");
+ String cc = extractBasicHeader(emlContent, "Cc:");
+ String bcc = extractBasicHeader(emlContent, "Bcc:");
+ String date = extractBasicHeader(emlContent, "Date:");
+
+ // Try to extract HTML content
+ String htmlBody = extractHtmlBody(emlContent);
+ if (htmlBody == null) {
+ String textBody = extractTextBody(emlContent);
+ htmlBody =
+ convertTextToHtml(
+ textBody != null ? textBody : "Email content could not be parsed");
+ }
+
+ // Generate HTML with custom styling based on request
+ StringBuilder html = new StringBuilder();
+ html.append("\n");
+ html.append(" \n");
+ html.append("").append(escapeHtml(subject)).append(" \n");
+ html.append("\n");
+ html.append("\n");
+
+ html.append("\n");
+ html.append("\n");
+
+ html.append("
\n");
+ html.append(processEmailHtmlBody(htmlBody));
+ html.append("
\n");
+
+ // Add attachment information - always check for and display attachments
+ String attachmentInfo = extractAttachmentInfo(emlContent);
+ if (!attachmentInfo.isEmpty()) {
+ html.append("
\n");
+ html.append("
Attachments \n");
+ html.append(attachmentInfo);
+
+ // Add a status message about attachment inclusion
+ if (request != null && request.isIncludeAttachments()) {
+ html.append("
\n");
+ html.append(
+ "
Note: Attachments are saved as external files and linked in this PDF. Click the links to open files externally.
\n");
+ html.append("
\n");
+ } else {
+ html.append("
\n");
+ html.append(
+ "
Attachment information displayed - files not included in PDF. Enable 'Include attachments' to embed files.
\n");
+ html.append("
\n");
+ }
+
+ html.append("
\n");
+ }
+
+ // Show advanced features status if requested
+ assert request != null;
+ if (request.getFileInput().isEmpty()) {
+ html.append("
\n");
+ html.append(
+ "
Note: Some advanced features require Jakarta Mail dependencies.
\n");
+ html.append("
\n");
+ }
+
+ html.append("
\n");
+ html.append("");
+
+ return html.toString();
+ }
+
+ private static EmailContent extractEmailContentAdvanced(
+ byte[] emlBytes, EmlToPdfRequest request) {
+ try {
+ // Use Jakarta Mail for processing
+ 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());
+
+ // Cast the session object to the proper type for the constructor
+ Class>[] constructorArgs = new Class>[] {sessionClass, InputStream.class};
+ Constructor> mimeMessageConstructor =
+ mimeMessageClass.getConstructor(constructorArgs);
+ Object message =
+ mimeMessageConstructor.newInstance(session, new ByteArrayInputStream(emlBytes));
+
+ return extractEmailContentAdvanced(message, request);
+
+ } catch (ReflectiveOperationException e) {
+ // Create basic EmailContent from basic processing
+ EmailContent content = new EmailContent();
+ content.setHtmlBody(convertEmlToHtmlBasic(emlBytes, request));
+ return content;
+ }
+ }
+
+ private static String convertEmlToHtmlAdvanced(byte[] emlBytes, EmlToPdfRequest request) {
+ EmailContent content = extractEmailContentAdvanced(emlBytes, request);
+ return generateEnhancedEmailHtml(content, request);
+ }
+
+ private static String extractAttachmentInfo(String emlContent) {
+ StringBuilder attachmentInfo = new StringBuilder();
+ try {
+ String[] lines = emlContent.split("\r?\n");
+ boolean inHeaders = true;
+ String currentContentType = "";
+ String currentDisposition = "";
+ String currentFilename = "";
+ String currentEncoding = "";
+ boolean inMultipart = false;
+ String boundary = "";
+
+ // First pass: find boundary for multipart messages
+ for (String line : lines) {
+ String lowerLine = line.toLowerCase().trim();
+ if (lowerLine.startsWith("content-type:") && lowerLine.contains("multipart")) {
+ if (lowerLine.contains("boundary=")) {
+ int boundaryStart = lowerLine.indexOf("boundary=") + 9;
+ String boundaryPart = line.substring(boundaryStart).trim();
+ if (boundaryPart.startsWith("\"")) {
+ boundary = boundaryPart.substring(1, boundaryPart.indexOf("\"", 1));
+ } else {
+ int spaceIndex = boundaryPart.indexOf(" ");
+ boundary =
+ spaceIndex > 0
+ ? boundaryPart.substring(0, spaceIndex)
+ : boundaryPart;
+ }
+ inMultipart = true;
+ break;
+ }
+ }
+ if (line.trim().isEmpty()) break;
+ }
+
+ // Second pass: extract attachment information
+ for (String line : lines) {
+ String lowerLine = line.toLowerCase().trim();
+
+ // Check for boundary markers in multipart messages
+ if (inMultipart && line.trim().startsWith("--" + boundary)) {
+ // Reset for new part
+ currentContentType = "";
+ currentDisposition = "";
+ currentFilename = "";
+ currentEncoding = "";
+ inHeaders = true;
+ continue;
+ }
+
+ if (inHeaders && line.trim().isEmpty()) {
+ inHeaders = false;
+
+ // Process accumulated attachment info
+ if (isAttachment(currentDisposition, currentFilename, currentContentType)) {
+ addAttachmentToInfo(
+ attachmentInfo,
+ currentFilename,
+ currentContentType,
+ currentEncoding);
+
+ // Reset for next attachment
+ currentContentType = "";
+ currentDisposition = "";
+ currentFilename = "";
+ currentEncoding = "";
+ }
+ continue;
+ }
+
+ if (!inHeaders) continue; // Skip body content
+
+ // Parse headers
+ if (lowerLine.startsWith("content-type:")) {
+ currentContentType = line.substring(13).trim();
+ } else if (lowerLine.startsWith("content-disposition:")) {
+ currentDisposition = line.substring(20).trim();
+ // Extract filename if present
+ currentFilename = extractFilenameFromDisposition(currentDisposition);
+ } else if (lowerLine.startsWith("content-transfer-encoding:")) {
+ currentEncoding = line.substring(26).trim();
+ } else if (line.startsWith(" ") || line.startsWith("\t")) {
+ // Continuation of previous header
+ if (currentDisposition.contains("filename=")) {
+ currentDisposition += " " + line.trim();
+ currentFilename = extractFilenameFromDisposition(currentDisposition);
+ } else if (!currentContentType.isEmpty()) {
+ currentContentType += " " + line.trim();
+ }
+ }
+ }
+
+ if (isAttachment(currentDisposition, currentFilename, currentContentType)) {
+ addAttachmentToInfo(
+ attachmentInfo, currentFilename, currentContentType, currentEncoding);
+ }
+
+ } catch (RuntimeException e) {
+ log.warn("Error extracting attachment info: {}", e.getMessage());
+ }
+ return attachmentInfo.toString();
+ }
+
+ private static boolean isAttachment(String disposition, String filename, String contentType) {
+ return (disposition.toLowerCase().contains("attachment") && !filename.isEmpty())
+ || (!filename.isEmpty() && !contentType.toLowerCase().startsWith("text/"))
+ || (contentType.toLowerCase().contains("application/") && !filename.isEmpty());
+ }
+
+ private static String extractFilenameFromDisposition(String disposition) {
+ if (disposition.contains("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("^\"|\"$", "");
+ // Apply MIME decoding to handle encoded filenames
+ return safeMimeDecode(filename);
+ }
+ return "";
+ }
+
+ private static void addAttachmentToInfo(
+ StringBuilder attachmentInfo, String filename, String contentType, String encoding) {
+ // Create attachment info with paperclip emoji before filename
+ attachmentInfo
+ .append("")
+ .append("")
+ .append(MimeConstants.ATTACHMENT_MARKER)
+ .append(" ")
+ .append("")
+ .append(escapeHtml(filename))
+ .append(" ");
+
+ // Add content type and encoding info
+ if (!contentType.isEmpty() || !encoding.isEmpty()) {
+ attachmentInfo.append(" (");
+ if (!contentType.isEmpty()) {
+ attachmentInfo.append(escapeHtml(contentType));
+ }
+ if (!encoding.isEmpty()) {
+ if (!contentType.isEmpty()) attachmentInfo.append(", ");
+ attachmentInfo.append("encoding: ").append(escapeHtml(encoding));
+ }
+ attachmentInfo.append(") ");
+ }
+ attachmentInfo.append("
\n");
+ }
+
+ private static boolean isInvalidEmlFormat(byte[] emlBytes) {
+ try {
+ int checkLength = Math.min(emlBytes.length, StyleConstants.EML_CHECK_LENGTH);
+ String content = new String(emlBytes, 0, checkLength, StandardCharsets.UTF_8);
+ String lowerContent = content.toLowerCase();
+
+ 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 < StyleConstants.MIN_HEADER_COUNT_FOR_VALID_EML && !hasMimeStructure;
+
+ } catch (RuntimeException e) {
+ return false;
+ }
+ }
+
+ 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());
+ // Handle multi-line headers
+ 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;
+ }
+ }
+ // Apply MIME header decoding
+ return safeMimeDecode(value.toString());
+ }
+ if (line.trim().isEmpty()) break;
+ }
+ } catch (RuntimeException e) {
+ log.warn("Error extracting header '{}': {}", headerName, e.getMessage());
+ }
+ return "";
+ }
+
+ private static String extractHtmlBody(String emlContent) {
+ try {
+ String lowerContent = emlContent.toLowerCase();
+ int htmlStart = lowerContent.indexOf("content-type: text/html");
+ if (htmlStart == -1) return null;
+
+ return getString(emlContent, htmlStart);
+
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ @Nullable
+ private static String getString(String emlContent, int htmlStart) {
+ 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();
+ }
+
+ private static String extractTextBody(String emlContent) {
+ try {
+ String lowerContent = emlContent.toLowerCase();
+ int textStart = lowerContent.indexOf("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;
+ }
+
+ return getString(emlContent, textStart);
+
+ } 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 String convertTextToHtml(String textBody) {
+ if (textBody == null) return "";
+
+ String html = escapeHtml(textBody);
+ html = html.replace("\r\n", "\n").replace("\r", "\n");
+ html = html.replace("\n", " \n");
+
+ html =
+ html.replaceAll(
+ "(https?://[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+)",
+ "$1 ");
+
+ html =
+ html.replaceAll(
+ "([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,63})",
+ "$1 ");
+
+ return html;
+ }
+
+ private static String processEmailHtmlBody(String htmlBody) {
+ return processEmailHtmlBody(htmlBody, null);
+ }
+
+ private static String processEmailHtmlBody(String htmlBody, EmailContent emailContent) {
+ if (htmlBody == null) return "";
+
+ String processed = htmlBody;
+
+ // Remove problematic CSS
+ processed = processed.replaceAll("(?i)\\s*position\\s*:\\s*fixed[^;]*;?", "");
+ processed = processed.replaceAll("(?i)\\s*position\\s*:\\s*absolute[^;]*;?", "");
+
+ // Process inline images (cid: references) if we have email content with attachments
+ if (emailContent != null && !emailContent.getAttachments().isEmpty()) {
+ processed = processInlineImages(processed, emailContent);
+ }
+
+ return processed;
+ }
+
+ private static String processInlineImages(String htmlContent, EmailContent emailContent) {
+ if (htmlContent == null || emailContent == null) return htmlContent;
+
+ // Create a map of Content-ID to attachment data
+ Map contentIdMap = new HashMap<>();
+ for (EmailAttachment attachment : emailContent.getAttachments()) {
+ if (attachment.isEmbedded()
+ && attachment.getContentId() != null
+ && attachment.getData() != null) {
+ contentIdMap.put(attachment.getContentId(), attachment);
+ }
+ }
+
+ if (contentIdMap.isEmpty()) return htmlContent;
+
+ // Pattern to match cid: references in img src attributes
+ Pattern cidPattern =
+ Pattern.compile(
+ "(?i) ]*\\ssrc\\s*=\\s*['\"]cid:([^'\"]+)['\"][^>]*>",
+ Pattern.CASE_INSENSITIVE);
+ Matcher matcher = cidPattern.matcher(htmlContent);
+
+ StringBuffer result = new StringBuffer();
+ while (matcher.find()) {
+ String contentId = matcher.group(1);
+ EmailAttachment attachment = contentIdMap.get(contentId);
+
+ if (attachment != null && attachment.getData() != null) {
+ // Convert to data URI
+ String mimeType = attachment.getContentType();
+ if (mimeType == null || mimeType.isEmpty()) {
+ // Try to determine MIME type from filename
+ String filename = attachment.getFilename();
+ if (filename != null) {
+ if (filename.toLowerCase().endsWith(".png")) {
+ mimeType = "image/png";
+ } else if (filename.toLowerCase().endsWith(".jpg")
+ || filename.toLowerCase().endsWith(".jpeg")) {
+ mimeType = "image/jpeg";
+ } else if (filename.toLowerCase().endsWith(".gif")) {
+ mimeType = "image/gif";
+ } else if (filename.toLowerCase().endsWith(".bmp")) {
+ mimeType = "image/bmp";
+ } else {
+ mimeType = "image/png"; // fallback
+ }
+ } else {
+ mimeType = "image/png"; // fallback
+ }
+ }
+
+ String base64Data = Base64.getEncoder().encodeToString(attachment.getData());
+ String dataUri = "data:" + mimeType + ";base64," + base64Data;
+
+ // Replace the cid: reference with the data URI
+ String replacement =
+ matcher.group(0).replaceFirst("cid:" + Pattern.quote(contentId), dataUri);
+ matcher.appendReplacement(result, Matcher.quoteReplacement(replacement));
+ } else {
+ // Keep original if attachment not found
+ matcher.appendReplacement(result, Matcher.quoteReplacement(matcher.group(0)));
+ }
+ }
+ matcher.appendTail(result);
+
+ return result.toString();
+ }
+
+ private static void appendEnhancedStyles(StringBuilder html) {
+ int fontSize = StyleConstants.DEFAULT_FONT_SIZE;
+ String textColor = StyleConstants.DEFAULT_TEXT_COLOR;
+ String backgroundColor = StyleConstants.DEFAULT_BACKGROUND_COLOR;
+ String borderColor = StyleConstants.DEFAULT_BORDER_COLOR;
+
+ html.append("body {\n");
+ html.append(" font-family: ").append(StyleConstants.DEFAULT_FONT_FAMILY).append(";\n");
+ html.append(" font-size: ").append(fontSize).append("px;\n");
+ html.append(" line-height: ").append(StyleConstants.DEFAULT_LINE_HEIGHT).append(";\n");
+ html.append(" color: ").append(textColor).append(";\n");
+ html.append(" margin: 0;\n");
+ html.append(" padding: 16px;\n");
+ html.append(" background-color: ").append(backgroundColor).append(";\n");
+ html.append("}\n\n");
+
+ html.append(".email-container {\n");
+ html.append(" width: 100%;\n");
+ html.append(" max-width: 100%;\n");
+ html.append(" margin: 0 auto;\n");
+ html.append("}\n\n");
+
+ html.append(".email-header {\n");
+ html.append(" padding-bottom: 10px;\n");
+ html.append(" border-bottom: 1px solid ").append(borderColor).append(";\n");
+ html.append(" margin-bottom: 10px;\n");
+ html.append("}\n\n");
+ html.append(".email-header h1 {\n");
+ html.append(" margin: 0 0 10px 0;\n");
+ html.append(" font-size: ").append(fontSize + 4).append("px;\n");
+ html.append(" font-weight: bold;\n");
+ html.append("}\n\n");
+ html.append(".email-meta div {\n");
+ html.append(" margin-bottom: 2px;\n");
+ html.append(" font-size: ").append(fontSize - 1).append("px;\n");
+ html.append("}\n\n");
+
+ html.append(".email-body {\n");
+ html.append(" word-wrap: break-word;\n");
+ html.append("}\n\n");
+
+ html.append(".attachment-section {\n");
+ html.append(" margin-top: 15px;\n");
+ html.append(" padding: 10px;\n");
+ html.append(" background-color: ")
+ .append(StyleConstants.ATTACHMENT_BACKGROUND_COLOR)
+ .append(";\n");
+ html.append(" border: 1px solid ")
+ .append(StyleConstants.ATTACHMENT_BORDER_COLOR)
+ .append(";\n");
+ html.append(" border-radius: 3px;\n");
+ html.append("}\n\n");
+ html.append(".attachment-section h3 {\n");
+ html.append(" margin: 0 0 8px 0;\n");
+ html.append(" font-size: ").append(fontSize + 1).append("px;\n");
+ html.append("}\n\n");
+ html.append(".attachment-item {\n");
+ html.append(" padding: 5px 0;\n");
+ html.append("}\n\n");
+ html.append(".attachment-icon {\n");
+ html.append(" margin-right: 5px;\n");
+ html.append("}\n\n");
+ html.append(".attachment-details, .attachment-type {\n");
+ html.append(" font-size: ").append(fontSize - 2).append("px;\n");
+ html.append(" color: #555555;\n");
+ html.append("}\n\n");
+ html.append(".attachment-inclusion-note, .attachment-info-note {\n");
+ html.append(" margin-top: 8px;\n");
+ html.append(" padding: 6px;\n");
+ html.append(" font-size: ").append(fontSize - 2).append("px;\n");
+ html.append(" border-radius: 3px;\n");
+ html.append("}\n\n");
+ html.append(".attachment-inclusion-note {\n");
+ html.append(" background-color: #e6ffed;\n");
+ html.append(" border: 1px solid #d4f7dc;\n");
+ html.append(" color: #006420;\n");
+ html.append("}\n\n");
+ html.append(".attachment-info-note {\n");
+ html.append(" background-color: #fff9e6;\n");
+ html.append(" border: 1px solid #fff0c2;\n");
+ html.append(" color: #664d00;\n");
+ html.append("}\n\n");
+ html.append(".attachment-link-container {\n");
+ html.append(" display: flex;\n");
+ html.append(" align-items: center;\n");
+ html.append(" padding: 8px;\n");
+ html.append(" background-color: #f8f9fa;\n");
+ html.append(" border: 1px solid #dee2e6;\n");
+ html.append(" border-radius: 4px;\n");
+ html.append(" margin: 4px 0;\n");
+ html.append("}\n\n");
+ html.append(".attachment-link-container:hover {\n");
+ html.append(" background-color: #e9ecef;\n");
+ html.append("}\n\n");
+ html.append(".attachment-note {\n");
+ html.append(" font-size: ").append(fontSize - 3).append("px;\n");
+ html.append(" color: #6c757d;\n");
+ html.append(" font-style: italic;\n");
+ html.append(" margin-left: 8px;\n");
+ html.append("}\n\n");
+
+ // Basic image styling: ensure images are responsive but not overly constrained.
+ html.append("img {\n");
+ html.append(" max-width: 100%;\n"); // Make images responsive to container width
+ html.append(" height: auto;\n"); // Maintain aspect ratio
+ html.append(" display: block;\n"); // Avoid extra space below images
+ html.append("}\n\n");
+ }
+
+ private static String escapeHtml(String text) {
+ if (text == null) return "";
+ return text.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\"", """)
+ .replace("'", "'");
+ }
+
+ private static stirling.software.common.model.api.converters.HTMLToPdfRequest createHtmlRequest(
+ EmlToPdfRequest request) {
+ stirling.software.common.model.api.converters.HTMLToPdfRequest htmlRequest =
+ new stirling.software.common.model.api.converters.HTMLToPdfRequest();
+
+ if (request != null) {
+ htmlRequest.setFileInput(request.getFileInput());
+ }
+
+ // Set default zoom level
+ htmlRequest.setZoom(Float.parseFloat(StyleConstants.DEFAULT_ZOOM));
+
+ return htmlRequest;
+ }
+
+ private static EmailContent extractEmailContentAdvanced(
+ Object message, EmlToPdfRequest request) {
+ EmailContent content = new EmailContent();
+
+ try {
+ Class> messageClass = message.getClass();
+
+ // Extract headers via reflection
+ java.lang.reflect.Method getSubject = messageClass.getMethod("getSubject");
+ String subject = (String) getSubject.invoke(message);
+ content.setSubject(subject != null ? safeMimeDecode(subject) : "No Subject");
+
+ java.lang.reflect.Method getFrom = messageClass.getMethod("getFrom");
+ Object[] fromAddresses = (Object[]) getFrom.invoke(message);
+ content.setFrom(
+ fromAddresses != null && fromAddresses.length > 0
+ ? safeMimeDecode(fromAddresses[0].toString())
+ : "");
+
+ java.lang.reflect.Method getAllRecipients = messageClass.getMethod("getAllRecipients");
+ Object[] recipients = (Object[]) getAllRecipients.invoke(message);
+ content.setTo(
+ recipients != null && recipients.length > 0
+ ? safeMimeDecode(recipients[0].toString())
+ : "");
+
+ java.lang.reflect.Method getSentDate = messageClass.getMethod("getSentDate");
+ content.setDate((Date) getSentDate.invoke(message));
+
+ // Extract content
+ java.lang.reflect.Method getContent = messageClass.getMethod("getContent");
+ Object messageContent = getContent.invoke(message);
+
+ if (messageContent instanceof String stringContent) {
+ java.lang.reflect.Method getContentType = messageClass.getMethod("getContentType");
+ String contentType = (String) getContentType.invoke(message);
+ if (contentType != null && contentType.toLowerCase().contains("text/html")) {
+ content.setHtmlBody(stringContent);
+ } else {
+ content.setTextBody(stringContent);
+ }
+ } else {
+ // Handle multipart content
+ try {
+ Class> multipartClass = Class.forName("jakarta.mail.Multipart");
+ if (multipartClass.isInstance(messageContent)) {
+ processMultipartAdvanced(messageContent, content, request);
+ }
+ } catch (Exception e) {
+ log.warn("Error processing content: {}", e.getMessage());
+ }
+ }
+
+ } catch (Exception e) {
+ content.setSubject("Email Conversion");
+ content.setFrom("Unknown");
+ content.setTo("Unknown");
+ content.setTextBody("Email content could not be parsed with advanced processing");
+ }
+
+ return content;
+ }
+
+ private static void processMultipartAdvanced(
+ Object multipart, EmailContent content, EmlToPdfRequest request) {
+ try {
+ // Enhanced multipart type checking
+ if (!isValidJakartaMailMultipart(multipart)) {
+ log.warn("Invalid Jakarta Mail multipart type: {}", multipart.getClass().getName());
+ return;
+ }
+
+ Class> multipartClass = multipart.getClass();
+ java.lang.reflect.Method getCount = multipartClass.getMethod("getCount");
+ int count = (Integer) getCount.invoke(multipart);
+
+ java.lang.reflect.Method getBodyPart =
+ multipartClass.getMethod("getBodyPart", int.class);
+
+ for (int i = 0; i < count; i++) {
+ Object part = getBodyPart.invoke(multipart, i);
+ processPartAdvanced(part, content, request);
+ }
+
+ } catch (Exception e) {
+ content.setTextBody("Email content could not be parsed with advanced processing");
+ }
+ }
+
+ private static void processPartAdvanced(
+ Object part, EmailContent content, EmlToPdfRequest request) {
+ try {
+ if (!isValidJakartaMailPart(part)) {
+ log.warn("Invalid Jakarta Mail part type: {}", part.getClass().getName());
+ return;
+ }
+
+ Class> partClass = part.getClass();
+ java.lang.reflect.Method isMimeType = partClass.getMethod("isMimeType", String.class);
+ java.lang.reflect.Method getContent = partClass.getMethod("getContent");
+ java.lang.reflect.Method getDisposition = partClass.getMethod("getDisposition");
+ java.lang.reflect.Method getFileName = partClass.getMethod("getFileName");
+ java.lang.reflect.Method getContentType = partClass.getMethod("getContentType");
+ java.lang.reflect.Method getHeader = partClass.getMethod("getHeader", String.class);
+
+ Object disposition = getDisposition.invoke(part);
+ String filename = (String) getFileName.invoke(part);
+ String contentType = (String) getContentType.invoke(part);
+
+ if ((Boolean) isMimeType.invoke(part, "text/plain") && disposition == null) {
+ content.setTextBody((String) getContent.invoke(part));
+ } else if ((Boolean) isMimeType.invoke(part, "text/html") && disposition == null) {
+ content.setHtmlBody((String) getContent.invoke(part));
+ } else if ("attachment".equalsIgnoreCase((String) disposition)
+ || (filename != null && !filename.trim().isEmpty())) {
+
+ content.setAttachmentCount(content.getAttachmentCount() + 1);
+
+ // Always extract basic attachment metadata for display
+ if (filename != null && !filename.trim().isEmpty()) {
+ // Create attachment with metadata only
+ EmailAttachment attachment = new EmailAttachment();
+ // Apply MIME decoding to filename to handle encoded attachment names
+ attachment.setFilename(safeMimeDecode(filename));
+ attachment.setContentType(contentType);
+
+ // Check if it's an embedded image
+ String[] contentIdHeaders = (String[]) getHeader.invoke(part, "Content-ID");
+ if (contentIdHeaders != null && contentIdHeaders.length > 0) {
+ attachment.setEmbedded(true);
+ // Store the Content-ID, removing angle brackets if present
+ String contentId = contentIdHeaders[0];
+ if (contentId.startsWith("<") && contentId.endsWith(">")) {
+ contentId = contentId.substring(1, contentId.length() - 1);
+ }
+ attachment.setContentId(contentId);
+ }
+
+ // Extract attachment data if attachments should be included OR if it's an
+ // embedded image (needed for inline display)
+ if ((request != null && request.isIncludeAttachments())
+ || attachment.isEmbedded()) {
+ try {
+ Object attachmentContent = getContent.invoke(part);
+ byte[] attachmentData = null;
+
+ if (attachmentContent instanceof java.io.InputStream inputStream) {
+ try {
+ attachmentData = inputStream.readAllBytes();
+ } catch (IOException e) {
+ log.warn(
+ "Failed to read InputStream attachment: {}",
+ e.getMessage());
+ }
+ } else if (attachmentContent instanceof byte[] byteArray) {
+ attachmentData = byteArray;
+ } else if (attachmentContent instanceof String stringContent) {
+ attachmentData = stringContent.getBytes(StandardCharsets.UTF_8);
+ }
+
+ if (attachmentData != null) {
+ // Check size limit (use default 10MB if request is null)
+ long maxSizeMB =
+ request != null ? request.getMaxAttachmentSizeMB() : 10L;
+ long maxSizeBytes = maxSizeMB * 1024 * 1024;
+
+ if (attachmentData.length <= maxSizeBytes) {
+ attachment.setData(attachmentData);
+ attachment.setSizeBytes(attachmentData.length);
+ } else {
+ // For embedded images, always include data regardless of size
+ // to ensure inline display works
+ if (attachment.isEmbedded()) {
+ attachment.setData(attachmentData);
+ attachment.setSizeBytes(attachmentData.length);
+ } else {
+ // Still show attachment info even if too large
+ attachment.setSizeBytes(attachmentData.length);
+ }
+ }
+ }
+ } catch (Exception e) {
+ log.warn("Error extracting attachment data: {}", e.getMessage());
+ }
+ }
+
+ // Add attachment to the list for display (with or without data)
+ content.getAttachments().add(attachment);
+ }
+ } else if ((Boolean) isMimeType.invoke(part, "multipart/*")) {
+ // Handle nested multipart content
+ try {
+ Object multipartContent = getContent.invoke(part);
+ Class> multipartClass = Class.forName("jakarta.mail.Multipart");
+ if (multipartClass.isInstance(multipartContent)) {
+ processMultipartAdvanced(multipartContent, content, request);
+ }
+ } catch (Exception e) {
+ log.warn("Error processing multipart content: {}", e.getMessage());
+ }
+ }
+
+ } catch (Exception e) {
+ log.warn("Error processing multipart part: {}", e.getMessage());
+ }
+ }
+
+ private static String generateEnhancedEmailHtml(EmailContent content, EmlToPdfRequest request) {
+ StringBuilder html = new StringBuilder();
+
+ html.append("\n");
+ html.append(" \n");
+ html.append("").append(escapeHtml(content.getSubject())).append(" \n");
+ html.append("\n");
+ html.append("\n");
+
+ html.append("\n");
+ html.append("\n");
+
+ html.append("
\n");
+ if (content.getHtmlBody() != null && !content.getHtmlBody().trim().isEmpty()) {
+ html.append(processEmailHtmlBody(content.getHtmlBody(), content));
+ } else if (content.getTextBody() != null && !content.getTextBody().trim().isEmpty()) {
+ html.append("
");
+ html.append(convertTextToHtml(content.getTextBody()));
+ html.append("
");
+ } else {
+ html.append("
");
+ html.append("
No content available
");
+ html.append("
");
+ }
+ html.append("
\n");
+
+ if (content.getAttachmentCount() > 0 || !content.getAttachments().isEmpty()) {
+ html.append("
\n");
+ int displayedAttachmentCount =
+ content.getAttachmentCount() > 0
+ ? content.getAttachmentCount()
+ : content.getAttachments().size();
+ html.append("
Attachments (").append(displayedAttachmentCount).append(") \n");
+
+ if (!content.getAttachments().isEmpty()) {
+ for (EmailAttachment attachment : content.getAttachments()) {
+ // Create attachment info with paperclip emoji before filename
+ String uniqueId = generateUniqueAttachmentId(attachment.getFilename());
+ attachment.setEmbeddedFilename(
+ attachment.getEmbeddedFilename() != null
+ ? attachment.getEmbeddedFilename()
+ : attachment.getFilename());
+
+ html.append("
")
+ .append("")
+ .append(MimeConstants.ATTACHMENT_MARKER)
+ .append(" ")
+ .append("")
+ .append(escapeHtml(safeMimeDecode(attachment.getFilename())))
+ .append(" ");
+
+ String sizeStr = formatFileSize(attachment.getSizeBytes());
+ html.append(" (").append(sizeStr);
+ if (attachment.getContentType() != null
+ && !attachment.getContentType().isEmpty()) {
+ html.append(", ").append(escapeHtml(attachment.getContentType()));
+ }
+ html.append(")
\n");
+ }
+ }
+
+ if (request.isIncludeAttachments()) {
+ html.append("
\n");
+ html.append("
Attachments are embedded in the file.
\n");
+ html.append("
\n");
+ } else {
+ html.append("
\n");
+ html.append(
+ "
Attachment information displayed - files not included in PDF.
\n");
+ html.append("
\n");
+ }
+
+ html.append("
\n");
+ }
+
+ html.append("
\n");
+ html.append("");
+
+ return html.toString();
+ }
+
+ private static byte[] attachFilesToPdf(
+ byte[] pdfBytes,
+ List attachments,
+ stirling.software.common.service.CustomPDFDocumentFactory pdfDocumentFactory)
+ throws IOException {
+ try (PDDocument document = pdfDocumentFactory.load(pdfBytes);
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
+
+ if (attachments == null || attachments.isEmpty()) {
+ document.save(outputStream);
+ return outputStream.toByteArray();
+ }
+
+ List embeddedFiles = new ArrayList<>();
+
+ // Set up the embedded files name tree once
+ if (document.getDocumentCatalog().getNames() == null) {
+ document.getDocumentCatalog()
+ .setNames(new PDDocumentNameDictionary(document.getDocumentCatalog()));
+ }
+
+ PDDocumentNameDictionary names = document.getDocumentCatalog().getNames();
+ if (names.getEmbeddedFiles() == null) {
+ names.setEmbeddedFiles(new PDEmbeddedFilesNameTreeNode());
+ }
+
+ PDEmbeddedFilesNameTreeNode efTree = names.getEmbeddedFiles();
+ Map efMap = efTree.getNames();
+ if (efMap == null) {
+ efMap = new HashMap<>();
+ }
+
+ // Embed each attachment directly into the PDF
+ for (EmailAttachment attachment : attachments) {
+ if (attachment.getData() == null || attachment.getData().length == 0) {
+ continue;
+ }
+
+ try {
+ // Generate unique filename
+ String filename = attachment.getFilename();
+ if (filename == null || filename.trim().isEmpty()) {
+ filename = "attachment_" + System.currentTimeMillis();
+ if (attachment.getContentType() != null
+ && attachment.getContentType().contains("/")) {
+ String[] parts = attachment.getContentType().split("/");
+ if (parts.length > 1) {
+ filename += "." + parts[1];
+ }
+ }
+ }
+
+ // Ensure unique filename
+ String uniqueFilename = getUniqueFilename(filename, embeddedFiles, efMap);
+
+ // Create embedded file
+ PDEmbeddedFile embeddedFile =
+ new PDEmbeddedFile(
+ document, new ByteArrayInputStream(attachment.getData()));
+ embeddedFile.setSize(attachment.getData().length);
+ embeddedFile.setCreationDate(new GregorianCalendar());
+ if (attachment.getContentType() != null) {
+ embeddedFile.setSubtype(attachment.getContentType());
+ }
+
+ // Create file specification
+ PDComplexFileSpecification fileSpec = new PDComplexFileSpecification();
+ fileSpec.setFile(uniqueFilename);
+ fileSpec.setEmbeddedFile(embeddedFile);
+ if (attachment.getContentType() != null) {
+ fileSpec.setFileDescription("Email attachment: " + uniqueFilename);
+ }
+
+ // Add to the map (but don't set it yet)
+ efMap.put(uniqueFilename, fileSpec);
+ embeddedFiles.add(uniqueFilename);
+
+ // Store the filename for annotation creation
+ attachment.setEmbeddedFilename(uniqueFilename);
+
+ } catch (Exception e) {
+ // Log error but continue with other attachments
+ log.warn("Failed to embed attachment: {}", attachment.getFilename(), e);
+ }
+ }
+
+ // Set the complete map once at the end
+ if (!efMap.isEmpty()) {
+ efTree.setNames(efMap);
+
+ // Set catalog viewer preferences to automatically show attachments pane
+ setCatalogViewerPreferences(document);
+ }
+
+ // Add attachment annotations to the first page for each embedded file
+ if (!embeddedFiles.isEmpty()) {
+ addAttachmentAnnotationsToDocument(document, attachments);
+ }
+
+ document.save(outputStream);
+ return outputStream.toByteArray();
+ }
+ }
+
+ private static String getUniqueFilename(
+ String filename,
+ List embeddedFiles,
+ Map efMap) {
+ String uniqueFilename = filename;
+ int counter = 1;
+ while (embeddedFiles.contains(uniqueFilename) || efMap.containsKey(uniqueFilename)) {
+ String extension = "";
+ String baseName = filename;
+ int lastDot = filename.lastIndexOf('.');
+ if (lastDot > 0) {
+ extension = filename.substring(lastDot);
+ baseName = filename.substring(0, lastDot);
+ }
+ uniqueFilename = baseName + "_" + counter + extension;
+ counter++;
+ }
+ return uniqueFilename;
+ }
+
+ private static void addAttachmentAnnotationsToDocument(
+ PDDocument document, List attachments) throws IOException {
+ if (document.getNumberOfPages() == 0 || attachments == null || attachments.isEmpty()) {
+ return;
+ }
+
+ // 1. Find the screen position of all attachment markers
+ AttachmentMarkerPositionFinder finder = new AttachmentMarkerPositionFinder();
+ finder.setSortByPosition(true); // Process pages in order
+ finder.getText(document);
+ List markerPositions = finder.getPositions();
+
+ // 2. Warn if the number of markers and attachments don't match
+ if (markerPositions.size() != attachments.size()) {
+ log.warn(
+ "Found {} attachment markers, but there are {} attachments. Annotation count may be incorrect.",
+ markerPositions.size(),
+ attachments.size());
+ }
+
+ // 3. Create an invisible annotation over each found marker
+ int annotationsToAdd = Math.min(markerPositions.size(), attachments.size());
+ for (int i = 0; i < annotationsToAdd; i++) {
+ MarkerPosition position = markerPositions.get(i);
+ EmailAttachment attachment = attachments.get(i);
+
+ if (attachment.getEmbeddedFilename() != null) {
+ PDPage page = document.getPage(position.getPageIndex());
+ addAttachmentAnnotationToPage(
+ document, page, attachment, position.getX(), position.getY());
+ }
+ }
+ }
+
+ private static void addAttachmentAnnotationToPage(
+ PDDocument document, PDPage page, EmailAttachment attachment, float x, float y)
+ throws IOException {
+
+ PDAnnotationFileAttachment fileAnnotation = new PDAnnotationFileAttachment();
+
+ PDRectangle rect = getPdRectangle(page, x, y);
+ fileAnnotation.setRectangle(rect);
+
+ // Remove visual appearance while keeping clickable functionality
+ try {
+ PDAppearanceDictionary appearance = new PDAppearanceDictionary();
+ PDAppearanceStream normalAppearance = new PDAppearanceStream(document);
+ normalAppearance.setBBox(new PDRectangle(0, 0, 0, 0)); // Zero-size bounding box
+
+ appearance.setNormalAppearance(normalAppearance);
+ fileAnnotation.setAppearance(appearance);
+ } catch (Exception e) {
+ // If appearance manipulation fails, just set it to null
+ fileAnnotation.setAppearance(null);
+ }
+
+ // Set invisibility flags but keep it functional
+ fileAnnotation.setInvisible(true);
+ fileAnnotation.setHidden(false); // Must be false to remain clickable
+ fileAnnotation.setNoView(false); // Must be false to remain clickable
+ fileAnnotation.setPrinted(false);
+
+ PDEmbeddedFilesNameTreeNode efTree =
+ document.getDocumentCatalog().getNames().getEmbeddedFiles();
+ if (efTree != null) {
+ Map efMap = efTree.getNames();
+ if (efMap != null) {
+ PDComplexFileSpecification fileSpec = efMap.get(attachment.getEmbeddedFilename());
+ if (fileSpec != null) {
+ fileAnnotation.setFile(fileSpec);
+ }
+ }
+ }
+
+ fileAnnotation.setContents("Click to open: " + attachment.getFilename());
+ fileAnnotation.setAnnotationName("EmbeddedFile_" + attachment.getEmbeddedFilename());
+
+ page.getAnnotations().add(fileAnnotation);
+
+ log.info(
+ "Added attachment annotation for '{}' on page {}",
+ attachment.getFilename(),
+ document.getPages().indexOf(page) + 1);
+ }
+
+ private static @NotNull PDRectangle getPdRectangle(PDPage page, float x, float y) {
+ PDRectangle mediaBox = page.getMediaBox();
+ float pdfY = mediaBox.getHeight() - y;
+
+ float iconWidth =
+ StyleConstants.ATTACHMENT_ICON_WIDTH; // Keep original size for clickability
+ float iconHeight =
+ StyleConstants.ATTACHMENT_ICON_HEIGHT; // Keep original size for clickability
+
+ // Keep the full-size rectangle so it remains clickable
+ return new PDRectangle(
+ x + StyleConstants.ANNOTATION_X_OFFSET,
+ pdfY - iconHeight + StyleConstants.ANNOTATION_Y_OFFSET,
+ iconWidth,
+ iconHeight);
+ }
+
+ private static String formatEmailDate(Date date) {
+ if (date == null) return "";
+ java.text.SimpleDateFormat formatter =
+ new java.text.SimpleDateFormat("EEE, MMM d, yyyy 'at' h:mm a", Locale.ENGLISH);
+ return formatter.format(date);
+ }
+
+ private static String formatFileSize(long bytes) {
+ if (bytes < FileSizeConstants.BYTES_IN_KB) {
+ return bytes + " B";
+ } else if (bytes < FileSizeConstants.BYTES_IN_MB) {
+ return String.format("%.1f KB", bytes / (double) FileSizeConstants.BYTES_IN_KB);
+ } else if (bytes < FileSizeConstants.BYTES_IN_GB) {
+ return String.format("%.1f MB", bytes / (double) FileSizeConstants.BYTES_IN_MB);
+ } else {
+ return String.format("%.1f GB", bytes / (double) FileSizeConstants.BYTES_IN_GB);
+ }
+ }
+
+ private static void setCatalogViewerPreferences(PDDocument document) {
+ try {
+ PDDocumentCatalog catalog = document.getDocumentCatalog();
+ if (catalog != null) {
+ // Get the catalog's COS dictionary to work with low-level PDF objects
+ COSDictionary catalogDict = catalog.getCOSObject();
+
+ // Set PageMode to UseAttachments - this is the standard PDF specification approach
+ // PageMode values: UseNone, UseOutlines, UseThumbs, FullScreen, UseOC,
+ // UseAttachments
+ catalogDict.setName(COSName.PAGE_MODE, "UseAttachments");
+
+ // Also set viewer preferences for better attachment viewing experience
+ COSDictionary viewerPrefs =
+ (COSDictionary) catalogDict.getDictionaryObject(COSName.VIEWER_PREFERENCES);
+ if (viewerPrefs == null) {
+ viewerPrefs = new COSDictionary();
+ catalogDict.setItem(COSName.VIEWER_PREFERENCES, viewerPrefs);
+ }
+
+ // Set NonFullScreenPageMode to UseAttachments as fallback for viewers that support
+ // it
+ viewerPrefs.setName(COSName.getPDFName("NonFullScreenPageMode"), "UseAttachments");
+
+ // Additional viewer preferences that may help with attachment display
+ viewerPrefs.setBoolean(COSName.getPDFName("DisplayDocTitle"), true);
+
+ log.info(
+ "Set PDF PageMode to UseAttachments to automatically show attachments pane");
+ }
+ } catch (Exception e) {
+ // Log warning but don't fail the entire operation for viewer preferences
+ log.warn("Failed to set catalog viewer preferences for attachments", e);
+ }
+ }
+
+ private static String decodeMimeHeader(String encodedText) {
+ if (encodedText == null || encodedText.trim().isEmpty()) {
+ return encodedText;
+ }
+
+ try {
+ StringBuilder result = new StringBuilder();
+ Matcher matcher = MimeConstants.MIME_ENCODED_PATTERN.matcher(encodedText);
+ int lastEnd = 0;
+
+ while (matcher.find()) {
+ // Add any text before the encoded part
+ result.append(encodedText, lastEnd, matcher.start());
+
+ String charset = matcher.group(1);
+ String encoding = matcher.group(2).toUpperCase();
+ String encodedValue = matcher.group(3);
+
+ try {
+ String decodedValue;
+ if ("B".equals(encoding)) {
+ // Base64 decoding
+ byte[] decodedBytes = Base64.getDecoder().decode(encodedValue);
+ decodedValue = new String(decodedBytes, Charset.forName(charset));
+ } else if ("Q".equals(encoding)) {
+ // Quoted-printable decoding
+ decodedValue = decodeQuotedPrintable(encodedValue, charset);
+ } else {
+ // Unknown encoding, keep original
+ decodedValue = matcher.group(0);
+ }
+ result.append(decodedValue);
+ } catch (Exception e) {
+ log.warn("Failed to decode MIME header part: {}", matcher.group(0), e);
+ // If decoding fails, keep the original encoded text
+ result.append(matcher.group(0));
+ }
+
+ lastEnd = matcher.end();
+ }
+
+ // Add any remaining text after the last encoded part
+ result.append(encodedText.substring(lastEnd));
+
+ return result.toString();
+ } catch (Exception e) {
+ log.warn("Error decoding MIME header: {}", encodedText, e);
+ return encodedText; // Return original if decoding fails
+ }
+ }
+
+ 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; // Skip the hex digits
+ } catch (NumberFormatException e) {
+ // If hex parsing fails, keep the original character
+ result.append(c);
+ }
+ } else {
+ result.append(c);
+ }
+ }
+ case '_' -> // In RFC 2047, underscore represents space
+ result.append(' ');
+ default -> result.append(c);
+ }
+ }
+
+ // Convert bytes to proper charset
+ byte[] bytes = result.toString().getBytes(StandardCharsets.ISO_8859_1);
+ return new String(bytes, Charset.forName(charset));
+ }
+
+ private static String safeMimeDecode(String headerValue) {
+ if (headerValue == null) {
+ return "";
+ }
+
+ try {
+ if (isJakartaMailAvailable()) {
+ // Use Jakarta Mail's MimeUtility for proper MIME decoding
+ Class> mimeUtilityClass = Class.forName("jakarta.mail.internet.MimeUtility");
+ Method decodeText = mimeUtilityClass.getMethod("decodeText", String.class);
+ return (String) decodeText.invoke(null, headerValue.trim());
+ } else {
+ // Fallback to basic MIME decoding
+ return decodeMimeHeader(headerValue.trim());
+ }
+ } catch (Exception e) {
+ log.warn("Failed to decode MIME header, using original: {}", headerValue, e);
+ return headerValue;
+ }
+ }
+
+ private static boolean isValidJakartaMailPart(Object part) {
+ if (part == null) return false;
+
+ try {
+ // Check if the object implements jakarta.mail.Part interface
+ Class> partInterface = Class.forName("jakarta.mail.Part");
+ if (!partInterface.isInstance(part)) {
+ return false;
+ }
+
+ // Additional check for MimePart
+ try {
+ Class> mimePartInterface = Class.forName("jakarta.mail.internet.MimePart");
+ return mimePartInterface.isInstance(part);
+ } catch (ClassNotFoundException e) {
+ // MimePart not available, but Part is sufficient
+ return true;
+ }
+ } catch (ClassNotFoundException e) {
+ log.debug("Jakarta Mail Part interface not available for validation");
+ return false;
+ }
+ }
+
+ private static boolean isValidJakartaMailMultipart(Object multipart) {
+ if (multipart == null) return false;
+
+ try {
+ // Check if the object implements jakarta.mail.Multipart interface
+ Class> multipartInterface = Class.forName("jakarta.mail.Multipart");
+ if (!multipartInterface.isInstance(multipart)) {
+ return false;
+ }
+
+ // Additional check for MimeMultipart
+ try {
+ Class> mimeMultipartClass = Class.forName("jakarta.mail.internet.MimeMultipart");
+ if (mimeMultipartClass.isInstance(multipart)) {
+ log.debug("Found MimeMultipart instance for enhanced processing");
+ return true;
+ }
+ } catch (ClassNotFoundException e) {
+ log.debug("MimeMultipart not available, using base Multipart interface");
+ }
+
+ return true;
+ } catch (ClassNotFoundException e) {
+ log.debug("Jakarta Mail Multipart interface not available for validation");
+ return false;
+ }
+ }
+
+ @Data
+ public static class EmailContent {
+ private String subject;
+ private String from;
+ private String to;
+ private Date date;
+ private String htmlBody;
+ private String textBody;
+ private int attachmentCount;
+ private List 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;
+
+ // New fields for advanced processing
+ private String contentId;
+ private String disposition;
+ private String transferEncoding;
+
+ // Custom setter to maintain size calculation logic
+ public void setData(byte[] data) {
+ this.data = data;
+ if (data != null) {
+ this.sizeBytes = data.length;
+ }
+ }
+ }
+
+ @Data
+ public static class MarkerPosition {
+ private int pageIndex;
+ private float x;
+ private float y;
+ private String character;
+
+ public MarkerPosition(int pageIndex, float x, float y, String character) {
+ this.pageIndex = pageIndex;
+ this.x = x;
+ this.y = y;
+ this.character = character;
+ }
+ }
+
+ public static class AttachmentMarkerPositionFinder
+ extends org.apache.pdfbox.text.PDFTextStripper {
+ @Getter private final List positions = new ArrayList<>();
+ private int currentPageIndex;
+ protected boolean sortByPosition;
+ private boolean isInAttachmentSection;
+ private boolean attachmentSectionFound;
+
+ public AttachmentMarkerPositionFinder() {
+ super();
+ this.currentPageIndex = 0;
+ this.sortByPosition = false;
+ this.isInAttachmentSection = false;
+ this.attachmentSectionFound = false;
+ }
+
+ @Override
+ protected void startPage(org.apache.pdfbox.pdmodel.PDPage page) throws IOException {
+ super.startPage(page);
+ }
+
+ @Override
+ protected void endPage(org.apache.pdfbox.pdmodel.PDPage page) throws IOException {
+ currentPageIndex++;
+ super.endPage(page);
+ }
+
+ @Override
+ protected void writeString(
+ String string, List textPositions)
+ throws IOException {
+ // Check if we are entering or exiting the attachment section
+ String lowerString = string.toLowerCase();
+
+ // Look for attachment section start marker
+ if (lowerString.contains("attachments (")) {
+ isInAttachmentSection = true;
+ attachmentSectionFound = true;
+ }
+
+ // Look for attachment section end markers (common patterns that indicate end of
+ // attachments)
+ if (isInAttachmentSection
+ && (lowerString.contains("