Compare commits

..
Author SHA1 Message Date
pixeebot[bot]GitHubpixeebot[bot] <104101892+pixeebot[bot]@users.noreply.github.com>
7f096297af Hardening suggestions for Stirling-PDF / multiFileAsync (#3923)
I've reviewed the recently opened PR ([3922 - Support multi-file async
job results and ZIP
extraction](https://github.com/Stirling-Tools/Stirling-PDF/pull/3922))
and have identified some area(s) that could benefit from additional
hardening measures.

These changes should help prevent potential security vulnerabilities and
improve overall code quality.

Thank you for your consideration!
🧚🤖  Powered by Pixeebot  

[Feedback](https://ask.pixee.ai/feedback) |
[Community](https://pixee-community.slack.com/signup#/domain-signup) |
[Docs](https://docs.pixee.ai/)
![](https://d1zaessa2hpsmj.cloudfront.net/pixel/v1/track?writeKey=2PI43jNm7atYvAuK7rJUz3Kcd6A&event=PR_HARDENING%7CStirling-Tools%2FStirling-PDF%7C624e04a783753f7728d85d32469b6a2b5d4b113f)

Co-authored-by: pixeebot[bot] <104101892+pixeebot[bot]@users.noreply.github.com>
2025-07-10 16:36:19 +01:00
Anthony Stirling 624e04a783 tests and formatting 2025-07-10 15:22:37 +01:00
Anthony Stirling bb9f1d4f8b extra fixes 2025-07-10 14:58:11 +01:00
0d967aa5ea Update common/src/main/java/stirling/software/common/service/TaskManager.java
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-10 14:55:49 +01:00
Anthony Stirling f19ff6691d cleanups 2025-07-10 14:34:43 +01:00
Anthony Stirling 98b4763c96 remove "legacy" 2025-07-10 14:30:22 +01:00
Anthony Stirling 2c9537f786 multi file async 2025-07-10 14:18:35 +01:00
1528 changed files with 9750 additions and 186526 deletions
+1 -8
View File
@@ -5,14 +5,7 @@
"Bash(mkdir:*)",
"Bash(./gradlew:*)",
"Bash(grep:*)",
"Bash(cat:*)",
"Bash(find:*)",
"Bash(npm test)",
"Bash(npm test:*)",
"Bash(ls:*)",
"Bash(npx tsc:*)",
"Bash(echo)",
"Bash(rm:*)"
"Bash(cat:*)"
],
"deny": []
}
+2 -15
View File
@@ -49,7 +49,7 @@
"java.configuration.updateBuildConfiguration": "interactive",
"java.format.enabled": true,
"java.format.settings.profile": "GoogleStyle",
"java.format.settings.google.version": "1.28.0",
"java.format.settings.google.version": "1.26.0",
"java.format.settings.google.extra": "--aosp --skip-sorting-imports --skip-javadoc-formatting",
"java.saveActions.cleanup": true,
"java.cleanup.actions": [
@@ -79,17 +79,9 @@
".venv*/",
".vscode/",
"bin/",
"app/core/bin/",
"app/common/bin/",
"app/proprietary/bin/",
"build/",
"app/core/build/",
"app/common/build/",
"app/proprietary/build/",
"configs/",
"app/core/configs/",
"customFiles/",
"app/core/customFiles/",
"docs/",
"exampleYmlFiles",
"gradle/",
@@ -101,9 +93,6 @@
".git-blame-ignore-revs",
".gitattributes",
".gitignore",
"app/core/.gitignore",
"app/common/.gitignore",
"app/proprietary/.gitignore",
".pre-commit-config.yaml"
],
"java.signatureHelp.enabled": true,
@@ -130,9 +119,7 @@
"EditorConfig.EditorConfig", // EditorConfig support for maintaining consistent coding styles
"ms-azuretools.vscode-docker", // Docker extension for Visual Studio Code
"charliermarsh.ruff", // Ruff extension for Ruff language support
"github.vscode-github-actions", // GitHub Actions extension for Visual Studio Code
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
"redhat.vscode-yaml" // YAML extension for Visual Studio Code
"github.vscode-github-actions" // GitHub Actions extension for Visual Studio Code
]
}
},
+8 -2
View File
@@ -24,19 +24,25 @@ indent_size = 2
insert_final_newline = false
trim_trailing_whitespace = false
[{*.js,*.jsx,*.ts,*.tsx}]
[*.js]
indent_size = 2
[*.css]
# CSS files typically use an indent size of 2 spaces for better readability and alignment with community standards.
indent_size = 2
[*.{yml,yaml}]
[*.yaml]
# YAML files use an indent size of 2 spaces to maintain consistency with common YAML formatting practices.
indent_size = 2
insert_final_newline = false
trim_trailing_whitespace = false
[*.yml]
# YML files follow the same conventions as YAML files, using an indent size of 2 spaces.
indent_size = 2
insert_final_newline = false
trim_trailing_whitespace = false
[*.json]
# JSON files use an indent size of 2 spaces, which is the standard for JSON formatting.
indent_size = 2
-4
View File
@@ -1,9 +1,5 @@
# Formatting
5f771b785130154ed47952635b7acef371ffe0ec
7fa5e130d99227c2202ebddfdd91348176ec0c7b
14d4fbb2a36195eedb034785e5a5ff6a47f268c6
ee8030c1c4148062cde15c49c67d04ef03930c55
fcd41924f5f261febfa9d9a92994671f3ebc97d6
# Normalize files
55d4fda01b2f39f5b7d7b4fda5214bd7ff0fd5dd
+7 -7
View File
@@ -1,10 +1,10 @@
* text=auto eol=lf
# Ignore all JavaScript files in a directory
app/core/src/main/resources/static/pdfjs/* linguist-vendored
app/core/src/main/resources/static/pdfjs/** linguist-vendored
app/core/src/main/resources/static/pdfjs-legacy/* linguist-vendored
app/core/src/main/resources/static/pdfjs-legacy/** linguist-vendored
app/core/src/main/resources/static/css/bootstrap-icons.css linguist-vendored
app/core/src/main/resources/static/css/bootstrap.min.css linguist-vendored
app/core/src/main/resources/static/css/fonts/* linguist-vendored
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
+2 -21
View File
@@ -1,21 +1,2 @@
# All PRs must be approved by Frooodle or Ludy87
* @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
# Backend
/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
#V1 frontend
/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
/app/core/src/main/resources/templates/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
#V2 frontend
/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle
#V2 docker
/docker/backend/** @Frooodle @Ludy87 @DarioGii @Ludy87
/docker/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
/docker/compose/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
#GHA (All users)
/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
# All PRs to V1 must be approved by Frooodle
* @Frooodle @reecebrowne @Ludy87 @DarioGii @ConnorYoh
-31
View File
@@ -1,31 +0,0 @@
build: &build
- build.gradle
- app/(common|core|proprietary)/build.gradle
app: &app
- app/(common|core|proprietary)/src/main/java/**
openapi: &openapi
- build.gradle
- app/(common|core|proprietary)/build.gradle
- app/(common|core|proprietary)/src/main/java/**
project: &project
- app/(common|core|proprietary)/src/(main|test)/java/**
- app/(common|core|proprietary)/build.gradle
- 'app/(common|core|proprietary)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*'
- exampleYmlFiles/**
- gradle/**
- libs/**
- testing/**
- build.gradle
- Dockerfile
- Dockerfile.fat
- Dockerfile.ultra-lite
- gradle.properties
- gradlew
- gradlew.bat
- launch4jConfig.xml
- settings.gradle
- frontend/**
- docker/**
+1 -2
View File
@@ -7,7 +7,6 @@
"sbplat",
"reecebrowne",
"DarioGii",
"ConnorYoh",
"EthanHealy01"
"ConnorYoh"
]
}
+44 -60
View File
@@ -2,102 +2,88 @@ version: 1
labels:
- label: "Bugfix"
title: '^fix(\([^)]*\))?:|^fix:.*'
title: '^fix:.*'
- label: "enhancement"
title: '^feat(\([^)]*\))?:|^feat:.*'
title: '^feat:.*'
- label: "build"
title: '^build(\([^)]*\))?:|^build:.*'
title: '^build:.*'
- label: "chore"
title: '^chore(\([^)]*\))?:|^chore:.*'
title: '^chore:.*'
- label: "ci"
title: '^ci(\([^)]*\))?:|^ci:.*'
- label: "ci"
title: '^.*\(ci\):.*'
title: '^ci:.*'
- label: "perf"
title: '^perf(\([^)]*\))?:|^perf:.*'
title: '^perf:.*'
- label: "refactor"
title: '^refactor(\([^)]*\))?:|^refactor:.*'
title: '^refactor:.*'
- label: "revert"
title: '^revert(\([^)]*\))?:|^revert:.*'
title: '^revert:.*'
- label: "style"
title: '^style(\([^)]*\))?:|^style:.*'
title: '^style:.*'
- label: "Documentation"
title: '^docs(\([^)]*\))?:|^docs:.*'
- label: "Documentation"
title: '^.*\(docs\):.*'
- label: "dependencies"
title: '^deps(\([^)]*\))?:|^deps:.*'
- label: "dependencies"
title: '^.*\(deps\):.*'
title: '^docs:.*'
- label: 'API'
title: '.*openapi.*|.*swagger.*|.*api.*'
title: '.*openapi.*'
- label: 'Translation'
files:
- 'app/core/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}.properties'
- 'stirling-pdf/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}.properties'
- 'scripts/ignore_translation.toml'
- 'app/core/src/main/resources/templates/fragments/languages.html'
- 'stirling-pdf/src/main/resources/templates/fragments/languages.html'
- '.github/scripts/check_language_properties.py'
- label: 'Front End'
files:
- 'app/core/src/main/resources/templates/.*'
- 'app/proprietary/src/main/resources/templates/.*'
- 'app/core/src/main/resources/static/.*'
- 'app/proprietary/src/main/resources/static/.*'
- 'app/core/src/main/java/stirling/software/SPDF/controller/web/.*'
- 'app/core/src/main/java/stirling/software/SPDF/UI/.*'
- 'app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/.*'
- '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/.*'
- 'proprietary/src/main/java/stirling/software/proprietary/security/controller/web/.*'
- label: 'Java'
files:
- 'app/common/src/main/java/.*.java'
- 'app/proprietary/src/main/java/.*.java'
- 'app/core/src/main/java/.*.java'
- 'common/src/main/java/.*.java'
- 'proprietary/src/main/java/.*.java'
- 'stirling-pdf/src/main/java/.*.java'
- label: 'Back End'
files:
- 'app/core/src/main/java/stirling/software/SPDF/config/.*'
- 'app/core/src/main/java/stirling/software/SPDF/controller/.*'
- 'app/core/src/main/resources/settings.yml.template'
- 'app/core/src/main/resources/application.properties'
- 'app/core/src/main/resources/banner.txt'
- 'app/core/src/main/resources/static/python/png_to_webp.py'
- 'app/core/src/main/resources/static/python/split_photos.py'
- 'app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/**'
- '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:
- 'app/proprietary/src/main/java/stirling/software/proprietary/security/.*'
- 'proprietary/src/main/java/stirling/software/proprietary/security/.*'
- 'scripts/download-security-jar.sh'
- '.github/workflows/dependency-review.yml'
- '.github/workflows/scorecards.yml'
- label: 'API'
files:
- 'app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java'
- 'app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java'
- 'app/core/src/main/java/stirling/software/SPDF/controller/api/.*'
- 'app/core/src/main/java/stirling/software/SPDF/model/api/.*'
- 'app/core/src/main/java/stirling/software/SPDF/service/ApiDocService.java'
- 'app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/.*'
- 'app/core/src/main/resources/static/python/png_to_webp.py'
- 'app/core/src/main/resources/static/python/split_photos.py'
- '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/.*'
- 'proprietary/src/main/java/stirling/software/proprietary/security/controller/api/.*'
- 'scripts/png_to_webp.py'
- 'split_photos.py'
- '.github/workflows/swagger.yml'
- label: 'Documentation'
@@ -130,14 +116,12 @@ labels:
- '.pre-commit-config'
- '.github/workflows/pre_commit.yml'
- 'devGuide/.*'
- 'devTools/.*'
- 'devTools/.*'
- label: 'Test'
files:
- 'app/common/src/test/.*'
- 'app/proprietary/src/test/.*'
- 'app/core/src/test/.*'
- 'common/src/test/.*'
- 'proprietary/src/test/.*'
- 'stirling-pdf/src/test/.*'
- 'testing/.*'
- '.github/workflows/scorecards.yml'
- 'exampleYmlFiles/test_cicd.yml'
@@ -153,6 +137,6 @@ labels:
- 'gradlew.bat'
- 'settings.gradle'
- 'build.gradle'
- 'app/common/build.gradle'
- 'app/proprietary/build.gradle'
- 'app/core/build.gradle'
- 'common/build.gradle'
- 'proprietary/build.gradle'
- 'stirling-pdf/build.gradle'
+86
View File
@@ -0,0 +1,86 @@
Translation:
- changed-files:
- 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: 'stirling-pdf/src/main/resources/templates/fragments/languages.html'
Front End:
- changed-files:
- 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: '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: '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: '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: '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'
Documentation:
- changed-files:
- any-glob-to-any-file: '**/*.md'
- any-glob-to-any-file: 'scripts/counter_translation.py'
- any-glob-to-any-file: 'scripts/ignore_translation.toml'
Docker:
- changed-files:
- any-glob-to-any-file: '.github/workflows/build.yml'
- any-glob-to-any-file: '.github/workflows/push-docker.yml'
- any-glob-to-any-file: 'Dockerfile'
- any-glob-to-any-file: 'Dockerfile.fat'
- any-glob-to-any-file: 'Dockerfile.ultra-lite'
- any-glob-to-any-file: 'exampleYmlFiles/*.yml'
- any-glob-to-any-file: 'scripts/download-security-jar.sh'
- any-glob-to-any-file: 'scripts/init.sh'
- any-glob-to-any-file: 'scripts/init-without-ocr.sh'
- any-glob-to-any-file: 'scripts/installFonts.sh'
- any-glob-to-any-file: 'test.sh'
- any-glob-to-any-file: 'test2.sh'
Devtools:
- changed-files:
- any-glob-to-any-file: '.devcontainer/**/*'
- any-glob-to-any-file: 'Dockerfile.dev'
Test:
- changed-files:
- any-glob-to-any-file: 'cucumber/**/*'
- any-glob-to-any-file: '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'
- any-glob-to-any-file: '.github/workflows/scorecards.yml'
Github:
- changed-files:
- any-glob-to-any-file: '.github/**/*'
-8
View File
@@ -42,7 +42,6 @@
- name: "Front End"
color: "BBD2F1"
description: "Issues or pull requests related to front-end development"
from_name: "frontend"
- name: "github-actions"
description: "Pull requests that update GitHub Actions code"
color: "999999"
@@ -78,7 +77,6 @@
- name: "Translation"
color: "9FABF9"
from_name: "translation"
description: "Issues or pull requests related to translation"
- name: "upstream"
color: "DEDEDE"
- name: "v2"
@@ -177,9 +175,3 @@
description: "This PR changes 1000+ lines ignoring generated files."
- name: "to research"
color: "FBCA04"
- name: "pr-deployed"
color: "00FF00"
description: "Pull request has been deployed to a test environment"
- name: "codex"
color: "ededed"
description: "chatgpt AI generated code"
+5 -7
View File
@@ -197,7 +197,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
if len(file_list) == 1:
file_arr = file_list[0].split()
base_dir = os.path.abspath(
os.path.join(os.getcwd(), "app", "core", "src", "main", "resources")
os.path.join(os.getcwd(), "stirling-pdf", "src", "main", "resources")
)
for file_path in file_arr:
@@ -219,14 +219,13 @@ def check_for_differences(reference_file, file_list, branch, actor):
# only local windows command
not file_normpath.startswith(
os.path.join(
"", "app", "core", "src", "main", "resources", "messages_"
"", "stirling-pdf", "src", "main", "resources", "messages_"
)
)
and not file_normpath.startswith(
os.path.join(
os.getcwd(),
"app",
"core",
"stirling-pdf",
"src",
"main",
"resources",
@@ -329,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/app/core/src/main/resources/messages_en_GB.properties)"
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/stirling-pdf/src/main/resources/messages_en_GB.properties)"
)
else:
report.append("## ✅ Overall Check Status: **_Success_**")
@@ -390,8 +389,7 @@ if __name__ == "__main__":
file_list = glob.glob(
os.path.join(
os.getcwd(),
"app",
"core",
"stirling-pdf",
"src",
"main",
"resources",
-504
View File
@@ -1,504 +0,0 @@
name: Auto PR V2 Deployment
on:
pull_request:
types: [opened, synchronize, reopened, closed]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
check-pr:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
outputs:
should_deploy: ${{ steps.check-conditions.outputs.should_deploy }}
pr_number: ${{ github.event.number }}
pr_repository: ${{ steps.get-pr-info.outputs.repository }}
pr_ref: ${{ steps.get-pr-info.outputs.ref }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
- name: Check deployment conditions
id: check-conditions
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
run: |
echo "PR Title: $PR_TITLE"
echo "PR Author: $PR_AUTHOR"
echo "PR Branch: $PR_BRANCH"
echo "PR Base Branch: ${{ github.event.pull_request.base.ref }}"
# Define authorized users
authorized_users=(
"Frooodle"
"sf298"
"Ludy87"
"LaserKaspar"
"sbplat"
"reecebrowne"
"DarioGii"
"ConnorYoh"
"EthanHealy01"
"jbrunton96"
)
# Check if author is in the authorized list
is_authorized=false
for user in "${authorized_users[@]}"; do
if [[ "$PR_AUTHOR" == "$user" ]]; then
is_authorized=true
break
fi
done
# If PR is targeting V2 and user is authorized, deploy unconditionally
PR_BASE_BRANCH="${{ github.event.pull_request.base.ref }}"
if [[ "$PR_BASE_BRANCH" == "V2" && "$is_authorized" == "true" ]]; then
echo "✅ Deployment forced: PR targets V2 and author is authorized."
echo "should_deploy=true" >> $GITHUB_OUTPUT
exit 0
fi
# Otherwise, continue with original keyword checks
has_v2_keyword=false
if [[ "$PR_TITLE" =~ [Vv]2 ]] || [[ "$PR_TITLE" =~ [Vv]ersion.?2 ]] || [[ "$PR_TITLE" =~ [Vv]ersion.?[Tt]wo ]]; then
has_v2_keyword=true
fi
has_branch_keyword=false
if [[ "$PR_BRANCH" =~ [Vv]2 ]] || [[ "$PR_BRANCH" =~ [Rr]eact ]]; then
has_branch_keyword=true
fi
if [[ "$is_authorized" == "true" && ( "$has_v2_keyword" == "true" || "$has_branch_keyword" == "true" ) ]]; then
echo "✅ Deployment conditions met"
echo "should_deploy=true" >> $GITHUB_OUTPUT
else
echo "❌ Deployment conditions not met"
echo " - Authorized user: $is_authorized"
echo " - Has V2 keyword in title: $has_v2_keyword"
echo " - Has V2/React keyword in branch: $has_branch_keyword"
echo "should_deploy=false" >> $GITHUB_OUTPUT
fi
- name: Get PR repository and ref
id: get-pr-info
if: steps.check-conditions.outputs.should_deploy == 'true'
run: |
# For forks, use the full repository name, for internal PRs use the current repo
if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
repository="${{ github.event.pull_request.head.repo.full_name }}"
else
repository="${{ github.repository }}"
fi
echo "repository=$repository" >> $GITHUB_OUTPUT
echo "ref=${{ github.event.pull_request.head.ref }}" >> $GITHUB_OUTPUT
deploy-v2-pr:
needs: check-pr
runs-on: ubuntu-latest
if: needs.check-pr.outputs.should_deploy == 'true'
# Concurrency control - only one deployment per PR at a time
concurrency:
group: v2-deploy-pr-${{ needs.check-pr.outputs.pr_number }}
cancel-in-progress: true
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
- name: Checkout main repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: ${{ github.repository }}
ref: main
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
id: setup-bot
uses: ./.github/actions/setup-bot
continue-on-error: true
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Add deployment started comment
id: deployment-started
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = ${{ needs.check-pr.outputs.pr_number }};
// Delete previous V2 deployment comments to avoid clutter
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
per_page: 100
});
const v2Comments = comments.filter(comment =>
comment.body.includes('🚀 **Auto-deploying V2 version**') ||
comment.body.includes('## 🚀 V2 Auto-Deployment Complete!') ||
comment.body.includes('❌ **V2 Auto-deployment failed**')
);
for (const comment of v2Comments) {
console.log(`Deleting old V2 comment: ${comment.id}`);
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id
});
}
// Create new deployment started comment
const { data: newComment } = await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment triggered by V2/version2 keywords in the PR title or V2/React keywords in the branch name._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.`
});
return newComment.id;
- name: Checkout PR
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: ${{ needs.check-pr.outputs.pr_repository }}
ref: ${{ needs.check-pr.outputs.pr_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 # Fetch full history for commit hash detection
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Get version number
id: versionNumber
run: |
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Get commit hashes for frontend and backend
id: commit-hashes
run: |
# Get last commit that touched the frontend folder, docker/frontend, or docker/compose
FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "")
if [ -z "$FRONTEND_HASH" ]; then
FRONTEND_HASH="no-frontend-changes"
fi
# Get last commit that touched backend code, docker/backend, or docker/compose
BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
if [ -z "$BACKEND_HASH" ]; then
BACKEND_HASH="no-backend-changes"
fi
echo "Frontend hash: $FRONTEND_HASH"
echo "Backend hash: $BACKEND_HASH"
echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
# Short hashes for tags
if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
else
echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
fi
if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
echo "backend_short=no-backend" >> $GITHUB_OUTPUT
else
echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT
fi
- name: Check if frontend image exists
id: check-frontend
run: |
if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Frontend image already exists, skipping build"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Frontend image needs to be built"
fi
- name: Check if backend image exists
id: check-backend
run: |
if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Backend image already exists, skipping build"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Backend image needs to be built"
fi
- name: Build and push V2 frontend image
if: steps.check-frontend.outputs.exists == 'false'
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./docker/frontend/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Build and push V2 backend image
if: steps.check-backend.outputs.exists == 'false'
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./docker/backend/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key
- name: Deploy V2 to VPS
id: deploy
run: |
# Use same port strategy as regular PRs - just the PR number
V2_PORT=${{ needs.check-pr.outputs.pr_number }}
BACKEND_PORT=$((V2_PORT + 10000)) # Backend on higher port to avoid conflicts
# Create docker-compose for V2 with separate frontend and backend
cat > docker-compose.yml << EOF
version: '3.3'
services:
stirling-pdf-v2-backend:
container_name: stirling-pdf-v2-backend-pr-${{ needs.check-pr.outputs.pr_number }}
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
ports:
- "${BACKEND_PORT}:8080" # Backend API port
volumes:
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/data:/usr/share/tessdata:rw
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/config:/configs:rw
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false"
SYSTEM_DEFAULTLOCALE: en-GB
UI_APPNAME: "Stirling-PDF V2 PR#${{ needs.check-pr.outputs.pr_number }}"
UI_HOMEDESCRIPTION: "V2 PR#${{ needs.check-pr.outputs.pr_number }} - Frontend/Backend Split Architecture"
UI_APPNAMENAVBAR: "V2 PR#${{ needs.check-pr.outputs.pr_number }}"
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
SWAGGER_SERVER_URL: "http://${{ secrets.VPS_HOST }}:${V2_PORT}"
restart: on-failure:5
stirling-pdf-v2-frontend:
container_name: stirling-pdf-v2-frontend-pr-${{ needs.check-pr.outputs.pr_number }}
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
ports:
- "${V2_PORT}:80" # Frontend port (same as regular PRs)
environment:
VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:${BACKEND_PORT}"
depends_on:
- stirling-pdf-v2-backend
restart: on-failure:5
EOF
# Deploy to VPS
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose-v2.yml
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
# Create V2 PR-specific directories
mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs}
# Move docker-compose file to correct location
mv /tmp/docker-compose-v2.yml /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/docker-compose.yml
# Stop any existing container and clean up
cd /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}
docker-compose down --remove-orphans 2>/dev/null || true
# Start the new container
docker-compose pull
docker-compose up -d
# Clean up unused Docker resources to save space
docker system prune -af --volumes || true
# Clean up old backend/frontend images (older than 2 weeks)
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
ENDSSH
# Set port for output
echo "v2_port=${V2_PORT}" >> $GITHUB_OUTPUT
- name: Post V2 deployment URL to PR
if: success()
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = ${{ needs.check-pr.outputs.pr_number }};
const v2Port = ${{ steps.deploy.outputs.v2_port }};
// Delete the "deploying..." comment since we're posting the final result
const deploymentStartedId = ${{ steps.deployment-started.outputs.result }};
if (deploymentStartedId) {
console.log(`Deleting deployment started comment: ${deploymentStartedId}`);
try {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: deploymentStartedId
});
} catch (error) {
console.log(`Could not delete deployment started comment: ${error.message}`);
}
}
const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${v2Port}`;
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
`Your V2 PR with the new frontend/backend split architecture has been deployed!\n\n` +
`🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` +
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
`🔄 **Auto-deployed** because PR title or branch name contains V2/version2/React keywords.`;
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: commentBody
});
cleanup-v2-deployment:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
id: setup-bot
uses: ./.github/actions/setup-bot
continue-on-error: true
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Clean up V2 deployment comments
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = ${{ github.event.pull_request.number }};
// Find and delete V2 deployment comments
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber
});
const v2Comments = comments.filter(c =>
c.body?.includes("## 🚀 V2 Auto-Deployment Complete!") &&
c.user?.type === "Bot"
);
for (const comment of v2Comments) {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id
});
console.log(`Deleted V2 deployment comment (ID: ${comment.id})`);
}
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key
- name: Cleanup V2 deployment
run: |
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
if [ -d "/stirling/V2-PR-${{ github.event.pull_request.number }}" ]; then
echo "Found V2 PR directory, proceeding with cleanup..."
# Stop and remove V2 containers
cd /stirling/V2-PR-${{ github.event.pull_request.number }}
docker-compose down || true
# Go back to root before removal
cd /
# Remove V2 PR-specific directories
rm -rf /stirling/V2-PR-${{ github.event.pull_request.number }}
# Clean up V2 containers by name (in case compose cleanup missed them)
docker rm -f stirling-pdf-v2-frontend-pr-${{ github.event.pull_request.number }} || true
docker rm -f stirling-pdf-v2-backend-pr-${{ github.event.pull_request.number }} || true
echo "V2 cleanup completed"
else
echo "V2 PR directory not found, nothing to clean up"
fi
# Clean up old unused images (older than 2 weeks) but keep recent ones for reuse
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
# Note: We don't remove the commit-based images since they can be reused across PRs
# Only remove PR-specific containers and directories
ENDSSH
- name: Cleanup temporary files
if: always()
run: |
rm -f ../private.key
continue-on-error: true
@@ -6,18 +6,20 @@ on:
permissions:
contents: read
pull-requests: read
issues: write # Required for adding reactions to comments
pull-requests: read # Required for reading PR information
jobs:
check-comment:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: read
if: |
github.event.issue.pull_request &&
(
contains(github.event.comment.body, 'prdeploy') ||
contains(github.event.comment.body, 'deploypr')
contains(github.event.comment.body, 'prdeploy') ||
contains(github.event.comment.body, 'deploypr')
)
&&
(
@@ -29,7 +31,6 @@ jobs:
github.event.comment.user.login == 'reecebrowne' ||
github.event.comment.user.login == 'DarioGii' ||
github.event.comment.user.login == 'EthanHealy01' ||
github.event.comment.user.login == 'jbrunton96' ||
github.event.comment.user.login == 'ConnorYoh'
)
outputs:
@@ -42,18 +43,14 @@ jobs:
enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout PR
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
id: setup-bot
uses: ./.github/actions/setup-bot
continue-on-error: true
# Generate GitHub App token
- name: Generate GitHub App Token
id: generate-token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
@@ -126,7 +123,7 @@ jobs:
id: add-eyes-reaction
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
github-token: ${{ steps.generate-token.outputs.token }}
script: |
console.log(`Adding eyes reaction to comment ID: ${context.payload.comment.id}`);
try {
@@ -148,23 +145,18 @@ jobs:
needs: check-comment
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout PR
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
id: setup-bot
uses: ./.github/actions/setup-bot
continue-on-error: true
- name: Generate GitHub App Token
id: generate-token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
@@ -174,7 +166,7 @@ jobs:
with:
repository: ${{ needs.check-comment.outputs.pr_repository }}
ref: ${{ needs.check-comment.outputs.pr_ref }}
token: ${{ steps.setup-bot.outputs.token }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
@@ -196,8 +188,14 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Get version number
id: versionNumber
run: |
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
@@ -299,7 +297,7 @@ jobs:
if: success()
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
github-token: ${{ steps.generate-token.outputs.token }}
script: |
console.log(`Adding rocket reaction to comment ID: ${{ needs.check-comment.outputs.comment_id }}`);
try {
@@ -315,26 +313,11 @@ jobs:
console.error(error);
}
// add label to PR
const prNumber = ${{ needs.check-comment.outputs.pr_number }};
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: ['pr-deployed']
});
console.log(`Added 'pr-deployed' label to PR #${prNumber}`);
} catch (error) {
console.error(`Failed to add label to PR: ${error.message}`);
console.error(error);
}
- name: Add failure reaction to comment
if: failure()
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
github-token: ${{ steps.generate-token.outputs.token }}
script: |
console.log(`Adding -1 reaction to comment ID: ${{ needs.check-comment.outputs.comment_id }}`);
try {
@@ -354,7 +337,7 @@ jobs:
if: success()
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const { GITHUB_REPOSITORY } = process.env;
const [repoOwner, repoName] = GITHUB_REPOSITORY.split('/');
@@ -374,11 +357,3 @@ jobs:
issue_number: prNumber,
body: commentBody
});
- name: Cleanup temporary files
if: always()
run: |
echo "Cleaning up temporary files..."
rm -f ../private.key docker-compose.yml
echo "Cleanup complete."
continue-on-error: true
+4 -86
View File
@@ -1,7 +1,7 @@
name: PR Deployment cleanup
on:
pull_request_target:
pull_request:
types: [opened, synchronize, reopened, closed]
permissions:
@@ -13,99 +13,25 @@ env:
jobs:
cleanup:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
if: github.event.action == 'closed'
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout PR
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
id: setup-bot
uses: ./.github/actions/setup-bot
continue-on-error: true
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Remove 'pr-deployed' label if present
id: remove-label-comment
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const prNumber = ${{ github.event.pull_request.number }};
const owner = context.repo.owner;
const repo = context.repo.repo;
// Get all labels on the PR
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
owner,
repo,
issue_number: prNumber
});
const hasLabel = labels.some(label => label.name === 'pr-deployed');
if (hasLabel) {
console.log("Label 'pr-deployed' found. Removing...");
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: 'pr-deployed'
});
} else {
console.log("Label 'pr-deployed' not found. Nothing to do.");
}
// Find existing bot comments about the deployment
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber
});
const deploymentComments = comments.filter(c =>
c.body?.includes("## 🚀 PR Test Deployment") &&
c.user?.type === "Bot"
);
if (deploymentComments.length > 0) {
for (const comment of deploymentComments) {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id
});
console.log(`Deleted deployment comment (ID: ${comment.id})`);
}
} else {
console.log("No matching deployment comments found.");
}
// Set flag if either label or comment was present
const hasDeploymentComment = deploymentComments.length > 0;
core.setOutput('present', (hasLabel || hasDeploymentComment) ? 'true' : 'false');
- name: Set up SSH
if: steps.remove-label-comment.outputs.present == 'true'
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key
- name: Cleanup PR deployment
if: steps.remove-label-comment.outputs.present == 'true'
id: cleanup
run: |
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
@@ -131,11 +57,3 @@ jobs:
echo "NO_CLEANUP_NEEDED"
fi
ENDSSH
- name: Cleanup temporary files
if: always()
run: |
echo "Cleaning up temporary files..."
rm -f ../private.key
echo "Cleanup complete."
continue-on-error: true
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -87,7 +87,7 @@ jobs:
- name: AI PR Title Analysis
if: steps.actor.outputs.is_repo_dev == 'true'
id: ai-title-analysis
uses: actions/ai-inference@0cbed4a10641c75090de5968e66d70eb4660f751 # v1.2.7
uses: actions/ai-inference@d645f067d89ee1d5d736a5990e327e504d1c5a4a # v1.1.0
with:
model: openai/gpt-4o
system-prompt-file: ".github/config/system-prompt.txt"
+27
View File
@@ -0,0 +1,27 @@
name: "Pull Request Labeler"
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@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Apply Labels
uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
configuration-path: .github/labeler-config.yml
sync-labels: true
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
+54 -186
View File
@@ -1,187 +1,122 @@
name: Build and Test Workflow
name: Build repo
on:
push:
branches: ["main"]
pull_request:
branches: ["main", "V2", "V2-gha"]
workflow_dispatch:
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.ref }}
cancel-in-progress: true
branches: ["main"]
permissions:
contents: read
jobs:
files-changed:
name: detect what files changed
runs-on: ubuntu-latest
timeout-minutes: 3
outputs:
build: ${{ steps.changes.outputs.build }}
app: ${{ steps.changes.outputs.app }}
project: ${{ steps.changes.outputs.project }}
openapi: ${{ steps.changes.outputs.openapi }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check for file changes
uses: dorny/paths-filter@v3.0.2
id: changes
with:
filters: .github/config/.files.yaml
build:
runs-on: ubuntu-latest
permissions:
actions: read
security-events: write
strategy:
fail-fast: false
matrix:
jdk-version: [17, 21]
spring-security: [true, false]
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@v4.2.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK ${{ matrix.jdk-version }}
uses: actions/setup-java@v4.7.1
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4.4.1
with:
gradle-version: 8.14
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
run: ./gradlew clean build -PnoSpotless
run: ./gradlew clean build
env:
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.spring-security }}
- name: Check Test Reports Exist
id: check-reports
if: always()
run: |
declare -a dirs=(
"app/core/build/reports/tests/"
"app/core/build/test-results/"
"app/common/build/reports/tests/"
"app/common/build/test-results/"
"app/proprietary/build/reports/tests/"
"app/proprietary/build/test-results/"
"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
echo "Missing $dir"
exit 1
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@v4.6.2
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
path: |
app/**/build/reports/tests/
app/**/build/test-results/
app/**/build/reports/problems/
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/
build/reports/problems/
retention-days: 3
if-no-files-found: warn
check-generateOpenApiDocs:
if: needs.files-changed.outputs.openapi == 'true'
needs: [files-changed]
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@v2.12.2
with:
egress-policy: audit
- uses: actions/checkout@v4.2.2
- name: Set up JDK 17
uses: actions/setup-java@v4.7.1
with:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@v4.4.1
- name: Generate OpenAPI documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
- name: Upload OpenAPI Documentation
uses: actions/upload-artifact@v4.6.2
with:
name: openapi-docs
path: ./SwaggerDoc.json
frontend-validation:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@v2.12.2
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@v4.2.2
- name: Set up Node.js
uses: actions/setup-node@v4.1.0
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: cd frontend && npm ci
- name: Build frontend
run: cd frontend && npm run build
- name: Run frontend tests
run: cd frontend && npm run test -- --run
- name: Upload frontend build artifacts
uses: actions/upload-artifact@v4.6.2
with:
name: frontend-build
path: frontend/dist/
retention-days: 3
check-licence:
if: needs.files-changed.outputs.build == 'true'
needs: [files-changed, build]
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@v4.2.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 17
uses: actions/setup-java@v4.7.1
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
distribution: "adopt"
- name: check the licenses for compatibility
run: ./gradlew clean checkLicense
- name: FAILED - check the licenses for compatibility
if: failure()
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: dependencies-without-allowed-license.json
path: build/reports/dependency-license/dependencies-without-allowed-license.json
path: |
build/reports/dependency-license/dependencies-without-allowed-license.json
retention-days: 3
docker-compose-tests:
if: needs.files-changed.outputs.project == 'true'
needs: files-changed
# if: github.event_name == 'push' && github.ref == 'refs/heads/main' ||
# (github.event_name == 'pull_request' &&
# contains(github.event.pull_request.labels.*.name, 'licenses') == false &&
@@ -200,7 +135,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -211,7 +146,7 @@ jobs:
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
distribution: "adopt"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
@@ -226,7 +161,6 @@ jobs:
with:
python-version: "3.12"
cache: 'pip' # caching pip dependencies
cache-dependency-path: ./testing/cucumber/requirements.txt
- name: Pip requirements
run: |
@@ -238,69 +172,3 @@ jobs:
chmod +x ./testing/test.sh
chmod +x ./testing/test_disabledEndpoints.sh
./testing/test.sh
test-build-docker-images:
if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true'
needs: [files-changed, build, check-generateOpenApiDocs, check-licence]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "Dockerfile.fat"]
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 17
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
gradle-version: 8.14
- name: Build application
run: ./gradlew clean build
env:
DISABLE_ADDITIONAL_FEATURES: true
STIRLING_PDF_DESKTOP_UI: false
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Build ${{ matrix.docker-rev }}
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/backend/${{ matrix.docker-rev }}
push: false
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Upload Reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: reports-docker-${{ matrix.docker-rev }}
path: |
build/reports/tests/
build/test-results/
build/reports/problems/
retention-days: 3
if-no-files-found: warn
+9 -21
View File
@@ -4,19 +4,7 @@ on:
pull_request_target:
types: [opened, synchronize, reopened]
paths:
- "app/core/src/main/resources/messages_*.properties"
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.ref }}
cancel-in-progress: true
- "stirling-pdf/src/main/resources/messages_*.properties"
permissions:
contents: read # Allow read access to repository content
@@ -30,7 +18,7 @@ jobs:
pull-requests: write # Allow writing to pull requests
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -79,7 +67,7 @@ jobs:
exit 1
fi
# Get changed files and filter for properties files, handle case where no matches are found
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^app/core/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$' > changed_files.txt || echo "No matching properties files found in PR"
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"
@@ -127,9 +115,9 @@ jobs:
// Filter for relevant files based on the PR changes
const changedFiles = files
.filter(file =>
.filter(file =>
file.status !== "removed" &&
/^app\/core\/src\/main\/resources\/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$/.test(file.filename)
/^stirling-pdf\/src\/main\/resources\/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$/.test(file.filename)
)
.map(file => file.filename);
@@ -169,12 +157,12 @@ jobs:
// Determine reference file
let referenceFilePath;
if (changedFiles.includes("app/core/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: "app/core/src/main/resources/messages_en_GB.properties",
path: "stirling-pdf/src/main/resources/messages_en_GB.properties",
ref: branch,
});
@@ -186,7 +174,7 @@ jobs:
const { data: fileContent } = await github.rest.repos.getContent({
owner: repoOwner,
repo: repoName,
path: "app/core/src/main/resources/messages_en_GB.properties",
path: "stirling-pdf/src/main/resources/messages_en_GB.properties",
ref: "main",
});
@@ -289,4 +277,4 @@ jobs:
rm -rf pr-branch
rm -f pr-branch-messages_en_GB.properties main-branch-messages_en_GB.properties changed_files.txt result.txt
echo "Cleanup complete."
continue-on-error: true # Ensure cleanup runs even if previous steps fail
continue-on-error: true # Ensure cleanup runs even if previous steps fail
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
-188
View File
@@ -1,188 +0,0 @@
name: Auto V2 Deploy on Push
on:
push:
branches:
- V2
- deploy-on-v2-commit
permissions:
contents: read
jobs:
deploy-v2-on-push:
runs-on: ubuntu-latest
concurrency:
group: deploy-v2-push-V2
cancel-in-progress: true
steps:
- name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Get commit hashes for frontend and backend
id: commit-hashes
run: |
# Get last commit that touched the frontend folder, docker/frontend, or docker/compose
FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "")
if [ -z "$FRONTEND_HASH" ]; then
FRONTEND_HASH="no-frontend-changes"
fi
# Get last commit that touched backend code, docker/backend, or docker/compose
BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
if [ -z "$BACKEND_HASH" ]; then
BACKEND_HASH="no-backend-changes"
fi
echo "Frontend hash: $FRONTEND_HASH"
echo "Backend hash: $BACKEND_HASH"
echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
# Short hashes for tags
if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
else
echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
fi
if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
echo "backend_short=no-backend" >> $GITHUB_OUTPUT
else
echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT
fi
- name: Check if frontend image exists
id: check-frontend
run: |
if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Frontend image already exists, skipping build"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Frontend image needs to be built"
fi
- name: Check if backend image exists
id: check-backend
run: |
if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Backend image already exists, skipping build"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Backend image needs to be built"
fi
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Build and push frontend image
if: steps.check-frontend.outputs.exists == 'false'
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/frontend/Dockerfile
push: true
tags: |
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Build and push backend image
if: steps.check-backend.outputs.exists == 'false'
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/backend/Dockerfile
push: true
tags: |
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-latest
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
chmod 600 ../private.key
- name: Deploy to VPS on port 3000
run: |
export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml
cat > $UNIQUE_NAME << EOF
version: '3.3'
services:
backend:
container_name: stirling-v2-backend
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
ports:
- "13000:8080"
volumes:
- /stirling/V2/data:/usr/share/tessdata:rw
- /stirling/V2/config:/configs:rw
- /stirling/V2/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false"
SYSTEM_DEFAULTLOCALE: en-GB
UI_APPNAME: "Stirling-PDF V2"
UI_HOMEDESCRIPTION: "V2 Frontend/Backend Split"
UI_APPNAMENAVBAR: "V2 Deployment"
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
SWAGGER_SERVER_URL: "http://${{ secrets.VPS_HOST }}:3000"
restart: on-failure:5
frontend:
container_name: stirling-v2-frontend
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
ports:
- "3000:80"
environment:
VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:13000"
depends_on:
- backend
restart: on-failure:5
EOF
# Copy to remote with unique name
scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/$UNIQUE_NAME
# SSH and rename/move atomically to avoid interference
ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
mkdir -p /stirling/V2/{data,config,logs}
mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml
cd /stirling/V2
docker-compose down || true
docker-compose pull
docker-compose up -d
docker system prune -af --volumes || true
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
ENDSSH
- name: Cleanup temporary files
if: always()
run: |
rm -f ../private.key
@@ -1,217 +0,0 @@
name: Frontend License Report Workflow
on:
push:
branches:
- V2
paths:
- "frontend/package.json"
- "frontend/package-lock.json"
- "frontend/scripts/generate-licenses.js"
pull_request:
branches:
- V2
paths:
- "frontend/package.json"
- "frontend/package-lock.json"
- "frontend/scripts/generate-licenses.js"
permissions:
contents: read
jobs:
generate-frontend-license-report:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Check out code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Setup GitHub App Bot
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Node.js
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Generate frontend license report
working-directory: frontend
run: npm run generate-licenses
- name: Check for license warnings
run: |
if [ -f "frontend/src/assets/license-warnings.json" ]; then
echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
else
echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
fi
# PR Event: Check licenses and comment on PR
- name: Delete previous license check comments
if: github.event_name == 'pull_request'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = context.issue.number;
// Get all comments on the PR
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
per_page: 100
});
// Filter for license check comments
const licenseComments = comments.filter(comment =>
comment.body.includes('## ✅ Frontend License Check Passed') ||
comment.body.includes('## ❌ Frontend License Check Failed')
);
// Delete old license check comments
for (const comment of licenseComments) {
console.log(`Deleting old license check comment: ${comment.id}`);
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id
});
}
- name: Comment on PR - License Check Results
if: github.event_name == 'pull_request'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = context.issue.number;
const hasWarnings = process.env.LICENSE_WARNINGS_EXIST === 'true';
let commentBody;
if (hasWarnings) {
// Read warnings file to get specific issues
const fs = require('fs');
let warningDetails = '';
try {
const warnings = JSON.parse(fs.readFileSync('frontend/src/assets/license-warnings.json', 'utf8'));
warningDetails = warnings.warnings.map(w => `- ${w.message}`).join('\n');
} catch (e) {
warningDetails = 'Unable to read warning details';
}
commentBody = `## ❌ Frontend License Check Failed
The frontend license check has detected compatibility warnings that require review:
${warningDetails}
**Action Required:** Please review these licenses to ensure they are acceptable for your use case before merging.
_This check will fail the PR until license issues are resolved._`;
} else {
commentBody = `## ✅ Frontend License Check Passed
All frontend licenses have been validated and no compatibility warnings were detected.
The frontend license report has been updated successfully.`;
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: commentBody
});
- name: Fail workflow if license warnings exist (PR only)
if: github.event_name == 'pull_request' && env.LICENSE_WARNINGS_EXIST == 'true'
run: |
echo "❌ License warnings detected. Failing the workflow."
exit 1
# Push Event: Commit license files and create PR
- name: Commit changes (Push only)
if: github.event_name == 'push'
run: |
git add frontend/src/assets/3rdPartyLicenses.json
# Note: Do NOT commit license-warnings.json - it's only for PR review
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
- name: Prepare PR body (Push only)
if: github.event_name == 'push'
run: |
PR_BODY="Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
This PR updates the frontend license report based on changes to package.json dependencies."
if [ "${{ env.LICENSE_WARNINGS_EXIST }}" = "true" ]; then
PR_BODY="$PR_BODY
## ⚠️ License Compatibility Warnings
The following licenses may require review for corporate compatibility:
$(cat frontend/src/assets/license-warnings.json | jq -r '.warnings[].message')
Please review these licenses to ensure they are acceptable for your use case."
fi
echo "PR_BODY<<EOF" >> $GITHUB_ENV
echo "$PR_BODY" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Create Pull Request (Push only)
id: cpr
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: "Update Frontend 3rd Party Licenses"
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: update-frontend-3rd-party-licenses
base: V2
title: "Update Frontend 3rd Party Licenses"
body: ${{ env.PR_BODY }}
labels: Licenses,github-actions,frontend
draft: false
delete-branch: true
sign-commits: true
- name: Enable Pull Request Automerge (Push only)
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'false'
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
env:
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
- name: Add review required label (Push only)
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'true'
run: gh pr edit "${{ steps.cpr.outputs.pull-request-number }}" --add-label "license-review-required"
env:
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
+4 -16
View File
@@ -7,18 +7,6 @@ on:
paths:
- "build.gradle"
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read
@@ -31,7 +19,7 @@ jobs:
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -51,7 +39,7 @@ jobs:
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
distribution: "adopt"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
@@ -69,11 +57,11 @@ jobs:
- name: Move and rename license file
run: |
mv build/reports/dependency-license/index.json app/core/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 app/core/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
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
issues: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
+8 -8
View File
@@ -21,7 +21,7 @@ jobs:
versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -60,7 +60,7 @@ jobs:
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -86,7 +86,7 @@ jobs:
run: |
mkdir ./binaries
mv ./build/launch4j/Stirling-PDF.exe ./binaries/win-Stirling-PDF-portable-Server${{ matrix.file_suffix }}.exe
mv ./app/core/build/libs/stirling-pdf-${{ needs.read_versions.outputs.version }}.jar ./binaries/Stirling-PDF${{ matrix.file_suffix }}.jar
mv ./stirling-pdf/build/libs/stirling-pdf-${{ needs.read_versions.outputs.version }}.jar ./binaries/Stirling-PDF${{ matrix.file_suffix }}.jar
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
@@ -110,7 +110,7 @@ jobs:
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -148,7 +148,7 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -238,7 +238,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -252,7 +252,7 @@ jobs:
- name: Install Cosign
if: matrix.os == 'windows-latest'
uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1
- name: Generate key pair
if: matrix.os == 'windows-latest'
@@ -301,7 +301,7 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
+3 -14
View File
@@ -2,9 +2,8 @@ name: Pre-commit
on:
workflow_dispatch:
push:
branches:
- main
schedule:
- cron: "0 0 * * 1"
permissions:
contents: read
@@ -17,7 +16,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -38,7 +37,6 @@ jobs:
with:
python-version: 3.12
cache: 'pip' # caching pip dependencies
cache-dependency-path: ./.github/scripts/requirements_pre_commit.txt
- name: Run Pre-Commit Hooks
run: |
@@ -47,15 +45,6 @@ jobs:
- run: pre-commit run --all-files -c .pre-commit-config.yaml
continue-on-error: true
- name: Set up JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: 17
distribution: "temurin"
- name: Build with Gradle
run: ./gradlew clean build
- name: git add
run: |
git add .
+7 -19
View File
@@ -7,18 +7,6 @@ on:
- master
- main
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read
@@ -30,7 +18,7 @@ jobs:
id-token: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -54,7 +42,7 @@ jobs:
- name: Install cosign
if: github.ref == 'refs/heads/master'
uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1
with:
cosign-release: "v2.4.1"
@@ -67,13 +55,13 @@ jobs:
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -88,7 +76,7 @@ jobs:
- name: Generate tags
id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
if: github.ref != 'refs/heads/main'
with:
images: |
@@ -134,7 +122,7 @@ jobs:
- name: Generate tags ultra-lite
id: meta2
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
if: github.ref != 'refs/heads/main'
with:
images: |
@@ -165,7 +153,7 @@ jobs:
- name: Generate tags fat
id: meta3
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
+4 -4
View File
@@ -23,7 +23,7 @@ jobs:
version: ${{ steps.versionNumber.outputs.versionNumber }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -83,7 +83,7 @@ jobs:
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -95,7 +95,7 @@ jobs:
run: ls -R
- name: Install Cosign
uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1
- name: Generate key pair
run: cosign generate-key-pair
@@ -161,7 +161,7 @@ jobs:
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
+2 -2
View File
@@ -34,7 +34,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -74,6 +74,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
uses: github/codeql-action/upload-sarif@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
with:
sarif_file: results.sarif
+1 -13
View File
@@ -9,18 +9,6 @@ on:
- main
workflow_dispatch:
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
pull-requests: read
actions: read
@@ -30,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
+2 -14
View File
@@ -6,18 +6,6 @@ on:
branches:
- master
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read
@@ -26,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -41,7 +29,7 @@ jobs:
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
- name: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
run: ./gradlew generateOpenApiDocs
- name: Upload Swagger Documentation to SwaggerHub
run: ./gradlew swaggerhubUpload
+6 -18
View File
@@ -8,22 +8,10 @@ on:
paths:
- "build.gradle"
- "README.md"
- "app/core/src/main/resources/messages_*.properties"
- "app/core/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"
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read
@@ -32,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -53,11 +41,11 @@ jobs:
- name: Sync translation property files
run: |
python .github/scripts/check_language_properties.py --reference-file "app/core/src/main/resources/messages_en_GB.properties" --branch main
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 app/core/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
@@ -113,4 +101,4 @@ jobs:
sign-commits: true
add-paths: |
README.md
app/core/src/main/resources/messages_*.properties
stirling-pdf/src/main/resources/messages_*.properties
+4 -26
View File
@@ -4,18 +4,6 @@ on:
push:
branches: ["master", "UITest", "testdriver"]
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read
@@ -24,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -37,11 +25,6 @@ jobs:
java-version: '17'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
with:
gradle-version: 8.14
- name: Build with Gradle
run: ./gradlew clean build
env:
@@ -57,7 +40,7 @@ jobs:
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
@@ -122,17 +105,12 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
cache: 'npm'
- name: Run TestDriver.ai
uses: testdriverai/action@f0d0f45fdd684db628baa843fe9313f3ca3a8aa8 #1.1.3
with:
@@ -156,7 +134,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
+4 -10
View File
@@ -27,7 +27,6 @@ clientWebUI/
!cucumber/exampleFiles/
!cucumber/exampleFiles/example_html.zip
exampleYmlFiles/stirling/
/stirling/
/testing/file_snapshots
SwaggerDoc.json
@@ -125,13 +124,10 @@ SwaggerDoc.json
*.tar.gz
*.rar
*.db
build
app/core/build
app/common/build
app/proprietary/build
common/build
proprietary/build
stirling-pdf/build
/build
/stirling-pdf/build
/common/build
/proprietary/build
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -197,8 +193,6 @@ id_ed25519.pub
.pytest_cache
.ipynb_checkpoints
**/jcef-bundle/
# node_modules
+5 -15
View File
@@ -1,15 +1,15 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.7
rev: v0.12.0
hooks:
- id: ruff
args:
- --fix
- --line-length=127
files: ^((\.github/scripts|scripts|app/core/src/main/resources/static/python)/.+)?[^/]+\.py$
files: ^((\.github/scripts|scripts)/.+)?[^/]+\.py$
exclude: (split_photos.py)
- id: ruff-format
files: ^((\.github/scripts|scripts|app/core/src/main/resources/static/python)/.+)?[^/]+\.py$
files: ^((\.github/scripts|scripts)/.+)?[^/]+\.py$
exclude: (split_photos.py)
- repo: https://github.com/codespell-project/codespell
rev: v2.4.1
@@ -20,9 +20,9 @@ repos:
- --skip="./.*,*.csv,*.json,*.ambr"
- --quiet-level=2
files: \.(html|css|js|py|md)$
exclude: (.vscode|.devcontainer|app/core/src/main/resources|app/proprietary/src/main/resources|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
exclude: (.vscode|.devcontainer|stirling-pdf/src/main/resources|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
- repo: https://github.com/gitleaks/gitleaks
rev: v8.28.0
rev: v8.27.2
hooks:
- id: gitleaks
- repo: https://github.com/pre-commit/pre-commit-hooks
@@ -34,13 +34,3 @@ repos:
- id: trailing-whitespace
files: ^.*(\.js|\.java|\.py|\.yml)$
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$)
# - repo: https://github.com/thibaudcolas/pre-commit-stylelint
# rev: v16.21.1
# hooks:
# - id: stylelint
# additional_dependencies:
# - stylelint@16.21.1
# - stylelint-config-standard@38.0.0
# - "@stylistic/stylelint-plugin@3.1.3"
# files: \.(css)$
# args: [--fix]
-2
View File
@@ -17,7 +17,5 @@
"GitHub.vscode-pull-request-github", // GitHub Pull Requests extension for Visual Studio Code
"charliermarsh.ruff", // Ruff code formatter for Python to follow the Ruff Style Guide
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
"redhat.vscode-yaml", // YAML extension for Visual Studio Code
]
}
+10 -26
View File
@@ -2,20 +2,13 @@
"editor.wordSegmenterLocales": "",
"editor.guides.bracketPairs": "active",
"editor.guides.bracketPairsHorizontal": "active",
"editor.defaultFormatter": "EditorConfig.EditorConfig",
"cSpell.enabled": false,
"[feature]": {
"editor.defaultFormatter": "alexkrechik.cucumberautocomplete"
},
"[java]": {
"editor.defaultFormatter": "josevseb.google-java-format-for-vs-code"
},
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"[css]": {
"editor.defaultFormatter": "stylelint.vscode-stylelint"
},
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
@@ -34,14 +27,11 @@
"[gradle]": {
"editor.defaultFormatter": "vscjava.vscode-gradle"
},
"[yaml]": {
"editor.defaultFormatter": "redhat.vscode-yaml"
},
"java.compile.nullAnalysis.mode": "automatic",
"java.configuration.updateBuildConfiguration": "interactive",
"java.format.enabled": true,
"java.format.settings.profile": "GoogleStyle",
"java.format.settings.google.version": "1.28.0",
"java.format.settings.google.version": "1.27.0",
"java.format.settings.google.extra": "--aosp --skip-sorting-imports --skip-javadoc-formatting",
// (DE) Aktiviert Kommentare im Java-Format.
// (EN) Enables comments in Java formatting.
@@ -80,17 +70,13 @@
".venv*/",
".vscode/",
"bin/",
"app/core/bin/",
"app/common/bin/",
"app/proprietary/bin/",
"common/bin/",
"proprietary/bin/",
"build/",
"app/core/build/",
"app/common/build/",
"app/proprietary/build/",
"common/build/",
"proprietary/build/",
"configs/",
"app/core/configs/",
"customFiles/",
"app/core/customFiles/",
"docs/",
"exampleYmlFiles",
"gradle/",
@@ -102,9 +88,8 @@
".git-blame-ignore-revs",
".gitattributes",
".gitignore",
"app/core/.gitignore",
"app/common/.gitignore",
"app/proprietary/.gitignore",
"common/.gitignore",
"proprietary/.gitignore",
".pre-commit-config.yaml",
],
// Enables signature help in Java.
@@ -134,10 +119,9 @@
"html.format.indentHandlebars": true,
"html.format.preserveNewLines": true,
"html.format.maxPreserveNewLines": 2,
"stylelint.configFile": "devTools/.stylelintrc.json",
"java.project.sourcePaths": [
"app/core/src/main/java",
"app/common/src/main/java",
"app/proprietary/src/main/java"
"stirling-pdf/src/main/java",
"common/src/main/java",
"proprietary/src/main/java"
]
}
-224
View File
@@ -1,224 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Common Development Commands
### Build and Test
- **Build project**: `./gradlew clean build`
- **Run locally**: `./gradlew bootRun`
- **Full test suite**: `./test.sh` (builds all Docker variants and runs comprehensive tests)
- **Code formatting**: `./gradlew spotlessApply` (runs automatically before compilation)
### Docker Development
- **Build ultra-lite**: `docker build -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite .`
- **Build standard**: `docker build -t stirlingtools/stirling-pdf:latest -f ./Dockerfile .`
- **Build fat version**: `docker build -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .`
- **Example compose files**: Located in `exampleYmlFiles/` directory
### Security Mode Development
Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security features during development. This is required for testing the full version locally.
### Frontend Development
- **Frontend dev server**: `cd frontend && npm run dev` (requires backend on localhost:8080)
- **Tech Stack**: Vite + React + TypeScript + Mantine UI + TailwindCSS
- **Proxy Configuration**: Vite proxies `/api/*` calls to backend (localhost:8080)
- **Build Process**: DO NOT run build scripts manually - builds are handled by CI/CD pipelines
- **Package Installation**: DO NOT run npm install commands - package management handled separately
- **Deployment Options**:
- **Desktop App**: `npm run tauri-build` (native desktop application)
- **Web Server**: `npm run build` then serve dist/ folder
- **Development**: `npm run tauri-dev` for desktop dev mode
#### Multi-Tool Workflow Architecture
Frontend designed for **stateful document processing**:
- Users upload PDFs once, then chain tools (split → merge → compress → view)
- File state and processing results persist across tool switches
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
#### FileContext - Central State Management
**Location**: `src/contexts/FileContext.tsx`
- **Active files**: Currently loaded PDFs and their variants
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
- **IndexedDB persistence**: File storage with thumbnail caching
- **Preview system**: Tools can preview results (e.g., Split → Viewer → back to Split) without context pollution
**Critical**: All file operations go through FileContext. Don't bypass with direct file handling.
#### Processing Services
- **enhancedPDFProcessingService**: Background PDF parsing and manipulation
- **thumbnailGenerationService**: Web Worker-based with main-thread fallback
- **fileStorage**: IndexedDB with LRU cache management
#### Memory Management Strategy
**Why manual cleanup exists**: Large PDFs (up to 100GB+) through multiple tools accumulate:
- PDF.js documents that need explicit .destroy() calls
- Blob URLs from tool outputs that need revocation
- Web Workers that need termination
Without cleanup: browser crashes with memory leaks.
#### Tool Development
**Architecture**: Modular hook-based system with clear separation of concerns:
- **useToolOperation** (`frontend/src/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
- Coordinates all tool operations with consistent interface
- Integrates with FileContext for operation tracking
- Handles validation, error handling, and UI state management
- **Supporting Hooks**:
- **useToolState**: UI state management (loading, progress, error, files)
- **useToolApiCalls**: HTTP requests and file processing
- **useToolResources**: Blob URLs, thumbnails, ZIP downloads
- **Utilities**:
- **toolErrorHandler**: Standardized error extraction and i18n support
- **toolResponseProcessor**: API response handling (single/zip/custom)
- **toolOperationTracker**: FileContext integration utilities
**Three Tool Patterns**:
**Pattern 1: Single-File Tools** (Individual processing)
- Backend processes one file per API call
- Set `multiFileEndpoint: false`
- Examples: Compress, Rotate
```typescript
return useToolOperation({
operationType: 'compress',
endpoint: '/api/v1/misc/compress-pdf',
buildFormData: (params, file: File) => { /* single file */ },
multiFileEndpoint: false,
filePrefix: 'compressed_'
});
```
**Pattern 2: Multi-File Tools** (Batch processing)
- Backend accepts `MultipartFile[]` arrays in single API call
- Set `multiFileEndpoint: true`
- Examples: Split, Merge, Overlay
```typescript
return useToolOperation({
operationType: 'split',
endpoint: '/api/v1/general/split-pages',
buildFormData: (params, files: File[]) => { /* all files */ },
multiFileEndpoint: true,
filePrefix: 'split_'
});
```
**Pattern 3: Complex Tools** (Custom processing)
- Tools with complex routing logic or non-standard processing
- Provide `customProcessor` for full control
- Examples: Convert, OCR
```typescript
return useToolOperation({
operationType: 'convert',
customProcessor: async (params, files) => { /* custom logic */ },
filePrefix: 'converted_'
});
```
**Benefits**:
- **No Timeouts**: Operations run until completion (supports 100GB+ files)
- **Consistent**: All tools follow same pattern and interface
- **Maintainable**: Single responsibility hooks, easy to test and modify
- **i18n Ready**: Built-in internationalization support
- **Type Safe**: Full TypeScript support with generic interfaces
- **Memory Safe**: Automatic resource cleanup and blob URL management
## Architecture Overview
### Project Structure
- **Backend**: Spring Boot application with Thymeleaf templating
- **Frontend**: React-based SPA in `/frontend` directory (Thymeleaf templates fully replaced)
- **File Storage**: IndexedDB for client-side file persistence and thumbnails
- **Internationalization**: JSON-based translations (converted from backend .properties)
- **PDF Processing**: PDFBox for core PDF operations, LibreOffice for conversions, PDF.js for client-side rendering
- **Security**: Spring Security with optional authentication (controlled by `DOCKER_ENABLE_SECURITY`)
- **Configuration**: YAML-based configuration with environment variable overrides
### Controller Architecture
- **API Controllers** (`src/main/java/.../controller/api/`): REST endpoints for PDF operations
- Organized by function: converters, security, misc, pipeline
- Follow pattern: `@RestController` + `@RequestMapping("/api/v1/...")`
- **Web Controllers** (`src/main/java/.../controller/web/`): Serve Thymeleaf templates
- Pattern: `@Controller` + return template names
### Key Components
- **SPDFApplication.java**: Main application class with desktop UI and browser launching logic
- **ConfigInitializer**: Handles runtime configuration and settings files
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
- **Security Layer**: Authentication, authorization, and user management (when enabled)
### Component Architecture
- **React Components**: Located in `frontend/src/components/` and `frontend/src/tools/`
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
- **Internationalization**:
- Backend: `messages_*.properties` files
- Frontend: JSON files in `frontend/public/locales/` (converted from .properties)
- Conversion Script: `scripts/convert_properties_to_json.py`
### Configuration Modes
- **Ultra-lite**: Basic PDF operations only
- **Standard**: Full feature set
- **Fat**: Pre-downloaded dependencies for air-gapped environments
- **Security Mode**: Adds authentication, user management, and enterprise features
### Testing Strategy
- **Integration Tests**: Cucumber tests in `testing/cucumber/`
- **Docker Testing**: `test.sh` validates all Docker variants
- **Manual Testing**: No unit tests currently - relies on UI and API testing
## Development Workflow
1. **Local Development**:
- Backend: `./gradlew bootRun` (runs on localhost:8080)
- Frontend: `cd frontend && npm run dev` (runs on localhost:5173, proxies to backend)
2. **Docker Testing**: Use `./test.sh` before submitting PRs
3. **Code Style**: Spotless enforces Google Java Format automatically
4. **Translations**:
- Backend: Use helper scripts in `/scripts` for multi-language updates
- Frontend: Update JSON files in `frontend/public/locales/` or use conversion script
5. **Documentation**: API docs auto-generated and available at `/swagger-ui/index.html`
## Frontend Architecture Status
- **Core Status**: React SPA architecture complete with multi-tool workflow support
- **State Management**: FileContext handles all file operations and tool navigation
- **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+)
- **Tool Integration**: Modular hook architecture with `useToolOperation` orchestrator
- Individual hooks: `useToolState`, `useToolApiCalls`, `useToolResources`
- Utilities: `toolErrorHandler`, `toolResponseProcessor`, `toolOperationTracker`
- Pattern: Each tool creates focused operation hook, UI consumes state/actions
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
## Important Notes
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21
- **Lombok**: Used extensively - ensure IDE plugin is installed
- **Desktop Mode**: Set `STIRLING_PDF_DESKTOP_UI=true` for desktop application mode
- **File Persistence**:
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation)
## Communication Style
- Be direct and to the point
- No apologies or conversational filler
- Answer questions directly without preamble
- Explain reasoning concisely when asked
- Avoid unnecessary elaboration
## Decision Making
- Ask clarifying questions before making assumptions
- Stop and ask when uncertain about project-specific details
- Confirm approach before making structural changes
- Request guidance on preferences (cross-platform vs specific tools, etc.)
- Verify understanding of requirements before proceeding
-692
View File
@@ -1,692 +0,0 @@
# Stirling-PDF Developer Guide
## 1. Introduction
Stirling-PDF is a robust, locally hosted, web-based PDF manipulation tool. **Stirling 2.0** represents a complete frontend rewrite, replacing the legacy Thymeleaf-based UI with a modern React SPA (Single Page Application).
This guide focuses on developing for Stirling 2.0, including both the React frontend and Spring Boot backend development workflows.
## 2. Project Overview
**Stirling 2.0** is built using:
**Backend:**
- Spring Boot (Java 17+, JDK 21 recommended)
- PDFBox for core PDF operations
- LibreOffice for document conversions
- qpdf for PDF optimization
- Spring Security (optional, controlled by `DOCKER_ENABLE_SECURITY`)
- Lombok for reducing boilerplate code
**Frontend (React SPA):**
- React + TypeScript
- Vite for build tooling and development server
- Mantine UI component library
- TailwindCSS for styling
- PDF.js for client-side PDF rendering
- PDF-LIB.js for client-side PDF manipulation
- IndexedDB for client-side file storage and thumbnails
- i18next for internationalization
**Infrastructure:**
- Docker for containerization
- Gradle for build management
**Legacy (reference only during development):**
- Thymeleaf templates (being completely replaced in 2.0)
## 3. Development Environment Setup
### Prerequisites
- Docker
- Git
- Java JDK 17 or later (JDK 21 recommended)
- Node.js 18+ and npm (required for frontend development)
- Gradle 7.0 or later (Included within the repo)
### Setup Steps
1. Clone the repository:
```bash
git clone https://github.com/Stirling-Tools/Stirling-PDF.git
cd Stirling-PDF
```
2. Install Docker and JDK17 if not already installed.
3. Install a recommended Java IDE such as Eclipse, IntelliJ, or VSCode
1. Only VSCode
1. Open VS Code.
2. When prompted, install the recommended extensions.
3. Alternatively, open the command palette (`Ctrl + Shift + P` or `Cmd + Shift + P` on macOS) and run:
```sh
Extensions: Show Recommended Extensions
```
4. Install the required extensions from the list.
4. Lombok Setup
Stirling-PDF uses Lombok to reduce boilerplate code. Some IDEs, like Eclipse, don't support Lombok out of the box. To set up Lombok in your development environment:
Visit the [Lombok website](https://projectlombok.org/setup/) for installation instructions specific to your IDE.
5. Add environment variable
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step.
5. **Frontend Setup (Required for Stirling 2.0)**
Navigate to the frontend directory and install dependencies using npm.
## 4. Stirling 2.0 Development Workflow
### Frontend Development (React)
The frontend is a React SPA that runs independently during development:
1. **Start the backend**: Run the Spring Boot application (serves API endpoints on localhost:8080)
2. **Start the frontend dev server**: Navigate to the frontend directory and run the development server (serves UI on localhost:5173)
3. **Development flow**: The Vite dev server automatically proxies API calls to the backend
### File Storage Architecture
Stirling 2.0 uses client-side file storage:
- **IndexedDB**: Stores files locally in the browser with automatic thumbnail generation
- **PDF.js**: Handles client-side PDF rendering and processing
- **URL Parameters**: Support for deep linking and tool state persistence
### Legacy Code Reference
The existing Thymeleaf templates remain in the codebase during development as reference material but will be completely removed for the 2.0 release.
## 5. Project Structure
```bash
Stirling-PDF/
├── .github/ # GitHub-specific files (workflows, issue templates)
├── configs/ # Configuration files used by stirling at runtime (generated at runtime)
├── frontend/ # React SPA frontend (Stirling 2.0)
│ ├── src/
│ │ ├── components/ # React components
│ │ ├── tools/ # Tool-specific React components
│ │ ├── hooks/ # Custom React hooks
│ │ ├── services/ # API and utility services
│ │ ├── types/ # TypeScript type definitions
│ │ └── utils/ # Utility functions
│ ├── public/
│ │ └── locales/ # Internationalization files (JSON)
│ ├── package.json # Frontend dependencies
│ └── vite.config.ts # Vite configuration
├── customFiles/ # Custom static files and templates (generated at runtime used to replace existing files)
├── docs/ # Documentation files
├── exampleYmlFiles/ # Example YAML configuration files
├── images/ # Image assets
├── pipeline/ # Pipeline-related files (generated at runtime)
├── scripts/ # Utility scripts
├── src/ # Source code
│ ├── main/
│ │ ├── java/
│ │ │ └── stirling/
│ │ │ └── software/
│ │ │ └── SPDF/
│ │ │ ├── config/
│ │ │ ├── controller/
│ │ │ ├── model/
│ │ │ ├── repository/
│ │ │ ├── service/
│ │ │ └── utils/
│ │ └── resources/
│ │ ├── static/ # Legacy static assets (reference only)
│ │ │ ├── css/
│ │ │ ├── js/
│ │ │ └── pdfjs/
│ │ └── templates/ # Legacy Thymeleaf templates (reference only)
│ └── test/
├── testing/ # Cucumber and integration tests
│ └── cucumber/ # Cucumber test files
├── build.gradle # Gradle build configuration
├── Dockerfile # Main Dockerfile
├── Dockerfile.ultra-lite # Dockerfile for ultra-lite version
├── Dockerfile.fat # Dockerfile for fat version
├── docker-compose.yml # Docker Compose configuration
└── test.sh # Test script to deploy all docker versions and run cuke tests
```
## 6. Docker-based Development
Stirling-PDF offers several Docker versions:
- Full: All features included
- Ultra-Lite: Basic PDF operations only
- Fat: Includes additional libraries and fonts predownloaded
### Example Docker Compose Files
Stirling-PDF provides several example Docker Compose files in the `exampleYmlFiles` directory, such as:
- `docker-compose-latest.yml`: Latest version without login and security features
- `docker-compose-latest-security.yml`: Latest version with login and security features enabled
- `docker-compose-latest-fat-security.yml`: Fat version with login and security features enabled
These files provide pre-configured setups for different scenarios. For example, here's a snippet from `docker-compose-latest-security.yml`:
```yaml
services:
stirling-pdf:
container_name: Stirling-PDF-Security
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest
deploy:
resources:
limits:
memory: 4G
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -q 'Please sign in'"]
interval: 5s
timeout: 10s
retries: 16
ports:
- "8080:8080"
volumes:
- ./stirling/latest/data:/usr/share/tessdata:rw
- ./stirling/latest/config:/configs:rw
- ./stirling/latest/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "false"
SECURITY_ENABLELOGIN: "true"
PUID: 1002
PGID: 1002
UMASK: "022"
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: Stirling-PDF
UI_HOMEDESCRIPTION: Demo site for Stirling-PDF Latest with Security
UI_APPNAMENAVBAR: Stirling-PDF Latest
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "true"
SHOW_SURVEY: "true"
restart: on-failure:5
```
To use these example files, copy the desired file to your project root and rename it to `docker-compose.yml`, or specify the file explicitly when running Docker Compose:
```bash
docker-compose -f exampleYmlFiles/docker-compose-latest-security.yml up
```
### Building Docker Images
Stirling-PDF uses different Docker images for various configurations. The build process is controlled by environment variables and uses specific Dockerfile variants. Here's how to build the Docker images:
1. Set the security environment variable:
```bash
export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds
```
2. Build the project with Gradle:
```bash
./gradlew clean build
```
3. Build the Docker images:
For the latest version:
```bash
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest -f ./Dockerfile .
```
For the ultra-lite version:
```bash
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite .
```
For the fat version (with login and security features enabled):
```bash
export DISABLE_ADDITIONAL_FEATURES=false
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .
```
Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. however to improve build times these can often be removed depending on your usecase
## 7. Testing
### Comprehensive Testing Script
Stirling-PDF provides a `test.sh` script in the root directory. This script builds all versions of Stirling-PDF, checks that each version works, and runs Cucumber tests. It's recommended to run this script before submitting a final pull request.
To run the test script:
```bash
./test.sh
```
This script performs the following actions:
1. Builds all Docker images (full, ultra-lite, fat).
2. Runs each version to ensure it starts correctly.
3. Executes Cucumber tests against the main version and ensures feature compatibility. In the event these tests fail, your PR will not be merged.
Note: The `test.sh` script will run automatically when you raise a PR. However, it's recommended to run it locally first to save resources and catch any issues early.
### Full Testing with Docker
1. Build and run the Docker container per the above instructions:
2. Access the application at `http://localhost:8080` and manually test all features developed.
### Frontend Development Testing (Stirling 2.0)
For React frontend development:
1. Start the backend: Run the Spring Boot application to serve API endpoints on localhost:8080
2. Start the frontend dev server: Navigate to the frontend directory and run the development server on localhost:5173
3. The Vite dev server automatically proxies API calls to the backend
4. Test React components, UI interactions, and IndexedDB file operations using browser developer tools
### Local Testing (Java and UI Components)
For quick iterations and development of Java backend, JavaScript, and UI components, you can run and test Stirling-PDF locally without Docker. This approach allows you to work on and verify changes to:
- Java backend logic
- RESTful API endpoints
- JavaScript functionality
- User interface components and styling
- Thymeleaf templates
To run Stirling-PDF locally:
1. Compile and run the project using built-in IDE methods or by running:
```bash
./gradlew bootRun
```
2. Access the application at `http://localhost:8080` in your web browser.
3. Manually test the features you're working on through the UI.
4. For API changes, use tools like Postman or curl to test endpoints directly.
Important notes:
- Local testing doesn't include features that depend on external tools like qpdf, LibreOffice, or Python scripts.
- There are currently no automated unit tests. All testing is done manually through the UI or API calls. (You are welcome to add JUnits!)
- Always verify your changes in the full Docker environment before submitting pull requests, as some integrations and features will only work in the complete setup.
## 8. Contributing
1. Fork the repository on GitHub.
2. Create a new branch for your feature or bug fix.
3. Make your changes and commit them with clear, descriptive messages and ensure any documentation is updated related to your changes.
4. Test your changes thoroughly in the Docker environment.
5. Run the `test.sh` script to ensure all versions build correctly and pass the Cucumber tests:
```bash
./test.sh
```
6. Push your changes to your fork.
7. Submit a pull request to the main repository.
8. See additional [contributing guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md).
When you raise a PR:
- The `test.sh` script will run automatically against your PR.
- The PR checks will verify versioning and dependency updates.
- Documentation will be automatically updated for dependency changes.
- Security issues will be checked using Snyk and PixeeBot.
Address any issues that arise from these checks before finalizing your pull request.
## 9. API Documentation
API documentation is available at `/swagger-ui/index.html` when running the application. You can also view the latest API documentation [here](https://app.swaggerhub.com/apis-docs/Stirling-Tools/Stirling-PDF/).
## 10. Customization
Stirling-PDF can be customized through environment variables or a `settings.yml` file. Key customization options include:
- Application name and branding
- Security settings
- UI customization
- Endpoint management
When using Docker, pass environment variables using the `-e` flag or in your `docker-compose.yml` file.
Example:
```bash
docker run -p 8080:8080 -e APP_NAME="My PDF Tool" stirling-pdf:full
```
Refer to the main README for a full list of customization options.
## 11. Language Translations
For managing language translations that affect multiple files, Stirling-PDF provides a helper script:
```bash
/scripts/replace_translation_line.sh
```
This script helps you make consistent replacements across language files.
When contributing translations:
1. Use the helper script for multi-file changes.
2. Ensure all language files are updated consistently.
3. The PR checks will verify consistency in language file updates.
Remember to test your changes thoroughly to ensure they don't break any existing functionality.
## Code examples
### React Component Development (Stirling 2.0)
For Stirling 2.0, new features are built as React components instead of Thymeleaf templates:
#### Creating a New Tool Component
1. **Create the React Component:**
```typescript
// frontend/src/tools/NewTool.tsx
import { useState } from 'react';
import { Button, FileInput, Container } from '@mantine/core';
interface NewToolProps {
params: Record<string, any>;
updateParams: (updates: Record<string, any>) => void;
}
export default function NewTool({ params, updateParams }: NewToolProps) {
const [files, setFiles] = useState<File[]>([]);
const handleProcess = async () => {
// Process files using API or client-side logic
};
return (
<Container>
<FileInput
multiple
accept="application/pdf"
onChange={setFiles}
/>
<Button onClick={handleProcess}>Process</Button>
</Container>
);
}
```
2. **Add API Integration:**
```typescript
// Use existing API endpoints or create new ones
const response = await fetch('/api/v1/new-tool', {
method: 'POST',
body: formData
});
```
3. **Register in Tool Picker:**
Update the tool picker component to include the new tool with proper routing and URL parameter support.
### Legacy Reference: Overview of Thymeleaf
Thymeleaf is a server-side Java HTML template engine. It is used in Stirling-PDF to render dynamic web pages. Thymeleaf integrates heavily with Spring Boot.
### Thymeleaf overview
In Stirling-PDF, Thymeleaf is used to create HTML templates that are rendered on the server side. These templates are located in the `stirling-pdf/src/main/resources/templates` directory. Thymeleaf templates use a combination of HTML and special Thymeleaf attributes to dynamically generate content.
Some examples of this are:
```html
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
```
or
```html
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
```
Where it uses the `th:block`, `th:` indicating it's a special Thymeleaf element to be used server-side in generating the HTML, and block being the actual element type.
In this case, we are inserting the `navbar` entry within the `fragments/navbar.html` fragment into the `th:block` element.
They can be more complex, such as:
```html
<th:block th:insert="~{fragments/common :: head(title=#{pageExtracter.title}, header=#{pageExtracter.header})}"></th:block>
```
Which is the same as above but passes the parameters title and header into the fragment `common.html` to be used in its HTML generation.
Thymeleaf can also be used to loop through objects or pass things from the Java side into the HTML side.
```java
@GetMapping
public String newFeaturePage(Model model) {
model.addAttribute("exampleData", exampleData);
return "new-feature";
}
```
In the above example, if exampleData is a list of plain java objects of class Person and within it, you had id, name, age, etc. You can reference it like so
```html
<tbody>
<!-- Use th:each to iterate over the list -->
<tr th:each="person : ${exampleData}">
<td th:text="${person.id}"></td>
<td th:text="${person.name}"></td>
<td th:text="${person.age}"></td>
<td th:text="${person.email}"></td>
</tr>
</tbody>
```
This would generate n entries of tr for each person in exampleData
### Adding a New Feature to the Backend (API)
1. **Create a New Controller:**
- Create a new Java class in the `stirling-pdf/src/main/java/stirling/software/SPDF/controller/api` directory.
- Annotate the class with `@RestController` and `@RequestMapping` to define the API endpoint.
- Ensure to add API documentation annotations like `@Tag(name = "General", description = "General APIs")` and `@Operation(summary = "Crops a PDF document", description = "This operation takes an input PDF file and crops it according to the given coordinates. Input:PDF Output:PDF Type:SISO")`.
```java
package stirling.software.SPDF.controller.api;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@RestController
@RequestMapping("/api/v1/new-feature")
@Tag(name = "General", description = "General APIs")
public class NewFeatureController {
@GetMapping
@Operation(summary = "New Feature", description = "This is a new feature endpoint.")
public String newFeature() {
return "NewFeatureResponse"; // This refers to the NewFeatureResponse.html template presenting the user with the generated html from that file when they navigate to /api/v1/new-feature
}
}
```
2. **Define the Service Layer:** (Not required but often useful)
- Create a new service class in the `stirling-pdf/src/main/java/stirling/software/SPDF/service` directory.
- Implement the business logic for the new feature.
```java
package stirling.software.SPDF.service;
import org.springframework.stereotype.Service;
@Service
public class NewFeatureService {
public String getNewFeatureData() {
// Implement business logic here
return "New Feature Data";
}
}
```
2b. **Integrate the Service with the Controller:**
- Autowire the service class in the controller and use it to handle the API request.
```java
package stirling.software.SPDF.controller.api;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import stirling.software.SPDF.service.NewFeatureService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@RestController
@RequestMapping("/api/v1/new-feature")
@Tag(name = "General", description = "General APIs")
public class NewFeatureController {
@Autowired
private NewFeatureService newFeatureService;
@GetMapping
@Operation(summary = "New Feature", description = "This is a new feature endpoint.")
public String newFeature() {
return newFeatureService.getNewFeatureData();
}
}
```
### Adding a New Feature to the Frontend (UI)
1. **Create a New Thymeleaf Template:**
- Create a new HTML file in the `stirling-pdf/src/main/resources/templates` directory.
- Use Thymeleaf attributes to dynamically generate content.
- Use `extract-page.html` as a base example for the HTML template, which is useful to ensure importing of the general layout, navbar, and footer.
```html
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title=#{newFeature.title}, header=#{newFeature.header})}"></th:block>
</head>
<body>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon organize">upload</span>
<span class="tool-header-text" th:text="#{newFeature.header}"></span>
</div>
<form th:action="@{'/api/v1/new-feature'}" method="post" enctype="multipart/form-data">
<div th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='application/pdf')}"></div>
<input type="hidden" id="customMode" name="customMode" value="">
<div class="mb-3">
<label for="featureInput" th:text="#{newFeature.prompt}"></label>
<input type="text" class="form-control" id="featureInput" name="featureInput" th:placeholder="#{newFeature.placeholder}" required>
</div>
<button type="submit" id="submitBtn" class="btn btn-primary" th:text="#{newFeature.submit}"></button>
</form>
</div>
</div>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>
```
2. **Create a New Controller for the UI:**
- Create a new Java class in the `stirling-pdf/src/main/java/stirling/software/SPDF/controller/ui` directory.
- Annotate the class with `@Controller` and `@RequestMapping` to define the UI endpoint.
```java
package stirling.software.SPDF.controller.ui;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import stirling.software.SPDF.service.NewFeatureService;
@Controller
@RequestMapping("/new-feature")
public class NewFeatureUIController {
@Autowired
private NewFeatureService newFeatureService;
@GetMapping
public String newFeaturePage(Model model) {
model.addAttribute("newFeatureData", newFeatureService.getNewFeatureData());
return "new-feature";
}
}
```
3. **Update the Navigation Bar:**
- Add a link to the new feature page in the navigation bar.
- Update the `stirling-pdf/src/main/resources/templates/fragments/navbar.html` file.
```html
<li class="nav-item">
<a class="nav-link" th:href="@{'/new-feature'}">New Feature</a>
</li>
```
## Adding New Translations to Existing Language Files in Stirling-PDF
When adding a new feature or modifying existing ones in Stirling-PDF, you'll need to add new translation entries to the existing language files. Here's a step-by-step guide:
### 1. Locate Existing Language Files
Find the existing `messages.properties` files in the `stirling-pdf/src/main/resources` directory. You'll see files like:
- `messages.properties` (default, usually English)
- `messages_en_GB.properties`
- `messages_fr_FR.properties`
- `messages_de_DE.properties`
- etc.
### 2. Add New Translation Entries
Open each of these files and add your new translation entries. For example, if you're adding a new feature called "PDF Splitter",
Use descriptive, hierarchical keys (e.g., `feature.element.description`)
you might add:
```properties
pdfSplitter.title=PDF Splitter
pdfSplitter.description=Split your PDF into multiple documents
pdfSplitter.button.split=Split PDF
pdfSplitter.input.pages=Enter page numbers to split
```
Add these entries to the default GB language file and any others you wish, translating the values as appropriate for each language.
### 3. Use Translations in Thymeleaf Templates
In your Thymeleaf templates, use the `#{key}` syntax to reference the new translations:
```html
<h1 th:text="#{pdfSplitter.title}">PDF Splitter</h1>
<p th:text="#{pdfSplitter.description}">Split your PDF into multiple documents</p>
<input type="text" th:placeholder="#{pdfSplitter.input.pages}">
<button th:text="#{pdfSplitter.button.split}">Split PDF</button>
```
Remember, never hard-code text in your templates or Java code. Always use translation keys to ensure proper localization.
+20 -39
View File
@@ -1,40 +1,16 @@
# Backend Dockerfile - Java Spring Boot with all dependencies and build stage
# Build the application
FROM gradle:8.14-jdk21 AS build
COPY build.gradle .
COPY settings.gradle .
COPY gradlew .
COPY gradle gradle/
COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/.
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
# Set the working directory
WORKDIR /app
# Copy the entire project to the working directory
COPY . .
# Build the application with DISABLE_ADDITIONAL_FEATURES=false
RUN DISABLE_ADDITIONAL_FEATURES=false \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Main stage
FROM alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1
FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
# Copy necessary files
COPY scripts /scripts
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
# first /app directory is for the build stage, second is for the final image
COPY --from=build /app/app/core/build/libs/*.jar app.jar
COPY pipeline /pipeline
COPY stirling-pdf/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
COPY stirling-pdf/build/libs/*.jar app.jar
ARG VERSION_TAG
LABEL org.opencontainers.image.title="Stirling-PDF Backend"
LABEL org.opencontainers.image.description="Backend service for Stirling-PDF - Java Spring Boot with PDF processing capabilities"
LABEL org.opencontainers.image.title="Stirling-PDF"
LABEL org.opencontainers.image.description="A powerful locally hosted web-based PDF manipulation tool supporting 50+ operations including merging, splitting, conversion, OCR, watermarking, and more."
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.vendor="Stirling-Tools"
@@ -43,10 +19,10 @@ LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
LABEL maintainer="Stirling-Tools"
LABEL org.opencontainers.image.authors="Stirling-Tools"
LABEL org.opencontainers.image.version="${VERSION_TAG}"
LABEL org.opencontainers.image.keywords="PDF, manipulation, backend, API, Spring Boot"
LABEL org.opencontainers.image.keywords="PDF, manipulation, merge, split, convert, OCR, watermark"
# Set Environment Variables
ENV DISABLE_ADDITIONAL_FEATURES=false \
ENV 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="" \
@@ -63,7 +39,8 @@ ENV DISABLE_ADDITIONAL_FEATURES=false \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# JDK for app and all dependencies
# JDK for app
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
@@ -86,19 +63,21 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
# pdftohtml
poppler-utils \
# OCR MY PDF (unpaper for descew and other advanced features)
unpaper \
tesseract-ocr-data-eng \
tesseract-ocr-data-chi_sim \
tesseract-ocr-data-deu \
tesseract-ocr-data-fra \
tesseract-ocr-data-por \
ocrmypdf \
unpaper \
# CV
py3-opencv \
python3 \
ocrmypdf \
py3-pip \
py3-pillow@testing \
py3-pdf2image@testing && \
py3-pdf2image@testing \
# URW Base 35 fonts for better PDF rendering
font-urw-base35 && \
python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --upgrade pip setuptools && \
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
@@ -106,17 +85,19 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
mv /usr/share/tessdata /usr/share/tessdata-original && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf /pipeline/watchedFolders /pipeline/finishedFolders && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
# Configure URW Base 35 fonts
ln -s /usr/share/fontconfig/conf.avail/69-urw-*.conf /etc/fonts/conf.d/ && \
fc-cache -f -v && \
chmod +x /scripts/* && \
chmod +x /scripts/init.sh && \
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar
EXPOSE 8080/tcp
# Set user and run command
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
+61
View File
@@ -0,0 +1,61 @@
# dockerfile.dev
# Basisimage: Gradle mit JDK 17 (Debian-basiert)
FROM gradle:8.14-jdk17
# Als Root-Benutzer arbeiten, um benötigte Pakete zu installieren
USER root
# Set GRADLE_HOME und füge Gradle zum PATH hinzu
ENV GRADLE_HOME=/opt/gradle
ENV PATH="$GRADLE_HOME/bin:$PATH"
# Update und Installation zusätzlicher Pakete (Debian/Ubuntu-basiert)
RUN apt-get update && apt-get install -y \
sudo \
libreoffice \
poppler-utils \
qpdf \
# settings.yml | tessdataDir: /usr/share/tesseract-ocr/5/tessdata
tesseract-ocr \
tesseract-ocr-eng \
fonts-terminus fonts-dejavu fonts-font-awesome fonts-noto fonts-noto-core fonts-noto-cjk fonts-noto-extra fonts-liberation fonts-linuxlibertine fonts-urw-base35 \
python3-uno \
python3-venv \
# ss -tln
iproute2 \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Setze die Environment Variable für setuptools
ENV SETUPTOOLS_USE_DISTUTILS=local \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# Installation der benötigten Python-Pakete
RUN python3 -m venv --system-site-packages /opt/venv \
&& . /opt/venv/bin/activate \
&& pip install --upgrade pip setuptools \
&& pip install --no-cache-dir WeasyPrint pdf2image pillow unoserver opencv-python-headless pre-commit
# Füge den venv-Pfad zur globalen PATH-Variable hinzu, damit die Tools verfügbar sind
ENV PATH="/opt/venv/bin:$PATH"
COPY . /workspace
RUN mkdir -p /tmp/stirling-pdf \
&& fc-cache -f -v \
&& adduser --disabled-password --gecos '' devuser \
&& chown -R devuser:devuser /home/devuser /workspace /tmp/stirling-pdf
RUN echo "devuser ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/devuser \
&& chmod 0440 /etc/sudoers.d/devuser
# Setze das Arbeitsverzeichnis (wird später per Bind-Mount überschrieben)
WORKDIR /workspace
RUN chmod +x /workspace/.devcontainer/git-init.sh
RUN sudo chmod +x /workspace/.devcontainer/init-setup.sh
# Wechsel zum NichtRoot Benutzer
USER devuser
@@ -1,4 +1,3 @@
# Backend fat Dockerfile - Java Spring Boot with all dependencies and build stage
# Build the application
FROM gradle:8.14-jdk21 AS build
@@ -6,9 +5,9 @@ COPY build.gradle .
COPY settings.gradle .
COPY gradlew .
COPY gradle gradle/
COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/.
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
@@ -23,13 +22,13 @@ RUN DISABLE_ADDITIONAL_FEATURES=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Main stage
FROM alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1
FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
# Copy necessary files
COPY scripts /scripts
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
# first /app directory is for the build stage, second is for the final image
COPY --from=build /app/app/core/build/libs/*.jar app.jar
COPY pipeline /pipeline
COPY 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
@@ -53,6 +52,7 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf
# JDK for app
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
@@ -76,17 +76,17 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
# pdftohtml
poppler-utils \
# OCR MY PDF (unpaper for descew and other advanced featues)
unpaper \
tesseract-ocr-data-eng \
tesseract-ocr-data-chi_sim \
tesseract-ocr-data-deu \
tesseract-ocr-data-fra \
tesseract-ocr-data-por \
ocrmypdf \
font-terminus font-dejavu font-noto font-noto-cjk font-awesome font-noto-extra font-liberation font-linux-libertine \
unpaper \
font-terminus font-dejavu font-noto font-noto-cjk font-awesome font-noto-extra font-liberation font-linux-libertine font-urw-base35 \
# CV
py3-opencv \
python3 \
ocrmypdf \
py3-pip \
py3-pillow@testing \
py3-pdf2image@testing && \
@@ -97,16 +97,18 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
mv /usr/share/tessdata /usr/share/tessdata-original && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf /pipeline/watchedFolders /pipeline/finishedFolders && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
# Configure URW Base 35 fonts
ln -s /usr/share/fontconfig/conf.avail/69-urw-*.conf /etc/fonts/conf.d/ && \
fc-cache -f -v && \
chmod +x /scripts/* && \
chmod +x /scripts/init.sh && \
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar
EXPOSE 8080/tcp
# Set user and run command
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
@@ -1,29 +1,5 @@
# Backend ultra-lite Dockerfile - Java Spring Boot with minimal dependencies and build stage
# Build the application
FROM gradle:8.14-jdk21 AS build
COPY build.gradle .
COPY settings.gradle .
COPY gradlew .
COPY gradle gradle/
COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/.
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
# Set the working directory
WORKDIR /app
# Copy the entire project to the working directory
COPY . .
# Build the application with DISABLE_ADDITIONAL_FEATURES=true
RUN DISABLE_ADDITIONAL_FEATURES=true \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Main stage
FROM alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1
# use alpine
FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
ARG VERSION_TAG
@@ -42,9 +18,11 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \
TMP=/tmp/stirling-pdf
# Copy necessary files
COPY scripts/download-security-jar.sh /scripts/download-security-jar.sh
COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
COPY scripts/installFonts.sh /scripts/installFonts.sh
COPY --from=build /app/app/core/build/libs/*.jar app.jar
COPY pipeline /pipeline
COPY 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 && \
@@ -61,10 +39,10 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /et
su-exec \
openjdk21-jre && \
# User permissions
mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto /tmp/stirling-pdf /pipeline/watchedFolders /pipeline/finishedFolders && \
mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto /tmp/stirling-pdf && \
chmod +x /scripts/*.sh && \
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar
# Set environment variables
@@ -74,4 +52,4 @@ EXPOSE 8080/tcp
# Run the application
ENTRYPOINT ["tini", "--", "/scripts/init-without-ocr.sh"]
CMD ["java", "-Dfile.encoding=UTF-8", "-Djava.io.tmpdir=/tmp/stirling-pdf", "-jar", "/app.jar"]
CMD ["java", "-Dfile.encoding=UTF-8", "-Djava.io.tmpdir=/tmp/stirling-pdf", "-jar", "/app.jar"]
+2 -2
View File
@@ -4,8 +4,8 @@ Copyright (c) 2025 Stirling PDF Inc.
Portions of this software are licensed as follows:
* All content that resides under the "app/proprietary/" directory of this repository,
if that directory exists, is licensed under the license defined in "app/proprietary/LICENSE".
* 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.
+37 -37
View File
@@ -116,47 +116,47 @@ Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![61%](https://geps.dev/progress/61) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![62%](https://geps.dev/progress/62) |
| Basque (Euskara) (eu_ES) | ![36%](https://geps.dev/progress/36) |
| Bulgarian (Български) (bg_BG) | ![68%](https://geps.dev/progress/68) |
| Catalan (Català) (ca_CA) | ![68%](https://geps.dev/progress/68) |
| Croatian (Hrvatski) (hr_HR) | ![60%](https://geps.dev/progress/60) |
| Czech (Česky) (cs_CZ) | ![70%](https://geps.dev/progress/70) |
| Danish (Dansk) (da_DK) | ![61%](https://geps.dev/progress/61) |
| Dutch (Nederlands) (nl_NL) | ![60%](https://geps.dev/progress/60) |
| Arabic (العربية) (ar_AR) | ![63%](https://geps.dev/progress/63) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![63%](https://geps.dev/progress/63) |
| Basque (Euskara) (eu_ES) | ![37%](https://geps.dev/progress/37) |
| Bulgarian (Български) (bg_BG) | ![70%](https://geps.dev/progress/70) |
| Catalan (Català) (ca_CA) | ![69%](https://geps.dev/progress/69) |
| Croatian (Hrvatski) (hr_HR) | ![62%](https://geps.dev/progress/62) |
| Czech (Česky) (cs_CZ) | ![71%](https://geps.dev/progress/71) |
| Danish (Dansk) (da_DK) | ![63%](https://geps.dev/progress/63) |
| Dutch (Nederlands) (nl_NL) | ![61%](https://geps.dev/progress/61) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![89%](https://geps.dev/progress/89) |
| German (Deutsch) (de_DE) | ![98%](https://geps.dev/progress/98) |
| Greek (Ελληνικά) (el_GR) | ![67%](https://geps.dev/progress/67) |
| Hindi (हिंदी) (hi_IN) | ![67%](https://geps.dev/progress/67) |
| French (Français) (fr_FR) | ![91%](https://geps.dev/progress/91) |
| German (Deutsch) (de_DE) | ![100%](https://geps.dev/progress/100) |
| Greek (Ελληνικά) (el_GR) | ![69%](https://geps.dev/progress/69) |
| Hindi (हिंदी) (hi_IN) | ![68%](https://geps.dev/progress/68) |
| Hungarian (Magyar) (hu_HU) | ![99%](https://geps.dev/progress/99) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![62%](https://geps.dev/progress/62) |
| Irish (Gaeilge) (ga_IE) | ![68%](https://geps.dev/progress/68) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![63%](https://geps.dev/progress/63) |
| Irish (Gaeilge) (ga_IE) | ![70%](https://geps.dev/progress/70) |
| Italian (Italiano) (it_IT) | ![98%](https://geps.dev/progress/98) |
| Japanese (日本語) (ja_JP) | ![93%](https://geps.dev/progress/93) |
| Korean (한국어) (ko_KR) | ![67%](https://geps.dev/progress/67) |
| Norwegian (Norsk) (no_NB) | ![66%](https://geps.dev/progress/66) |
| Persian (فارسی) (fa_IR) | ![64%](https://geps.dev/progress/64) |
| Polish (Polski) (pl_PL) | ![72%](https://geps.dev/progress/72) |
| Portuguese (Português) (pt_PT) | ![69%](https://geps.dev/progress/69) |
| Portuguese Brazilian (Português) (pt_BR) | ![76%](https://geps.dev/progress/76) |
| Romanian (Română) (ro_RO) | ![57%](https://geps.dev/progress/57) |
| Russian (Русский) (ru_RU) | ![88%](https://geps.dev/progress/88) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![95%](https://geps.dev/progress/95) |
| Simplified Chinese (简体中文) (zh_CN) | ![93%](https://geps.dev/progress/93) |
| Slovakian (Slovensky) (sk_SK) | ![51%](https://geps.dev/progress/51) |
| Slovenian (Slovenščina) (sl_SI) | ![71%](https://geps.dev/progress/71) |
| Spanish (Español) (es_ES) | ![74%](https://geps.dev/progress/74) |
| Swedish (Svenska) (sv_SE) | ![65%](https://geps.dev/progress/65) |
| Thai (ไทย) (th_TH) | ![59%](https://geps.dev/progress/59) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) |
| Traditional Chinese (繁體中文) (zh_TW) | ![97%](https://geps.dev/progress/97) |
| Turkish (Türkçe) (tr_TR) | ![80%](https://geps.dev/progress/80) |
| Ukrainian (Українська) (uk_UA) | ![71%](https://geps.dev/progress/71) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![57%](https://geps.dev/progress/57) |
| Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) |
| Japanese (日本語) (ja_JP) | ![70%](https://geps.dev/progress/70) |
| Korean (한국어) (ko_KR) | ![69%](https://geps.dev/progress/69) |
| Norwegian (Norsk) (no_NB) | ![67%](https://geps.dev/progress/67) |
| Persian (فارسی) (fa_IR) | ![66%](https://geps.dev/progress/66) |
| Polish (Polski) (pl_PL) | ![73%](https://geps.dev/progress/73) |
| Portuguese (Português) (pt_PT) | ![70%](https://geps.dev/progress/70) |
| Portuguese Brazilian (Português) (pt_BR) | ![77%](https://geps.dev/progress/77) |
| Romanian (Română) (ro_RO) | ![59%](https://geps.dev/progress/59) |
| Russian (Русский) (ru_RU) | ![70%](https://geps.dev/progress/70) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![97%](https://geps.dev/progress/97) |
| Simplified Chinese (简体中文) (zh_CN) | ![90%](https://geps.dev/progress/90) |
| Slovakian (Slovensky) (sk_SK) | ![53%](https://geps.dev/progress/53) |
| Slovenian (Slovenščina) (sl_SI) | ![73%](https://geps.dev/progress/73) |
| Spanish (Español) (es_ES) | ![75%](https://geps.dev/progress/75) |
| Swedish (Svenska) (sv_SE) | ![67%](https://geps.dev/progress/67) |
| Thai (ไทย) (th_TH) | ![60%](https://geps.dev/progress/60) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![66%](https://geps.dev/progress/66) |
| Traditional Chinese (繁體中文) (zh_TW) | ![77%](https://geps.dev/progress/77) |
| Turkish (Türkçe) (tr_TR) | ![82%](https://geps.dev/progress/82) |
| Ukrainian (Українська) (uk_UA) | ![72%](https://geps.dev/progress/72) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![58%](https://geps.dev/progress/58) |
| Malayalam (മലയാളം) (ml_IN) | ![75%](https://geps.dev/progress/75) |
## Stirling PDF Enterprise
@@ -1,101 +0,0 @@
package stirling.software.common.model.enumeration;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum Role {
// System-wide administrator - can manage all organizations
SYSTEM_ADMIN(
"ROLE_SYSTEM_ADMIN",
Integer.MAX_VALUE,
Integer.MAX_VALUE,
"adminUserSettings.systemAdmin"),
// Organization administrator - can manage their organization and all its teams
ORG_ADMIN("ROLE_ORG_ADMIN", Integer.MAX_VALUE, Integer.MAX_VALUE, "adminUserSettings.orgAdmin"),
// Team leader - can manage users in their specific team
TEAM_LEAD("ROLE_TEAM_LEAD", Integer.MAX_VALUE, Integer.MAX_VALUE, "adminUserSettings.teamLead"),
// Legacy admin role - equivalent to SYSTEM_ADMIN for backward compatibility
ADMIN("ROLE_ADMIN", Integer.MAX_VALUE, Integer.MAX_VALUE, "adminUserSettings.admin"),
// Regular user with unlimited access within their team/org
USER("ROLE_USER", Integer.MAX_VALUE, Integer.MAX_VALUE, "adminUserSettings.user"),
INTERNAL_API_USER(
"STIRLING-PDF-BACKEND-API-USER",
Integer.MAX_VALUE,
Integer.MAX_VALUE,
"adminUserSettings.internalApiUser"),
DEMO_USER("ROLE_DEMO_USER", 100, 100, "adminUserSettings.demoUser");
private final String roleId;
private final int apiCallsPerDay;
private final int webCallsPerDay;
private final String roleName;
public static String getRoleNameByRoleId(String roleId) {
// Using the fromString method to get the Role enum based on the roleId
Role role = fromString(roleId);
// Return the roleName of the found Role enum
return role.getRoleName();
}
// Method to retrieve all role IDs and role names
public static Map<String, String> getAllRoleDetails() {
// Using LinkedHashMap to preserve order
Map<String, String> roleDetails = new LinkedHashMap<>();
for (Role role : Role.values()) {
roleDetails.put(role.getRoleId(), role.getRoleName());
}
return roleDetails;
}
public static Role fromString(String roleId) {
for (Role role : Role.values()) {
if (role.getRoleId().equalsIgnoreCase(roleId)) {
return role;
}
}
throw new IllegalArgumentException("No Role defined for id: " + roleId);
}
/** Checks if this role can manage users across all organizations */
public boolean isSystemAdmin() {
return this == SYSTEM_ADMIN || this == ADMIN; // ADMIN for backward compatibility
}
/** Checks if this role can manage an organization and all its teams */
public boolean isOrgAdmin() {
return isSystemAdmin() || this == ORG_ADMIN;
}
/** Checks if this role can manage a specific team */
public boolean isTeamLead() {
return isOrgAdmin() || this == TEAM_LEAD;
}
/** Gets the hierarchy level of this role (higher number = more permissions) */
public int getHierarchyLevel() {
return switch (this) {
case SYSTEM_ADMIN, ADMIN -> 4;
case ORG_ADMIN -> 3;
case TEAM_LEAD -> 2;
case USER -> 1;
default -> 0; // Limited users
};
}
/** Checks if this role has higher or equal authority than another role */
public boolean hasAuthorityOver(Role otherRole) {
return this.getHierarchyLevel() >= otherRole.getHierarchyLevel();
}
}
@@ -1,208 +0,0 @@
package stirling.software.common.service;
import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
@Service
@RequiredArgsConstructor
@Slf4j
public class SsrfProtectionService {
private final ApplicationProperties applicationProperties;
private static final Pattern DATA_URL_PATTERN =
Pattern.compile("^data:.*", Pattern.CASE_INSENSITIVE);
private static final Pattern FRAGMENT_PATTERN = Pattern.compile("^#.*");
public enum SsrfProtectionLevel {
OFF, // No SSRF protection - allows all URLs
MEDIUM, // Block internal networks but allow external URLs
MAX // Block all external URLs - only data: and fragments
}
public boolean isUrlAllowed(String url) {
ApplicationProperties.Html.UrlSecurity config =
applicationProperties.getSystem().getHtml().getUrlSecurity();
if (!config.isEnabled()) {
return true;
}
if (url == null || url.trim().isEmpty()) {
return false;
}
String trimmedUrl = url.trim();
// Always allow data URLs and fragments
if (DATA_URL_PATTERN.matcher(trimmedUrl).matches()
|| FRAGMENT_PATTERN.matcher(trimmedUrl).matches()) {
return true;
}
SsrfProtectionLevel level = parseProtectionLevel(config.getLevel());
switch (level) {
case OFF:
return true;
case MAX:
return isMaxSecurityAllowed(trimmedUrl, config);
case MEDIUM:
return isMediumSecurityAllowed(trimmedUrl, config);
default:
return false;
}
}
private SsrfProtectionLevel parseProtectionLevel(String level) {
try {
return SsrfProtectionLevel.valueOf(level.toUpperCase());
} catch (IllegalArgumentException e) {
log.warn("Invalid SSRF protection level '{}', defaulting to MEDIUM", level);
return SsrfProtectionLevel.MEDIUM;
}
}
private boolean isMaxSecurityAllowed(
String url, ApplicationProperties.Html.UrlSecurity config) {
// MAX security: only allow explicitly whitelisted domains
try {
URI uri = new URI(url);
String host = uri.getHost();
if (host == null) {
return false;
}
return config.getAllowedDomains().contains(host.toLowerCase());
} catch (Exception e) {
log.debug("Failed to parse URL for MAX security check: {}", url, e);
return false;
}
}
private boolean isMediumSecurityAllowed(
String url, ApplicationProperties.Html.UrlSecurity config) {
try {
URI uri = new URI(url);
String host = uri.getHost();
if (host == null) {
return false;
}
String hostLower = host.toLowerCase();
// Check explicit blocked domains
if (config.getBlockedDomains().contains(hostLower)) {
log.debug("URL blocked by explicit domain blocklist: {}", url);
return false;
}
// Check internal TLD patterns
for (String tld : config.getInternalTlds()) {
if (hostLower.endsWith(tld.toLowerCase())) {
log.debug("URL blocked by internal TLD pattern '{}': {}", tld, url);
return false;
}
}
// If allowedDomains is specified, only allow those
if (!config.getAllowedDomains().isEmpty()) {
boolean isAllowed =
config.getAllowedDomains().stream()
.anyMatch(
domain ->
hostLower.equals(domain.toLowerCase())
|| hostLower.endsWith(
"." + domain.toLowerCase()));
if (!isAllowed) {
log.debug("URL not in allowed domains list: {}", url);
return false;
}
}
// Resolve hostname to IP address for network-based checks
try {
InetAddress address = InetAddress.getByName(host);
if (config.isBlockPrivateNetworks() && isPrivateAddress(address)) {
log.debug("URL blocked - private network address: {}", url);
return false;
}
if (config.isBlockLocalhost() && address.isLoopbackAddress()) {
log.debug("URL blocked - localhost address: {}", url);
return false;
}
if (config.isBlockLinkLocal() && address.isLinkLocalAddress()) {
log.debug("URL blocked - link-local address: {}", url);
return false;
}
if (config.isBlockCloudMetadata()
&& isCloudMetadataAddress(address.getHostAddress())) {
log.debug("URL blocked - cloud metadata endpoint: {}", url);
return false;
}
} catch (UnknownHostException e) {
log.debug("Failed to resolve hostname for SSRF check: {}", host, e);
return false;
}
return true;
} catch (Exception e) {
log.debug("Failed to parse URL for MEDIUM security check: {}", url, e);
return false;
}
}
private boolean isPrivateAddress(InetAddress address) {
return address.isSiteLocalAddress()
|| address.isAnyLocalAddress()
|| isPrivateIPv4Range(address.getHostAddress());
}
private boolean isPrivateIPv4Range(String ip) {
return ip.startsWith("10.")
|| ip.startsWith("192.168.")
|| (ip.startsWith("172.") && isInRange172(ip))
|| ip.startsWith("127.")
|| "0.0.0.0".equals(ip);
}
private boolean isInRange172(String ip) {
String[] parts = ip.split("\\.");
if (parts.length >= 2) {
try {
int secondOctet = Integer.parseInt(parts[1]);
return secondOctet >= 16 && secondOctet <= 31;
} catch (NumberFormatException e) {
return false;
}
}
return false;
}
private boolean isCloudMetadataAddress(String ip) {
// Cloud metadata endpoints for AWS, GCP, Azure, Oracle Cloud, and IBM Cloud
return ip.startsWith("169.254.169.254") // AWS/GCP/Azure
|| ip.startsWith("fd00:ec2::254") // AWS IPv6
|| ip.startsWith("169.254.169.253") // Oracle Cloud
|| ip.startsWith("169.254.169.250"); // IBM Cloud
}
}
@@ -1,71 +0,0 @@
package stirling.software.common.util;
import org.owasp.html.AttributePolicy;
import org.owasp.html.HtmlPolicyBuilder;
import org.owasp.html.PolicyFactory;
import org.owasp.html.Sanitizers;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.SsrfProtectionService;
@Component
public class CustomHtmlSanitizer {
private final SsrfProtectionService ssrfProtectionService;
private final ApplicationProperties applicationProperties;
@Autowired
public CustomHtmlSanitizer(
SsrfProtectionService ssrfProtectionService,
ApplicationProperties applicationProperties) {
this.ssrfProtectionService = ssrfProtectionService;
this.applicationProperties = applicationProperties;
}
private final AttributePolicy SSRF_SAFE_URL_POLICY =
new AttributePolicy() {
@Override
public String apply(String elementName, String attributeName, String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
String trimmedValue = value.trim();
// Use the SSRF protection service to validate the URL
if (ssrfProtectionService != null
&& !ssrfProtectionService.isUrlAllowed(trimmedValue)) {
return null;
}
return trimmedValue;
}
};
private final PolicyFactory SSRF_SAFE_IMAGES_POLICY =
new HtmlPolicyBuilder()
.allowElements("img")
.allowAttributes("alt", "width", "height", "title")
.onElements("img")
.allowAttributes("src")
.matching(SSRF_SAFE_URL_POLICY)
.onElements("img")
.toFactory();
private final PolicyFactory POLICY =
Sanitizers.FORMATTING
.and(Sanitizers.BLOCKS)
.and(Sanitizers.STYLES)
.and(Sanitizers.LINKS)
.and(Sanitizers.TABLES)
.and(SSRF_SAFE_IMAGES_POLICY)
.and(new HtmlPolicyBuilder().disallowElements("noscript").toFactory());
public String sanitize(String html) {
boolean disableSanitize =
Boolean.TRUE.equals(applicationProperties.getSystem().getDisableSanitize());
return disableSanitize ? html : POLICY.sanitize(html);
}
}
@@ -1,652 +0,0 @@
package stirling.software.common.util;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Properties;
import java.util.regex.Pattern;
import lombok.Data;
import lombok.experimental.UtilityClass;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
@UtilityClass
public class EmlParser {
private static volatile Boolean jakartaMailAvailable = null;
private static volatile Method mimeUtilityDecodeTextMethod = null;
private static volatile boolean mimeUtilityChecked = false;
private static final Pattern MIME_ENCODED_PATTERN =
Pattern.compile("=\\?([^?]+)\\?([BbQq])\\?([^?]*)\\?=");
private static final String DISPOSITION_ATTACHMENT = "attachment";
private static final String TEXT_PLAIN = "text/plain";
private static final String TEXT_HTML = "text/html";
private static final String MULTIPART_PREFIX = "multipart/";
private static final String HEADER_CONTENT_TYPE = "content-type:";
private static final String HEADER_CONTENT_DISPOSITION = "content-disposition:";
private static final String HEADER_CONTENT_TRANSFER_ENCODING = "content-transfer-encoding:";
private static final String HEADER_CONTENT_ID = "Content-ID";
private static final String HEADER_SUBJECT = "Subject:";
private static final String HEADER_FROM = "From:";
private static final String HEADER_TO = "To:";
private static final String HEADER_CC = "Cc:";
private static final String HEADER_BCC = "Bcc:";
private static final String HEADER_DATE = "Date:";
private static synchronized boolean isJakartaMailAvailable() {
if (jakartaMailAvailable == null) {
try {
Class.forName("jakarta.mail.internet.MimeMessage");
Class.forName("jakarta.mail.Session");
Class.forName("jakarta.mail.internet.MimeUtility");
Class.forName("jakarta.mail.internet.MimePart");
Class.forName("jakarta.mail.internet.MimeMultipart");
Class.forName("jakarta.mail.Multipart");
Class.forName("jakarta.mail.Part");
jakartaMailAvailable = true;
} catch (ClassNotFoundException e) {
jakartaMailAvailable = false;
}
}
return jakartaMailAvailable;
}
public static EmailContent extractEmailContent(
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer)
throws IOException {
EmlProcessingUtils.validateEmlInput(emlBytes);
if (isJakartaMailAvailable()) {
return extractEmailContentAdvanced(emlBytes, request, customHtmlSanitizer);
} else {
return extractEmailContentBasic(emlBytes, request, customHtmlSanitizer);
}
}
private static EmailContent extractEmailContentBasic(
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) {
String emlContent = new String(emlBytes, StandardCharsets.UTF_8);
EmailContent content = new EmailContent();
content.setSubject(extractBasicHeader(emlContent, HEADER_SUBJECT));
content.setFrom(extractBasicHeader(emlContent, HEADER_FROM));
content.setTo(extractBasicHeader(emlContent, HEADER_TO));
content.setCc(extractBasicHeader(emlContent, HEADER_CC));
content.setBcc(extractBasicHeader(emlContent, HEADER_BCC));
String dateStr = extractBasicHeader(emlContent, HEADER_DATE);
if (!dateStr.isEmpty()) {
content.setDateString(dateStr);
}
String htmlBody = extractHtmlBody(emlContent);
if (htmlBody != null) {
content.setHtmlBody(htmlBody);
} else {
String textBody = extractTextBody(emlContent);
content.setTextBody(textBody != null ? textBody : "Email content could not be parsed");
}
content.getAttachments().addAll(extractAttachmentsBasic(emlContent));
return content;
}
private static EmailContent extractEmailContentAdvanced(
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) {
try {
Class<?> sessionClass = Class.forName("jakarta.mail.Session");
Class<?> mimeMessageClass = Class.forName("jakarta.mail.internet.MimeMessage");
Method getDefaultInstance =
sessionClass.getMethod("getDefaultInstance", Properties.class);
Object session = getDefaultInstance.invoke(null, new Properties());
Class<?>[] constructorArgs = new Class<?>[] {sessionClass, InputStream.class};
Constructor<?> mimeMessageConstructor =
mimeMessageClass.getConstructor(constructorArgs);
Object message =
mimeMessageConstructor.newInstance(session, new ByteArrayInputStream(emlBytes));
return extractFromMimeMessage(message, request, customHtmlSanitizer);
} catch (ReflectiveOperationException e) {
return extractEmailContentBasic(emlBytes, request, customHtmlSanitizer);
}
}
private static EmailContent extractFromMimeMessage(
Object message, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) {
EmailContent content = new EmailContent();
try {
Class<?> messageClass = message.getClass();
Method getSubject = messageClass.getMethod("getSubject");
String subject = (String) getSubject.invoke(message);
content.setSubject(subject != null ? safeMimeDecode(subject) : "No Subject");
Method getFrom = messageClass.getMethod("getFrom");
Object[] fromAddresses = (Object[]) getFrom.invoke(message);
content.setFrom(buildAddressString(fromAddresses));
extractRecipients(message, messageClass, content);
Method getSentDate = messageClass.getMethod("getSentDate");
content.setDate((Date) getSentDate.invoke(message));
Method getContent = messageClass.getMethod("getContent");
Object messageContent = getContent.invoke(message);
processMessageContent(message, messageContent, content, request, customHtmlSanitizer);
} catch (ReflectiveOperationException | RuntimeException e) {
content.setSubject("Email Conversion");
content.setFrom("Unknown");
content.setTo("Unknown");
content.setCc("");
content.setBcc("");
content.setTextBody("Email content could not be parsed with advanced processing");
}
return content;
}
private static void extractRecipients(
Object message, Class<?> messageClass, EmailContent content) {
try {
Method getRecipients =
messageClass.getMethod(
"getRecipients", Class.forName("jakarta.mail.Message$RecipientType"));
Class<?> recipientTypeClass = Class.forName("jakarta.mail.Message$RecipientType");
Object toType = recipientTypeClass.getField("TO").get(null);
Object[] toRecipients = (Object[]) getRecipients.invoke(message, toType);
content.setTo(buildAddressString(toRecipients));
Object ccType = recipientTypeClass.getField("CC").get(null);
Object[] ccRecipients = (Object[]) getRecipients.invoke(message, ccType);
content.setCc(buildAddressString(ccRecipients));
Object bccType = recipientTypeClass.getField("BCC").get(null);
Object[] bccRecipients = (Object[]) getRecipients.invoke(message, bccType);
content.setBcc(buildAddressString(bccRecipients));
} catch (ReflectiveOperationException e) {
try {
Method getAllRecipients = messageClass.getMethod("getAllRecipients");
Object[] recipients = (Object[]) getAllRecipients.invoke(message);
content.setTo(buildAddressString(recipients));
content.setCc("");
content.setBcc("");
} catch (ReflectiveOperationException ex) {
content.setTo("");
content.setCc("");
content.setBcc("");
}
}
}
private static String buildAddressString(Object[] addresses) {
if (addresses == null || addresses.length == 0) {
return "";
}
StringBuilder builder = new StringBuilder();
for (int i = 0; i < addresses.length; i++) {
if (i > 0) builder.append(", ");
builder.append(safeMimeDecode(addresses[i].toString()));
}
return builder.toString();
}
private static void processMessageContent(
Object message,
Object messageContent,
EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer) {
try {
if (messageContent instanceof String stringContent) {
Method getContentType = message.getClass().getMethod("getContentType");
String contentType = (String) getContentType.invoke(message);
if (contentType != null && contentType.toLowerCase().contains(TEXT_HTML)) {
content.setHtmlBody(stringContent);
} else {
content.setTextBody(stringContent);
}
} else {
Class<?> multipartClass = Class.forName("jakarta.mail.Multipart");
if (multipartClass.isInstance(messageContent)) {
processMultipart(messageContent, content, request, customHtmlSanitizer, 0);
}
}
} catch (ReflectiveOperationException | ClassCastException e) {
content.setTextBody("Email content could not be parsed with advanced processing");
}
}
private static void processMultipart(
Object multipart,
EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer,
int depth) {
final int MAX_MULTIPART_DEPTH = 10;
if (depth > MAX_MULTIPART_DEPTH) {
content.setHtmlBody("<div class=\"error\">Maximum multipart depth exceeded</div>");
return;
}
try {
Class<?> multipartClass = multipart.getClass();
Method getCount = multipartClass.getMethod("getCount");
int count = (Integer) getCount.invoke(multipart);
Method getBodyPart = multipartClass.getMethod("getBodyPart", int.class);
for (int i = 0; i < count; i++) {
Object part = getBodyPart.invoke(multipart, i);
processPart(part, content, request, customHtmlSanitizer, depth + 1);
}
} catch (ReflectiveOperationException | ClassCastException e) {
content.setHtmlBody("<div class=\"error\">Error processing multipart content</div>");
}
}
private static void processPart(
Object part,
EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer,
int depth) {
try {
Class<?> partClass = part.getClass();
Method isMimeType = partClass.getMethod("isMimeType", String.class);
Method getContent = partClass.getMethod("getContent");
Method getDisposition = partClass.getMethod("getDisposition");
Method getFileName = partClass.getMethod("getFileName");
Method getContentType = partClass.getMethod("getContentType");
Method getHeader = partClass.getMethod("getHeader", String.class);
Object disposition = getDisposition.invoke(part);
String filename = (String) getFileName.invoke(part);
String contentType = (String) getContentType.invoke(part);
String normalizedDisposition =
disposition != null ? ((String) disposition).toLowerCase() : null;
if ((Boolean) isMimeType.invoke(part, TEXT_PLAIN) && normalizedDisposition == null) {
Object partContent = getContent.invoke(part);
if (partContent instanceof String stringContent) {
content.setTextBody(stringContent);
}
} else if ((Boolean) isMimeType.invoke(part, TEXT_HTML)
&& normalizedDisposition == null) {
Object partContent = getContent.invoke(part);
if (partContent instanceof String stringContent) {
String htmlBody =
customHtmlSanitizer != null
? customHtmlSanitizer.sanitize(stringContent)
: stringContent;
content.setHtmlBody(htmlBody);
}
} else if ((normalizedDisposition != null
&& normalizedDisposition.contains(DISPOSITION_ATTACHMENT))
|| (filename != null && !filename.trim().isEmpty())) {
processAttachment(
part, content, request, getHeader, getContent, filename, contentType);
} else if ((Boolean) isMimeType.invoke(part, "multipart/*")) {
Object multipartContent = getContent.invoke(part);
if (multipartContent != null) {
Class<?> multipartClass = Class.forName("jakarta.mail.Multipart");
if (multipartClass.isInstance(multipartContent)) {
processMultipart(
multipartContent, content, request, customHtmlSanitizer, depth + 1);
}
}
}
} catch (ReflectiveOperationException | RuntimeException e) {
// Continue processing other parts if one fails
}
}
private static void processAttachment(
Object part,
EmailContent content,
EmlToPdfRequest request,
Method getHeader,
Method getContent,
String filename,
String contentType) {
content.setAttachmentCount(content.getAttachmentCount() + 1);
if (filename != null && !filename.trim().isEmpty()) {
EmailAttachment attachment = new EmailAttachment();
attachment.setFilename(safeMimeDecode(filename));
attachment.setContentType(contentType);
try {
String[] contentIdHeaders = (String[]) getHeader.invoke(part, HEADER_CONTENT_ID);
if (contentIdHeaders != null) {
for (String contentIdHeader : contentIdHeaders) {
if (contentIdHeader != null && !contentIdHeader.trim().isEmpty()) {
attachment.setEmbedded(true);
String contentId = contentIdHeader.trim().replaceAll("[<>]", "");
attachment.setContentId(contentId);
break;
}
}
}
} catch (ReflectiveOperationException e) {
}
if ((request != null && request.isIncludeAttachments()) || attachment.isEmbedded()) {
extractAttachmentData(part, attachment, getContent, request);
}
content.getAttachments().add(attachment);
}
}
private static void extractAttachmentData(
Object part, EmailAttachment attachment, Method getContent, EmlToPdfRequest request) {
try {
Object attachmentContent = getContent.invoke(part);
byte[] attachmentData = null;
if (attachmentContent instanceof InputStream inputStream) {
try (InputStream stream = inputStream) {
attachmentData = stream.readAllBytes();
} catch (IOException e) {
if (attachment.isEmbedded()) {
attachmentData = new byte[0];
} else {
throw new RuntimeException(e);
}
}
} else if (attachmentContent instanceof byte[] byteArray) {
attachmentData = byteArray;
} else if (attachmentContent instanceof String stringContent) {
attachmentData = stringContent.getBytes(StandardCharsets.UTF_8);
}
if (attachmentData != null) {
long maxSizeMB = request != null ? request.getMaxAttachmentSizeMB() : 10L;
long maxSizeBytes = maxSizeMB * 1024 * 1024;
if (attachmentData.length <= maxSizeBytes || attachment.isEmbedded()) {
attachment.setData(attachmentData);
attachment.setSizeBytes(attachmentData.length);
} else {
attachment.setSizeBytes(attachmentData.length);
}
}
} catch (ReflectiveOperationException | RuntimeException e) {
// Continue without attachment data
}
}
private static String extractBasicHeader(String emlContent, String headerName) {
try {
String[] lines = emlContent.split("\r?\n");
for (int i = 0; i < lines.length; i++) {
String line = lines[i];
if (line.toLowerCase().startsWith(headerName.toLowerCase())) {
StringBuilder value =
new StringBuilder(line.substring(headerName.length()).trim());
for (int j = i + 1; j < lines.length; j++) {
if (lines[j].startsWith(" ") || lines[j].startsWith("\t")) {
value.append(" ").append(lines[j].trim());
} else {
break;
}
}
return safeMimeDecode(value.toString());
}
if (line.trim().isEmpty()) break;
}
} catch (RuntimeException e) {
// Ignore errors in header extraction
}
return "";
}
private static String extractHtmlBody(String emlContent) {
try {
String lowerContent = emlContent.toLowerCase();
int htmlStart = lowerContent.indexOf(HEADER_CONTENT_TYPE + " " + TEXT_HTML);
if (htmlStart == -1) return null;
int bodyStart = emlContent.indexOf("\r\n\r\n", htmlStart);
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n", htmlStart);
if (bodyStart == -1) return null;
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
int bodyEnd = findPartEnd(emlContent, bodyStart);
return emlContent.substring(bodyStart, bodyEnd).trim();
} catch (Exception e) {
return null;
}
}
private static String extractTextBody(String emlContent) {
try {
String lowerContent = emlContent.toLowerCase();
int textStart = lowerContent.indexOf(HEADER_CONTENT_TYPE + " " + TEXT_PLAIN);
if (textStart == -1) {
int bodyStart = emlContent.indexOf("\r\n\r\n");
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n");
if (bodyStart != -1) {
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
int bodyEnd = findPartEnd(emlContent, bodyStart);
return emlContent.substring(bodyStart, bodyEnd).trim();
}
return null;
}
int bodyStart = emlContent.indexOf("\r\n\r\n", textStart);
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n", textStart);
if (bodyStart == -1) return null;
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
int bodyEnd = findPartEnd(emlContent, bodyStart);
return emlContent.substring(bodyStart, bodyEnd).trim();
} catch (RuntimeException e) {
return null;
}
}
private static int findPartEnd(String content, int start) {
String[] lines = content.substring(start).split("\r?\n");
StringBuilder result = new StringBuilder();
for (String line : lines) {
if (line.startsWith("--") && line.length() > 10) break;
result.append(line).append("\n");
}
return start + result.length();
}
private static List<EmailAttachment> extractAttachmentsBasic(String emlContent) {
List<EmailAttachment> attachments = new ArrayList<>();
try {
String[] lines = emlContent.split("\r?\n");
boolean inHeaders = true;
String currentContentType = "";
String currentDisposition = "";
String currentFilename = "";
String currentEncoding = "";
for (String line : lines) {
String lowerLine = line.toLowerCase().trim();
if (line.trim().isEmpty()) {
inHeaders = false;
if (isAttachment(currentDisposition, currentFilename, currentContentType)) {
EmailAttachment attachment = new EmailAttachment();
attachment.setFilename(currentFilename);
attachment.setContentType(currentContentType);
attachment.setTransferEncoding(currentEncoding);
attachments.add(attachment);
}
currentContentType = "";
currentDisposition = "";
currentFilename = "";
currentEncoding = "";
inHeaders = true;
continue;
}
if (!inHeaders) continue;
if (lowerLine.startsWith(HEADER_CONTENT_TYPE)) {
currentContentType = line.substring(HEADER_CONTENT_TYPE.length()).trim();
} else if (lowerLine.startsWith(HEADER_CONTENT_DISPOSITION)) {
currentDisposition = line.substring(HEADER_CONTENT_DISPOSITION.length()).trim();
currentFilename = extractFilenameFromDisposition(currentDisposition);
} else if (lowerLine.startsWith(HEADER_CONTENT_TRANSFER_ENCODING)) {
currentEncoding =
line.substring(HEADER_CONTENT_TRANSFER_ENCODING.length()).trim();
}
}
} catch (RuntimeException e) {
// Continue with empty list
}
return attachments;
}
private static boolean isAttachment(String disposition, String filename, String contentType) {
return (disposition.toLowerCase().contains(DISPOSITION_ATTACHMENT) && !filename.isEmpty())
|| (!filename.isEmpty() && !contentType.toLowerCase().startsWith("text/"))
|| (contentType.toLowerCase().contains("application/") && !filename.isEmpty());
}
private static String extractFilenameFromDisposition(String disposition) {
if (disposition == null || !disposition.contains("filename=")) {
return "";
}
// Handle filename*= (RFC 2231 encoded filename)
if (disposition.toLowerCase().contains("filename*=")) {
int filenameStarStart = disposition.toLowerCase().indexOf("filename*=") + 10;
int filenameStarEnd = disposition.indexOf(";", filenameStarStart);
if (filenameStarEnd == -1) filenameStarEnd = disposition.length();
String extendedFilename =
disposition.substring(filenameStarStart, filenameStarEnd).trim();
extendedFilename = extendedFilename.replaceAll("^\"|\"$", "");
if (extendedFilename.contains("'")) {
String[] parts = extendedFilename.split("'", 3);
if (parts.length == 3) {
return EmlProcessingUtils.decodeUrlEncoded(parts[2]);
}
}
}
// Handle regular filename=
int filenameStart = disposition.toLowerCase().indexOf("filename=") + 9;
int filenameEnd = disposition.indexOf(";", filenameStart);
if (filenameEnd == -1) filenameEnd = disposition.length();
String filename = disposition.substring(filenameStart, filenameEnd).trim();
filename = filename.replaceAll("^\"|\"$", "");
return safeMimeDecode(filename);
}
public static String safeMimeDecode(String headerValue) {
if (headerValue == null || headerValue.trim().isEmpty()) {
return "";
}
if (!mimeUtilityChecked) {
synchronized (EmlParser.class) {
if (!mimeUtilityChecked) {
initializeMimeUtilityDecoding();
}
}
}
if (mimeUtilityDecodeTextMethod != null) {
try {
return (String) mimeUtilityDecodeTextMethod.invoke(null, headerValue.trim());
} catch (ReflectiveOperationException | RuntimeException e) {
// Fall through to custom implementation
}
}
return EmlProcessingUtils.decodeMimeHeader(headerValue.trim());
}
private static void initializeMimeUtilityDecoding() {
try {
Class<?> mimeUtilityClass = Class.forName("jakarta.mail.internet.MimeUtility");
mimeUtilityDecodeTextMethod = mimeUtilityClass.getMethod("decodeText", String.class);
} catch (ClassNotFoundException | NoSuchMethodException e) {
mimeUtilityDecodeTextMethod = null;
}
mimeUtilityChecked = true;
}
@Data
public static class EmailContent {
private String subject;
private String from;
private String to;
private String cc;
private String bcc;
private Date date;
private String dateString; // For basic parsing fallback
private String htmlBody;
private String textBody;
private int attachmentCount;
private List<EmailAttachment> attachments = new ArrayList<>();
public void setHtmlBody(String htmlBody) {
this.htmlBody = htmlBody != null ? htmlBody.replaceAll("\r", "") : null;
}
public void setTextBody(String textBody) {
this.textBody = textBody != null ? textBody.replaceAll("\r", "") : null;
}
}
@Data
public static class EmailAttachment {
private String filename;
private String contentType;
private byte[] data;
private boolean embedded;
private String embeddedFilename;
private long sizeBytes;
private String contentId;
private String disposition;
private String transferEncoding;
public void setData(byte[] data) {
this.data = data;
if (data != null) {
this.sizeBytes = data.length;
}
}
}
}
@@ -1,601 +0,0 @@
package stirling.software.common.util;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.experimental.UtilityClass;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
@UtilityClass
public class EmlProcessingUtils {
// Style constants
private static final int DEFAULT_FONT_SIZE = 12;
private static final String DEFAULT_FONT_FAMILY = "Helvetica, sans-serif";
private static final float DEFAULT_LINE_HEIGHT = 1.4f;
private static final String DEFAULT_ZOOM = "1.0";
private static final String DEFAULT_TEXT_COLOR = "#202124";
private static final String DEFAULT_BACKGROUND_COLOR = "#ffffff";
private static final String DEFAULT_BORDER_COLOR = "#e8eaed";
private static final String ATTACHMENT_BACKGROUND_COLOR = "#f9f9f9";
private static final String ATTACHMENT_BORDER_COLOR = "#eeeeee";
private static final int EML_CHECK_LENGTH = 8192;
private static final int MIN_HEADER_COUNT_FOR_VALID_EML = 2;
// MIME type detection
private static final Map<String, String> EXTENSION_TO_MIME_TYPE =
Map.of(
".png", "image/png",
".jpg", "image/jpeg",
".jpeg", "image/jpeg",
".gif", "image/gif",
".bmp", "image/bmp",
".webp", "image/webp",
".svg", "image/svg+xml",
".ico", "image/x-icon",
".tiff", "image/tiff",
".tif", "image/tiff");
public static void validateEmlInput(byte[] emlBytes) {
if (emlBytes == null || emlBytes.length == 0) {
throw new IllegalArgumentException("EML file is empty or null");
}
if (isInvalidEmlFormat(emlBytes)) {
throw new IllegalArgumentException("Invalid EML file format");
}
}
private static boolean isInvalidEmlFormat(byte[] emlBytes) {
try {
int checkLength = Math.min(emlBytes.length, EML_CHECK_LENGTH);
String content;
try {
content = new String(emlBytes, 0, checkLength, StandardCharsets.UTF_8);
if (content.contains("\uFFFD")) {
content = new String(emlBytes, 0, checkLength, StandardCharsets.ISO_8859_1);
}
} catch (Exception e) {
content = new String(emlBytes, 0, checkLength, StandardCharsets.ISO_8859_1);
}
String lowerContent = content.toLowerCase(Locale.ROOT);
boolean hasFrom =
lowerContent.contains("from:") || lowerContent.contains("return-path:");
boolean hasSubject = lowerContent.contains("subject:");
boolean hasMessageId = lowerContent.contains("message-id:");
boolean hasDate = lowerContent.contains("date:");
boolean hasTo =
lowerContent.contains("to:")
|| lowerContent.contains("cc:")
|| lowerContent.contains("bcc:");
boolean hasMimeStructure =
lowerContent.contains("multipart/")
|| lowerContent.contains("text/plain")
|| lowerContent.contains("text/html")
|| lowerContent.contains("boundary=");
int headerCount = 0;
if (hasFrom) headerCount++;
if (hasSubject) headerCount++;
if (hasMessageId) headerCount++;
if (hasDate) headerCount++;
if (hasTo) headerCount++;
return headerCount < MIN_HEADER_COUNT_FOR_VALID_EML && !hasMimeStructure;
} catch (RuntimeException e) {
return false;
}
}
public static String generateEnhancedEmailHtml(
EmlParser.EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer) {
StringBuilder html = new StringBuilder();
html.append(
String.format(
"""
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<title>%s</title>
<style>
""",
sanitizeText(content.getSubject(), customHtmlSanitizer)));
appendEnhancedStyles(html);
html.append(
"""
</style>
</head><body>
""");
html.append(
String.format(
"""
<div class="email-container">
<div class="email-header">
<h1>%s</h1>
<div class="email-meta">
<div><strong>From:</strong> %s</div>
<div><strong>To:</strong> %s</div>
""",
sanitizeText(content.getSubject(), customHtmlSanitizer),
sanitizeText(content.getFrom(), customHtmlSanitizer),
sanitizeText(content.getTo(), customHtmlSanitizer)));
if (content.getCc() != null && !content.getCc().trim().isEmpty()) {
html.append(
String.format(
"<div><strong>CC:</strong> %s</div>\n",
sanitizeText(content.getCc(), customHtmlSanitizer)));
}
if (content.getBcc() != null && !content.getBcc().trim().isEmpty()) {
html.append(
String.format(
"<div><strong>BCC:</strong> %s</div>\n",
sanitizeText(content.getBcc(), customHtmlSanitizer)));
}
if (content.getDate() != null) {
html.append(
String.format(
"<div><strong>Date:</strong> %s</div>\n",
PdfAttachmentHandler.formatEmailDate(content.getDate())));
} else if (content.getDateString() != null && !content.getDateString().trim().isEmpty()) {
html.append(
String.format(
"<div><strong>Date:</strong> %s</div>\n",
sanitizeText(content.getDateString(), customHtmlSanitizer)));
}
html.append("</div></div>\n");
html.append("<div class=\"email-body\">\n");
if (content.getHtmlBody() != null && !content.getHtmlBody().trim().isEmpty()) {
String processedHtml =
processEmailHtmlBody(content.getHtmlBody(), content, customHtmlSanitizer);
html.append(processedHtml);
} else if (content.getTextBody() != null && !content.getTextBody().trim().isEmpty()) {
html.append(
String.format(
"<div class=\"text-body\">%s</div>",
convertTextToHtml(content.getTextBody(), customHtmlSanitizer)));
} else {
html.append("<div class=\"no-content\"><p><em>No content available</em></p></div>");
}
html.append("</div>\n");
if (content.getAttachmentCount() > 0 || !content.getAttachments().isEmpty()) {
appendAttachmentsSection(html, content, request, customHtmlSanitizer);
}
html.append("</div>\n</body></html>");
return html.toString();
}
public static String processEmailHtmlBody(
String htmlBody,
EmlParser.EmailContent emailContent,
CustomHtmlSanitizer customHtmlSanitizer) {
if (htmlBody == null) return "";
String processed =
customHtmlSanitizer != null ? customHtmlSanitizer.sanitize(htmlBody) : htmlBody;
processed = processed.replaceAll("(?i)\\s*position\\s*:\\s*fixed[^;]*;?", "");
processed = processed.replaceAll("(?i)\\s*position\\s*:\\s*absolute[^;]*;?", "");
if (emailContent != null && !emailContent.getAttachments().isEmpty()) {
processed = PdfAttachmentHandler.processInlineImages(processed, emailContent);
}
return processed;
}
public static String convertTextToHtml(
String textBody, CustomHtmlSanitizer customHtmlSanitizer) {
if (textBody == null) return "";
String html =
customHtmlSanitizer != null
? customHtmlSanitizer.sanitize(textBody)
: escapeHtml(textBody);
html = html.replace("\r\n", "\n").replace("\r", "\n");
html = html.replace("\n", "<br>\n");
html =
html.replaceAll(
"(https?://[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+)",
"<a href=\"$1\" style=\"color: #1a73e8; text-decoration: underline;\">$1</a>");
html =
html.replaceAll(
"([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,63})",
"<a href=\"mailto:$1\" style=\"color: #1a73e8; text-decoration: underline;\">$1</a>");
return html;
}
private static void appendEnhancedStyles(StringBuilder html) {
String css =
String.format(
"""
body {
font-family: %s;
font-size: %dpx;
line-height: %s;
color: %s;
margin: 0;
padding: 16px;
background-color: %s;
}
.email-container {
width: 100%%;
max-width: 100%%;
margin: 0 auto;
}
.email-header {
padding-bottom: 10px;
border-bottom: 1px solid %s;
margin-bottom: 10px;
}
.email-header h1 {
margin: 0 0 10px 0;
font-size: %dpx;
font-weight: bold;
}
.email-meta div {
margin-bottom: 2px;
font-size: %dpx;
}
.email-body {
word-wrap: break-word;
}
.attachment-section {
margin-top: 15px;
padding: 10px;
background-color: %s;
border: 1px solid %s;
border-radius: 3px;
}
.attachment-section h3 {
margin: 0 0 8px 0;
font-size: %dpx;
}
.attachment-item {
padding: 5px 0;
}
.attachment-icon {
margin-right: 5px;
}
.attachment-details, .attachment-type {
font-size: %dpx;
color: #555555;
}
.attachment-inclusion-note, .attachment-info-note {
margin-top: 8px;
padding: 6px;
font-size: %dpx;
border-radius: 3px;
}
.attachment-inclusion-note {
background-color: #e6ffed;
border: 1px solid #d4f7dc;
color: #006420;
}
.attachment-info-note {
background-color: #fff9e6;
border: 1px solid #fff0c2;
color: #664d00;
}
.attachment-link-container {
display: flex;
align-items: center;
padding: 8px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
margin: 4px 0;
}
.attachment-link-container:hover {
background-color: #e9ecef;
}
.attachment-note {
font-size: %dpx;
color: #6c757d;
font-style: italic;
margin-left: 8px;
}
.no-content {
padding: 20px;
text-align: center;
color: #666;
font-style: italic;
}
.text-body {
white-space: pre-wrap;
}
img {
max-width: 100%%;
height: auto;
display: block;
}
""",
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_LINE_HEIGHT,
DEFAULT_TEXT_COLOR,
DEFAULT_BACKGROUND_COLOR,
DEFAULT_BORDER_COLOR,
DEFAULT_FONT_SIZE + 4,
DEFAULT_FONT_SIZE - 1,
ATTACHMENT_BACKGROUND_COLOR,
ATTACHMENT_BORDER_COLOR,
DEFAULT_FONT_SIZE + 1,
DEFAULT_FONT_SIZE - 2,
DEFAULT_FONT_SIZE - 2,
DEFAULT_FONT_SIZE - 3);
html.append(css);
}
private static void appendAttachmentsSection(
StringBuilder html,
EmlParser.EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer) {
html.append("<div class=\"attachment-section\">\n");
int displayedAttachmentCount =
content.getAttachmentCount() > 0
? content.getAttachmentCount()
: content.getAttachments().size();
html.append("<h3>Attachments (").append(displayedAttachmentCount).append(")</h3>\n");
if (!content.getAttachments().isEmpty()) {
for (int i = 0; i < content.getAttachments().size(); i++) {
EmlParser.EmailAttachment attachment = content.getAttachments().get(i);
String embeddedFilename =
attachment.getFilename() != null
? attachment.getFilename()
: ("attachment_" + i);
attachment.setEmbeddedFilename(embeddedFilename);
String sizeStr = GeneralUtils.formatBytes(attachment.getSizeBytes());
String contentType =
attachment.getContentType() != null
&& !attachment.getContentType().isEmpty()
? ", " + escapeHtml(attachment.getContentType())
: "";
String attachmentId = "attachment_" + i;
html.append(
String.format(
"""
<div class="attachment-item" id="%s">
<span class="attachment-icon" data-filename="%s">@</span>
<span class="attachment-name">%s</span>
<span class="attachment-details">(%s%s)</span>
</div>
""",
attachmentId,
escapeHtml(embeddedFilename),
escapeHtml(EmlParser.safeMimeDecode(attachment.getFilename())),
sizeStr,
contentType));
}
}
if (request != null && request.isIncludeAttachments()) {
html.append(
"""
<div class="attachment-info-note">
<p><em>Attachments are embedded in the file.</em></p>
</div>
""");
} else {
html.append(
"""
<div class="attachment-info-note">
<p><em>Attachment information displayed - files not included in PDF.</em></p>
</div>
""");
}
html.append("</div>\n");
}
public static HTMLToPdfRequest createHtmlRequest(EmlToPdfRequest request) {
HTMLToPdfRequest htmlRequest = new HTMLToPdfRequest();
if (request != null) {
htmlRequest.setFileInput(request.getFileInput());
}
htmlRequest.setZoom(Float.parseFloat(DEFAULT_ZOOM));
return htmlRequest;
}
public static String detectMimeType(String filename, String existingMimeType) {
if (existingMimeType != null && !existingMimeType.isEmpty()) {
return existingMimeType;
}
if (filename != null) {
String lowerFilename = filename.toLowerCase();
for (Map.Entry<String, String> entry : EXTENSION_TO_MIME_TYPE.entrySet()) {
if (lowerFilename.endsWith(entry.getKey())) {
return entry.getValue();
}
}
}
return "image/png";
}
public static String decodeUrlEncoded(String encoded) {
try {
return java.net.URLDecoder.decode(encoded, StandardCharsets.UTF_8);
} catch (Exception e) {
return encoded; // Return original if decoding fails
}
}
public static String decodeMimeHeader(String encodedText) {
if (encodedText == null || encodedText.trim().isEmpty()) {
return encodedText;
}
try {
StringBuilder result = new StringBuilder();
Pattern concatenatedPattern =
Pattern.compile(
"(=\\?[^?]+\\?[BbQq]\\?[^?]*\\?=)(\\s*=\\?[^?]+\\?[BbQq]\\?[^?]*\\?=)+");
Matcher concatenatedMatcher = concatenatedPattern.matcher(encodedText);
String processedText =
concatenatedMatcher.replaceAll(
match -> match.group().replaceAll("\\s+(?==\\?)", ""));
Pattern mimePattern = Pattern.compile("=\\?([^?]+)\\?([BbQq])\\?([^?]*)\\?=");
Matcher matcher = mimePattern.matcher(processedText);
int lastEnd = 0;
while (matcher.find()) {
result.append(processedText, lastEnd, matcher.start());
String charset = matcher.group(1);
String encoding = matcher.group(2).toUpperCase();
String encodedValue = matcher.group(3);
try {
String decodedValue =
switch (encoding) {
case "B" -> {
String cleanBase64 = encodedValue.replaceAll("\\s", "");
byte[] decodedBytes = Base64.getDecoder().decode(cleanBase64);
Charset targetCharset;
try {
targetCharset = Charset.forName(charset);
} catch (Exception e) {
targetCharset = StandardCharsets.UTF_8;
}
yield new String(decodedBytes, targetCharset);
}
case "Q" -> decodeQuotedPrintable(encodedValue, charset);
default -> matcher.group(0); // Return original if unknown encoding
};
result.append(decodedValue);
} catch (RuntimeException e) {
result.append(matcher.group(0)); // Keep original on decode error
}
lastEnd = matcher.end();
}
result.append(processedText.substring(lastEnd));
return result.toString();
} catch (Exception e) {
return encodedText; // Return original on any parsing error
}
}
private static String decodeQuotedPrintable(String encodedText, String charset) {
StringBuilder result = new StringBuilder();
for (int i = 0; i < encodedText.length(); i++) {
char c = encodedText.charAt(i);
switch (c) {
case '=' -> {
if (i + 2 < encodedText.length()) {
String hex = encodedText.substring(i + 1, i + 3);
try {
int value = Integer.parseInt(hex, 16);
result.append((char) value);
i += 2;
} catch (NumberFormatException e) {
result.append(c);
}
} else if (i + 1 == encodedText.length()
|| (i + 2 == encodedText.length()
&& encodedText.charAt(i + 1) == '\n')) {
if (i + 1 < encodedText.length() && encodedText.charAt(i + 1) == '\n') {
i++; // Skip the newline too
}
} else {
result.append(c);
}
}
case '_' -> result.append(' '); // Space encoding in Q encoding
default -> result.append(c);
}
}
byte[] bytes = result.toString().getBytes(StandardCharsets.ISO_8859_1);
try {
Charset targetCharset = Charset.forName(charset);
return new String(bytes, targetCharset);
} catch (Exception e) {
try {
return new String(bytes, StandardCharsets.UTF_8);
} catch (Exception fallbackException) {
return new String(bytes, StandardCharsets.ISO_8859_1);
}
}
}
public static String escapeHtml(String text) {
if (text == null) return "";
return text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
public static String sanitizeText(String text, CustomHtmlSanitizer customHtmlSanitizer) {
if (customHtmlSanitizer != null) {
return customHtmlSanitizer.sanitize(text);
} else {
return escapeHtml(text);
}
}
public static String simplifyHtmlContent(String htmlContent) {
String simplified = htmlContent.replaceAll("(?i)<script[^>]*>.*?</script>", "");
simplified = simplified.replaceAll("(?i)<style[^>]*>.*?</style>", "");
return simplified;
}
}
@@ -1,103 +0,0 @@
package stirling.software.common.util;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import lombok.experimental.UtilityClass;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
@UtilityClass
public class EmlToPdf {
public static String convertEmlToHtml(byte[] emlBytes, EmlToPdfRequest request)
throws IOException {
EmlProcessingUtils.validateEmlInput(emlBytes);
EmlParser.EmailContent emailContent =
EmlParser.extractEmailContent(emlBytes, request, null);
return EmlProcessingUtils.generateEnhancedEmailHtml(emailContent, request, null);
}
public static byte[] convertEmlToPdf(
String weasyprintPath,
EmlToPdfRequest request,
byte[] emlBytes,
String fileName,
CustomPDFDocumentFactory pdfDocumentFactory,
TempFileManager tempFileManager,
CustomHtmlSanitizer customHtmlSanitizer)
throws IOException, InterruptedException {
EmlProcessingUtils.validateEmlInput(emlBytes);
try {
EmlParser.EmailContent emailContent =
EmlParser.extractEmailContent(emlBytes, request, customHtmlSanitizer);
String htmlContent =
EmlProcessingUtils.generateEnhancedEmailHtml(
emailContent, request, customHtmlSanitizer);
byte[] pdfBytes =
convertHtmlToPdf(
weasyprintPath,
request,
htmlContent,
tempFileManager,
customHtmlSanitizer);
if (shouldAttachFiles(emailContent, request)) {
pdfBytes =
PdfAttachmentHandler.attachFilesToPdf(
pdfBytes, emailContent.getAttachments(), pdfDocumentFactory);
}
return pdfBytes;
} catch (IOException | InterruptedException e) {
throw e;
} catch (Exception e) {
throw new IOException("Error converting EML to PDF", e);
}
}
private static boolean shouldAttachFiles(
EmlParser.EmailContent emailContent, EmlToPdfRequest request) {
return emailContent != null
&& request != null
&& request.isIncludeAttachments()
&& !emailContent.getAttachments().isEmpty();
}
private static byte[] convertHtmlToPdf(
String weasyprintPath,
EmlToPdfRequest request,
String htmlContent,
TempFileManager tempFileManager,
CustomHtmlSanitizer customHtmlSanitizer)
throws IOException, InterruptedException {
var htmlRequest = EmlProcessingUtils.createHtmlRequest(request);
try {
return FileToPdf.convertHtmlToPdf(
weasyprintPath,
htmlRequest,
htmlContent.getBytes(StandardCharsets.UTF_8),
"email.html",
tempFileManager,
customHtmlSanitizer);
} catch (IOException | InterruptedException e) {
String simplifiedHtml = EmlProcessingUtils.simplifyHtmlContent(htmlContent);
return FileToPdf.convertHtmlToPdf(
weasyprintPath,
htmlRequest,
simplifiedHtml.getBytes(StandardCharsets.UTF_8),
"email.html",
tempFileManager,
customHtmlSanitizer);
}
}
}
@@ -1,680 +0,0 @@
package stirling.software.common.util;
import static stirling.software.common.util.AttachmentUtils.setCatalogViewerPreferences;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary;
import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PageMode;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationFileAttachment;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
import org.jetbrains.annotations.NotNull;
import org.springframework.web.multipart.MultipartFile;
import lombok.Data;
import lombok.Getter;
import lombok.experimental.UtilityClass;
import stirling.software.common.service.CustomPDFDocumentFactory;
@UtilityClass
public class PdfAttachmentHandler {
// Note: This class is designed for EML attachments, not general PDF attachments.
private static final String ATTACHMENT_MARKER = "@";
private static final float ATTACHMENT_ICON_WIDTH = 12f;
private static final float ATTACHMENT_ICON_HEIGHT = 14f;
private static final float ANNOTATION_X_OFFSET = 2f;
private static final float ANNOTATION_Y_OFFSET = 10f;
public static byte[] attachFilesToPdf(
byte[] pdfBytes,
List<EmlParser.EmailAttachment> attachments,
CustomPDFDocumentFactory pdfDocumentFactory)
throws IOException {
if (attachments == null || attachments.isEmpty()) {
return pdfBytes;
}
try (PDDocument document = pdfDocumentFactory.load(pdfBytes);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
List<MultipartFile> multipartAttachments = new ArrayList<>(attachments.size());
for (int i = 0; i < attachments.size(); i++) {
EmlParser.EmailAttachment attachment = attachments.get(i);
if (attachment.getData() != null && attachment.getData().length > 0) {
String embeddedFilename =
attachment.getFilename() != null
? attachment.getFilename()
: ("attachment_" + i);
attachment.setEmbeddedFilename(embeddedFilename);
multipartAttachments.add(createMultipartFile(attachment));
}
}
if (!multipartAttachments.isEmpty()) {
Map<Integer, String> indexToFilenameMap =
addAttachmentsToDocumentWithMapping(
document, multipartAttachments, attachments);
setCatalogViewerPreferences(document, PageMode.USE_ATTACHMENTS);
addAttachmentAnnotationsToDocumentWithMapping(
document, attachments, indexToFilenameMap);
}
document.save(outputStream);
return outputStream.toByteArray();
} catch (RuntimeException e) {
throw new IOException(
"Invalid PDF structure or processing error: " + e.getMessage(), e);
} catch (Exception e) {
throw new IOException("Error attaching files to PDF: " + e.getMessage(), e);
}
}
private static MultipartFile createMultipartFile(EmlParser.EmailAttachment attachment) {
return new MultipartFile() {
@Override
public @NotNull String getName() {
return "attachment";
}
@Override
public String getOriginalFilename() {
return attachment.getFilename() != null
? attachment.getFilename()
: "attachment_" + System.currentTimeMillis();
}
@Override
public String getContentType() {
return attachment.getContentType() != null
? attachment.getContentType()
: "application/octet-stream";
}
@Override
public boolean isEmpty() {
return attachment.getData() == null || attachment.getData().length == 0;
}
@Override
public long getSize() {
return attachment.getData() != null ? attachment.getData().length : 0;
}
@Override
public byte @NotNull [] getBytes() {
return attachment.getData() != null ? attachment.getData() : new byte[0];
}
@Override
public @NotNull InputStream getInputStream() {
byte[] data = attachment.getData();
return new ByteArrayInputStream(data != null ? data : new byte[0]);
}
@Override
public void transferTo(@NotNull File dest) throws IOException, IllegalStateException {
try (FileOutputStream fos = new FileOutputStream(dest)) {
byte[] data = attachment.getData();
if (data != null) {
fos.write(data);
}
}
}
};
}
private static String ensureUniqueFilename(String filename, Set<String> existingNames) {
if (!existingNames.contains(filename)) {
return filename;
}
String baseName;
String extension = "";
int lastDot = filename.lastIndexOf('.');
if (lastDot > 0) {
baseName = filename.substring(0, lastDot);
extension = filename.substring(lastDot);
} else {
baseName = filename;
}
int counter = 1;
String uniqueName;
do {
uniqueName = baseName + "_" + counter + extension;
counter++;
} while (existingNames.contains(uniqueName));
return uniqueName;
}
private static @NotNull PDRectangle calculateAnnotationRectangle(
PDPage page, float x, float y) {
PDRectangle cropBox = page.getCropBox();
// ISO 32000-1:2008 Section 8.3: PDF coordinate system transforms
int rotation = page.getRotation();
float pdfX = x;
float pdfY = cropBox.getHeight() - y;
switch (rotation) {
case 90 -> {
float temp = pdfX;
pdfX = pdfY;
pdfY = cropBox.getWidth() - temp;
}
case 180 -> {
pdfX = cropBox.getWidth() - pdfX;
pdfY = y;
}
case 270 -> {
float temp = pdfX;
pdfX = cropBox.getHeight() - pdfY;
pdfY = temp;
}
default -> {}
}
float iconHeight = ATTACHMENT_ICON_HEIGHT;
float paddingX = 2.0f;
float paddingY = 2.0f;
PDRectangle rect =
new PDRectangle(
pdfX + ANNOTATION_X_OFFSET + paddingX,
pdfY - iconHeight + ANNOTATION_Y_OFFSET + paddingY,
ATTACHMENT_ICON_WIDTH,
iconHeight);
PDRectangle mediaBox = page.getMediaBox();
if (rect.getLowerLeftX() < mediaBox.getLowerLeftX()
|| rect.getLowerLeftY() < mediaBox.getLowerLeftY()
|| rect.getUpperRightX() > mediaBox.getUpperRightX()
|| rect.getUpperRightY() > mediaBox.getUpperRightY()) {
float adjustedX =
Math.max(
mediaBox.getLowerLeftX(),
Math.min(
rect.getLowerLeftX(),
mediaBox.getUpperRightX() - rect.getWidth()));
float adjustedY =
Math.max(
mediaBox.getLowerLeftY(),
Math.min(
rect.getLowerLeftY(),
mediaBox.getUpperRightY() - rect.getHeight()));
rect = new PDRectangle(adjustedX, adjustedY, rect.getWidth(), rect.getHeight());
}
return rect;
}
public static String processInlineImages(
String htmlContent, EmlParser.EmailContent emailContent) {
if (htmlContent == null || emailContent == null) return htmlContent;
Map<String, EmlParser.EmailAttachment> contentIdMap = new HashMap<>();
for (EmlParser.EmailAttachment attachment : emailContent.getAttachments()) {
if (attachment.isEmbedded()
&& attachment.getContentId() != null
&& attachment.getData() != null) {
contentIdMap.put(attachment.getContentId(), attachment);
}
}
if (contentIdMap.isEmpty()) return htmlContent;
Pattern cidPattern =
Pattern.compile(
"(?i)<img[^>]*\\ssrc\\s*=\\s*['\"]cid:([^'\"]+)['\"][^>]*>",
Pattern.CASE_INSENSITIVE);
Matcher matcher = cidPattern.matcher(htmlContent);
StringBuilder result = new StringBuilder();
while (matcher.find()) {
String contentId = matcher.group(1);
EmlParser.EmailAttachment attachment = contentIdMap.get(contentId);
if (attachment != null && attachment.getData() != null) {
String mimeType =
EmlProcessingUtils.detectMimeType(
attachment.getFilename(), attachment.getContentType());
String base64Data = Base64.getEncoder().encodeToString(attachment.getData());
String dataUri = "data:" + mimeType + ";base64," + base64Data;
String replacement =
matcher.group(0).replaceFirst("cid:" + Pattern.quote(contentId), dataUri);
matcher.appendReplacement(result, Matcher.quoteReplacement(replacement));
} else {
matcher.appendReplacement(result, Matcher.quoteReplacement(matcher.group(0)));
}
}
matcher.appendTail(result);
return result.toString();
}
public static String formatEmailDate(Date date) {
if (date == null) return "";
SimpleDateFormat formatter =
new SimpleDateFormat("EEE, MMM d, yyyy 'at' h:mm a z", Locale.ENGLISH);
formatter.setTimeZone(TimeZone.getTimeZone("UTC"));
return formatter.format(date);
}
@Data
public static class MarkerPosition {
private int pageIndex;
private float x;
private float y;
private String character;
private String filename;
public MarkerPosition(int pageIndex, float x, float y, String character, String filename) {
this.pageIndex = pageIndex;
this.x = x;
this.y = y;
this.character = character;
this.filename = filename;
}
}
public static class AttachmentMarkerPositionFinder extends PDFTextStripper {
@Getter private final List<MarkerPosition> positions = new ArrayList<>();
private int currentPageIndex;
protected boolean sortByPosition;
private boolean isInAttachmentSection;
private boolean attachmentSectionFound;
private final StringBuilder currentText = new StringBuilder();
private static final Pattern ATTACHMENT_SECTION_PATTERN =
Pattern.compile("attachments\\s*\\(\\d+\\)", Pattern.CASE_INSENSITIVE);
private static final Pattern FILENAME_PATTERN =
Pattern.compile("@\\s*([^\\s\\(]+(?:\\.[a-zA-Z0-9]+)?)");
public AttachmentMarkerPositionFinder() {
super();
this.currentPageIndex = 0;
this.sortByPosition = false; // Disable sorting to preserve document order
this.isInAttachmentSection = false;
this.attachmentSectionFound = false;
}
@Override
public String getText(PDDocument document) throws IOException {
super.getText(document);
if (sortByPosition) {
positions.sort(
(a, b) -> {
int pageCompare = Integer.compare(a.getPageIndex(), b.getPageIndex());
if (pageCompare != 0) return pageCompare;
return Float.compare(
b.getY(), a.getY()); // Descending Y per PDF coordinate system
});
}
return ""; // Return empty string as we only need positions
}
@Override
protected void startPage(PDPage page) throws IOException {
super.startPage(page);
}
@Override
protected void endPage(PDPage page) throws IOException {
currentPageIndex++;
super.endPage(page);
}
@Override
protected void writeString(String string, List<TextPosition> textPositions)
throws IOException {
String lowerString = string.toLowerCase();
if (ATTACHMENT_SECTION_PATTERN.matcher(lowerString).find()) {
isInAttachmentSection = true;
attachmentSectionFound = true;
}
if (isInAttachmentSection
&& (lowerString.contains("</body>")
|| lowerString.contains("</html>")
|| (attachmentSectionFound
&& lowerString.trim().isEmpty()
&& string.length() > 50))) {
isInAttachmentSection = false;
}
if (isInAttachmentSection) {
currentText.append(string);
for (int i = 0; (i = string.indexOf(ATTACHMENT_MARKER, i)) != -1; i++) {
if (i < textPositions.size()) {
TextPosition textPosition = textPositions.get(i);
String filename = extractFilenameAfterMarker(string, i);
MarkerPosition position =
new MarkerPosition(
currentPageIndex,
textPosition.getXDirAdj(),
textPosition.getYDirAdj(),
ATTACHMENT_MARKER,
filename);
positions.add(position);
}
}
}
super.writeString(string, textPositions);
}
@Override
public void setSortByPosition(boolean sortByPosition) {
this.sortByPosition = sortByPosition;
}
private String extractFilenameAfterMarker(String text, int markerIndex) {
String afterMarker = text.substring(markerIndex + 1);
Matcher matcher = FILENAME_PATTERN.matcher("@" + afterMarker);
if (matcher.find()) {
return matcher.group(1);
}
String[] parts = afterMarker.split("[\\s\\(\\)]+");
for (String part : parts) {
part = part.trim();
if (part.length() > 3 && part.contains(".")) {
return part;
}
}
return null;
}
}
private static Map<Integer, String> addAttachmentsToDocumentWithMapping(
PDDocument document,
List<MultipartFile> attachments,
List<EmlParser.EmailAttachment> originalAttachments)
throws IOException {
PDDocumentCatalog catalog = document.getDocumentCatalog();
if (catalog == null) {
throw new IOException("PDF document catalog is not accessible");
}
PDDocumentNameDictionary documentNames = catalog.getNames();
if (documentNames == null) {
documentNames = new PDDocumentNameDictionary(catalog);
catalog.setNames(documentNames);
}
PDEmbeddedFilesNameTreeNode embeddedFilesTree = documentNames.getEmbeddedFiles();
if (embeddedFilesTree == null) {
embeddedFilesTree = new PDEmbeddedFilesNameTreeNode();
documentNames.setEmbeddedFiles(embeddedFilesTree);
}
Map<String, PDComplexFileSpecification> existingNames = embeddedFilesTree.getNames();
if (existingNames == null) {
existingNames = new HashMap<>();
}
Map<Integer, String> indexToFilenameMap = new HashMap<>();
for (int i = 0; i < attachments.size(); i++) {
MultipartFile attachment = attachments.get(i);
String filename = attachment.getOriginalFilename();
if (filename == null || filename.trim().isEmpty()) {
filename = "attachment_" + i;
}
String normalizedFilename =
isAscii(filename)
? filename
: java.text.Normalizer.normalize(
filename, java.text.Normalizer.Form.NFC);
String uniqueFilename =
ensureUniqueFilename(normalizedFilename, existingNames.keySet());
indexToFilenameMap.put(i, uniqueFilename);
PDEmbeddedFile embeddedFile = new PDEmbeddedFile(document, attachment.getInputStream());
embeddedFile.setSize((int) attachment.getSize());
GregorianCalendar currentTime = new GregorianCalendar();
embeddedFile.setCreationDate(currentTime);
embeddedFile.setModDate(currentTime);
String contentType = attachment.getContentType();
if (contentType != null && !contentType.trim().isEmpty()) {
embeddedFile.setSubtype(contentType);
}
PDComplexFileSpecification fileSpecification = new PDComplexFileSpecification();
fileSpecification.setFile(uniqueFilename);
fileSpecification.setFileUnicode(uniqueFilename);
fileSpecification.setEmbeddedFile(embeddedFile);
fileSpecification.setEmbeddedFileUnicode(embeddedFile);
existingNames.put(uniqueFilename, fileSpecification);
}
embeddedFilesTree.setNames(existingNames);
documentNames.setEmbeddedFiles(embeddedFilesTree);
catalog.setNames(documentNames);
return indexToFilenameMap;
}
private static void addAttachmentAnnotationsToDocumentWithMapping(
PDDocument document,
List<EmlParser.EmailAttachment> attachments,
Map<Integer, String> indexToFilenameMap)
throws IOException {
if (document.getNumberOfPages() == 0 || attachments == null || attachments.isEmpty()) {
return;
}
AttachmentMarkerPositionFinder finder = new AttachmentMarkerPositionFinder();
finder.setSortByPosition(false); // Keep document order to maintain pairing
finder.getText(document);
List<MarkerPosition> markerPositions = finder.getPositions();
int annotationsToAdd = Math.min(markerPositions.size(), attachments.size());
for (int i = 0; i < annotationsToAdd; i++) {
MarkerPosition position = markerPositions.get(i);
String filenameNearMarker = position.getFilename();
EmlParser.EmailAttachment matchingAttachment =
findAttachmentByFilename(attachments, filenameNearMarker);
if (matchingAttachment != null) {
String embeddedFilename =
findEmbeddedFilenameForAttachment(matchingAttachment, indexToFilenameMap);
if (embeddedFilename != null) {
PDPage page = document.getPage(position.getPageIndex());
addAttachmentAnnotationToPageWithMapping(
document,
page,
matchingAttachment,
embeddedFilename,
position.getX(),
position.getY(),
i);
} else {
// No embedded filename found for attachment
}
} else {
// No matching attachment found for filename near marker
}
}
}
private static EmlParser.EmailAttachment findAttachmentByFilename(
List<EmlParser.EmailAttachment> attachments, String targetFilename) {
if (targetFilename == null || targetFilename.trim().isEmpty()) {
return null;
}
String normalizedTarget = normalizeFilename(targetFilename);
// First try exact match
for (EmlParser.EmailAttachment attachment : attachments) {
if (attachment.getFilename() != null) {
String normalizedAttachment = normalizeFilename(attachment.getFilename());
if (normalizedAttachment.equals(normalizedTarget)) {
return attachment;
}
}
}
// Then try contains match
for (EmlParser.EmailAttachment attachment : attachments) {
if (attachment.getFilename() != null) {
String normalizedAttachment = normalizeFilename(attachment.getFilename());
if (normalizedAttachment.contains(normalizedTarget)
|| normalizedTarget.contains(normalizedAttachment)) {
return attachment;
}
}
}
return null;
}
private static String findEmbeddedFilenameForAttachment(
EmlParser.EmailAttachment attachment, Map<Integer, String> indexToFilenameMap) {
String attachmentFilename = attachment.getFilename();
if (attachmentFilename == null) {
return null;
}
for (Map.Entry<Integer, String> entry : indexToFilenameMap.entrySet()) {
String embeddedFilename = entry.getValue();
if (embeddedFilename != null
&& (embeddedFilename.equals(attachmentFilename)
|| embeddedFilename.contains(attachmentFilename)
|| attachmentFilename.contains(embeddedFilename))) {
return embeddedFilename;
}
}
return null;
}
private static String normalizeFilename(String filename) {
if (filename == null) return "";
return filename.toLowerCase()
.trim()
.replaceAll("\\s+", " ")
.replaceAll("[^a-zA-Z0-9._-]", "");
}
private static void addAttachmentAnnotationToPageWithMapping(
PDDocument document,
PDPage page,
EmlParser.EmailAttachment attachment,
String embeddedFilename,
float x,
float y,
int attachmentIndex)
throws IOException {
PDAnnotationFileAttachment fileAnnotation = new PDAnnotationFileAttachment();
PDRectangle rect = calculateAnnotationRectangle(page, x, y);
fileAnnotation.setRectangle(rect);
fileAnnotation.setPrinted(false);
fileAnnotation.setHidden(false);
fileAnnotation.setNoView(false);
fileAnnotation.setNoZoom(true);
fileAnnotation.setNoRotate(true);
try {
PDAppearanceDictionary appearance = new PDAppearanceDictionary();
PDAppearanceStream normalAppearance = new PDAppearanceStream(document);
normalAppearance.setBBox(new PDRectangle(0, 0, rect.getWidth(), rect.getHeight()));
appearance.setNormalAppearance(normalAppearance);
fileAnnotation.setAppearance(appearance);
} catch (RuntimeException e) {
fileAnnotation.setAppearance(null);
}
PDEmbeddedFilesNameTreeNode efTree =
document.getDocumentCatalog().getNames().getEmbeddedFiles();
if (efTree != null) {
Map<String, PDComplexFileSpecification> efMap = efTree.getNames();
if (efMap != null) {
PDComplexFileSpecification fileSpec = efMap.get(embeddedFilename);
if (fileSpec != null) {
fileAnnotation.setFile(fileSpec);
} else {
// Could not find embedded file
}
}
}
fileAnnotation.setContents(
"Attachment " + (attachmentIndex + 1) + ": " + attachment.getFilename());
fileAnnotation.setAnnotationName(
"EmbeddedFile_" + attachmentIndex + "_" + embeddedFilename);
page.getAnnotations().add(fileAnnotation);
}
private static boolean isAscii(String str) {
if (str == null) return true;
for (int i = 0; i < str.length(); i++) {
if (str.charAt(i) > 127) {
return false;
}
}
return true;
}
}
@@ -1,59 +0,0 @@
package stirling.software.common.model;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.StandardEnvironment;
import stirling.software.common.configuration.InstallationPathConfig;
class ApplicationPropertiesDynamicYamlPropertySourceTest {
@Test
void loads_yaml_into_environment() throws Exception {
// YAML-Config in Temp-Datei schreiben
String yaml =
""
+ "ui:\n"
+ " appName: \"My App\"\n"
+ "system:\n"
+ " enableAnalytics: true\n";
Path tmp = Files.createTempFile("spdf-settings-", ".yml");
Files.writeString(tmp, yaml);
// Pfad per statischem Mock liefern
try (MockedStatic<InstallationPathConfig> mocked =
Mockito.mockStatic(InstallationPathConfig.class)) {
mocked.when(InstallationPathConfig::getSettingsPath).thenReturn(tmp.toString());
ConfigurableEnvironment env = new StandardEnvironment();
ApplicationProperties props = new ApplicationProperties();
props.dynamicYamlPropertySource(env); // fügt PropertySource an erster Stelle ein
assertEquals("My App", env.getProperty("ui.appName"));
assertEquals("true", env.getProperty("system.enableAnalytics"));
}
}
@Test
void throws_when_settings_file_missing() throws Exception {
String missing = "/path/does/not/exist/spdf.yml";
try (MockedStatic<InstallationPathConfig> mocked =
Mockito.mockStatic(InstallationPathConfig.class)) {
mocked.when(InstallationPathConfig::getSettingsPath).thenReturn(missing);
ConfigurableEnvironment env = new StandardEnvironment();
ApplicationProperties props = new ApplicationProperties();
assertThrows(IOException.class, () -> props.dynamicYamlPropertySource(env));
}
}
}
@@ -1,248 +0,0 @@
package stirling.software.common.model;
import static org.junit.jupiter.api.Assertions.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties.Driver;
import stirling.software.common.model.ApplicationProperties.Premium;
import stirling.software.common.model.ApplicationProperties.Security;
import stirling.software.common.model.exception.UnsupportedProviderException;
class ApplicationPropertiesLogicTest {
@Test
void system_isAnalyticsEnabled_null_false_true() {
ApplicationProperties.System sys = new ApplicationProperties.System();
sys.setEnableAnalytics(null);
assertFalse(sys.isAnalyticsEnabled());
sys.setEnableAnalytics(Boolean.FALSE);
assertFalse(sys.isAnalyticsEnabled());
sys.setEnableAnalytics(Boolean.TRUE);
assertTrue(sys.isAnalyticsEnabled());
}
@Test
void tempFileManagement_defaults_and_overrides() {
ApplicationProperties.TempFileManagement tfm =
new ApplicationProperties.TempFileManagement();
String expectedBase =
java.lang.System.getProperty("java.io.tmpdir").replaceAll("/+$", "")
+ "/stirling-pdf";
assertEquals(expectedBase, tfm.getBaseTmpDir());
String expectedLibre = expectedBase + "/libreoffice";
assertEquals(expectedLibre, tfm.getLibreofficeDir());
tfm.setBaseTmpDir("/custom/base");
assertEquals("/custom/base", tfm.getBaseTmpDir());
tfm.setLibreofficeDir("/opt/libre");
assertEquals("/opt/libre", tfm.getLibreofficeDir());
}
@Test
void oauth2_scope_parsing_and_validity() {
Security.OAUTH2 oauth2 = new Security.OAUTH2();
oauth2.setIssuer("https://issuer");
oauth2.setClientId("client");
oauth2.setClientSecret("secret");
oauth2.setUseAsUsername("email");
oauth2.setScopes("openid, profile ,email");
assertTrue(oauth2.isSettingsValid());
}
@Test
void security_login_method_flags() {
Security sec = new Security();
sec.getOauth2().setEnabled(true);
sec.getSaml2().setEnabled(true);
assertTrue(sec.isUserPass());
assertTrue(sec.isOauth2Active());
assertTrue(sec.isSaml2Active());
sec.setLoginMethod(Security.LoginMethods.NORMAL.toString());
assertTrue(sec.isUserPass());
assertFalse(sec.isOauth2Active());
assertFalse(sec.isSaml2Active());
}
@Test
void security_isAltLogin_reflects_oauth2_or_saml2() {
Security sec = new Security();
assertFalse(sec.isAltLogin());
sec.getOauth2().setEnabled(true);
sec.getSaml2().setEnabled(false);
assertTrue(sec.isAltLogin());
sec.getOauth2().setEnabled(false);
sec.getSaml2().setEnabled(true);
assertTrue(sec.isAltLogin());
sec.getOauth2().setEnabled(true);
sec.getSaml2().setEnabled(true);
assertTrue(sec.isAltLogin());
}
@Test
void oauth2_client_provider_mapping_and_unsupported() throws UnsupportedProviderException {
Security.OAUTH2.Client client = new Security.OAUTH2.Client();
assertNotNull(client.get("google"));
assertNotNull(client.get("github"));
assertNotNull(client.get("keycloak"));
UnsupportedProviderException ex =
assertThrows(UnsupportedProviderException.class, () -> client.get("unknown"));
assertTrue(ex.getMessage().toLowerCase().contains("not supported"));
}
@Test
void premium_google_drive_getters_return_empty_string_on_null_or_blank() {
Premium.ProFeatures.GoogleDrive gd = new Premium.ProFeatures.GoogleDrive();
assertEquals("", gd.getClientId());
assertEquals("", gd.getApiKey());
assertEquals("", gd.getAppId());
gd.setClientId(" id ");
gd.setApiKey(" key ");
gd.setAppId(" app ");
assertEquals(" id ", gd.getClientId());
assertEquals(" key ", gd.getApiKey());
assertEquals(" app ", gd.getAppId());
}
@Test
void ui_getters_return_null_for_blank() {
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
ui.setAppName(" ");
ui.setHomeDescription("");
ui.setAppNameNavbar(null);
assertNull(ui.getAppName());
assertNull(ui.getHomeDescription());
assertNull(ui.getAppNameNavbar());
ui.setAppName("Stirling-PDF");
ui.setHomeDescription("Home");
ui.setAppNameNavbar("Nav");
assertEquals("Stirling-PDF", ui.getAppName());
assertEquals("Home", ui.getHomeDescription());
assertEquals("Nav", ui.getAppNameNavbar());
}
@Test
void driver_toString_contains_driver_name() {
assertTrue(Driver.H2.toString().contains("h2"));
assertTrue(Driver.POSTGRESQL.toString().contains("postgresql"));
}
@Test
void session_limits_and_timeouts_have_reasonable_defaults() {
ApplicationProperties.ProcessExecutor pe = new ApplicationProperties.ProcessExecutor();
ApplicationProperties.ProcessExecutor.SessionLimit s = pe.getSessionLimit();
assertEquals(2, s.getQpdfSessionLimit());
assertEquals(1, s.getTesseractSessionLimit());
assertEquals(1, s.getLibreOfficeSessionLimit());
assertEquals(1, s.getPdfToHtmlSessionLimit());
assertEquals(8, s.getPythonOpenCvSessionLimit());
assertEquals(16, s.getWeasyPrintSessionLimit());
assertEquals(1, s.getInstallAppSessionLimit());
assertEquals(1, s.getCalibreSessionLimit());
assertEquals(8, s.getGhostscriptSessionLimit());
assertEquals(2, s.getOcrMyPdfSessionLimit());
ApplicationProperties.ProcessExecutor.TimeoutMinutes t = pe.getTimeoutMinutes();
assertEquals(30, t.getTesseractTimeoutMinutes());
assertEquals(30, t.getQpdfTimeoutMinutes());
assertEquals(30, t.getLibreOfficeTimeoutMinutes());
assertEquals(20, t.getPdfToHtmlTimeoutMinutes());
assertEquals(30, t.getPythonOpenCvTimeoutMinutes());
assertEquals(30, t.getWeasyPrintTimeoutMinutes());
assertEquals(60, t.getInstallAppTimeoutMinutes());
assertEquals(30, t.getCalibreTimeoutMinutes());
assertEquals(30, t.getGhostscriptTimeoutMinutes());
assertEquals(30, t.getOcrMyPdfTimeoutMinutes());
}
@Deprecated
@Test
void enterprise_metadata_defaults() {
ApplicationProperties.EnterpriseEdition ee = new ApplicationProperties.EnterpriseEdition();
ApplicationProperties.EnterpriseEdition.CustomMetadata eMeta = ee.getCustomMetadata();
eMeta.setCreator(" ");
eMeta.setProducer(null);
assertEquals("Stirling-PDF", eMeta.getCreator());
assertEquals("Stirling-PDF", eMeta.getProducer());
}
@Test
void premium_metadata_defaults() {
Premium.ProFeatures pf = new Premium.ProFeatures();
Premium.ProFeatures.CustomMetadata pMeta = pf.getCustomMetadata();
pMeta.setCreator("");
pMeta.setProducer("");
assertEquals("Stirling-PDF", pMeta.getCreator());
assertEquals("Stirling-PDF", pMeta.getProducer());
}
@Test
void premium_metadata_awesome() {
Premium.ProFeatures pf = new Premium.ProFeatures();
Premium.ProFeatures.CustomMetadata pMeta = pf.getCustomMetadata();
pMeta.setCreator("Awesome PDF Tool");
pMeta.setProducer("Awesome PDF Tool");
assertEquals("Awesome PDF Tool", pMeta.getCreator());
assertEquals("Awesome PDF Tool", pMeta.getProducer());
}
@Test
void string_isValid_handles_null_empty_blank_and_trimmed() {
ApplicationProperties.Security.OAUTH2 oauth2 = new ApplicationProperties.Security.OAUTH2();
assertFalse(oauth2.isValid((String) null, "issuer"));
assertFalse(oauth2.isValid("", "issuer"));
assertFalse(oauth2.isValid(" ", "issuer"));
assertTrue(oauth2.isValid("x", "issuer"));
assertTrue(oauth2.isValid(" x ", "issuer")); // trimmt intern
}
@Test
void collection_isValid_handles_null_and_empty() {
ApplicationProperties.Security.OAUTH2 oauth2 = new ApplicationProperties.Security.OAUTH2();
Collection<String> nullColl = null;
Collection<String> empty = List.of();
assertFalse(oauth2.isValid(nullColl, "scopes"));
assertFalse(oauth2.isValid(empty, "scopes"));
}
@Test
void collection_isValid_true_when_non_empty_even_if_element_is_blank() {
ApplicationProperties.Security.OAUTH2 oauth2 = new ApplicationProperties.Security.OAUTH2();
// Aktuelles Verhalten: prüft NUR !isEmpty(), nicht Inhalt
Collection<String> oneBlank = new ArrayList<>();
oneBlank.add(" ");
assertTrue(
oauth2.isValid(oneBlank, "scopes"),
"Dokumentiert aktuelles Verhalten: nicht-leere Liste gilt als gültig, auch wenn Element leer/blank ist");
}
}
@@ -1,80 +0,0 @@
package stirling.software.common.model;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
class ApplicationPropertiesSaml2HttpTest {
@Test
void idpMetadataUri_http_is_resolved_via_mockwebserver() throws Exception {
try (MockWebServer server = new MockWebServer()) {
server.enqueue(
new MockResponse()
.setResponseCode(200)
.addHeader("Content-Type", "application/xml")
.setBody("<EntityDescriptor/>"));
server.start();
String url = server.url("/meta").toString();
var s = new ApplicationProperties.Security.SAML2();
s.setIdpMetadataUri(url);
try (InputStream in = s.getIdpMetadataUri()) {
String body = new String(in.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(body.contains("EntityDescriptor"));
}
}
}
@Test
void idpMetadataUri_invalidUri_triggers_catch_and_throwsIOException() {
// Ungültige URI -> new URI(...) wirft URISyntaxException -> catch -> IOException
var s = new ApplicationProperties.Security.SAML2();
s.setIdpMetadataUri("http:##invalid uri"); // absichtlich kaputt (Leerzeichen + ##)
assertThrows(IOException.class, s::getIdpMetadataUri);
}
@Test
void spCert_else_branch_returns_FileSystemResource_for_filesystem_path() throws Exception {
var s = new ApplicationProperties.Security.SAML2();
// temporäre Datei simuliert "Filesystem"-Pfad (-> else-Zweig)
Path tmp = Files.createTempFile("spdf-spcert-", ".crt");
Files.writeString(tmp, "CERT");
s.setSpCert(tmp.toString());
Resource r = s.getSpCert();
assertNotNull(r);
assertTrue(r instanceof FileSystemResource, "Expected FileSystemResource for FS path");
assertTrue(r.exists(), "Temp file should exist");
}
@Test
void idpCert_else_branch_returns_FileSystemResource_even_if_missing() {
var s = new ApplicationProperties.Security.SAML2();
// bewusst nicht existierender Pfad -> else-Zweig wird trotzdem genommen
String missing = "/this/path/does/not/exist/idp.crt";
s.setIdpCert(missing);
Resource r = s.getIdpCert();
assertNotNull(r);
assertTrue(r instanceof FileSystemResource, "Expected FileSystemResource for FS path");
assertFalse(r.exists(), "Resource should not exist for missing file");
}
}
@@ -1,55 +0,0 @@
package stirling.software.common.model;
import static org.junit.jupiter.api.Assertions.*;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.Resource;
class ApplicationPropertiesSaml2ResourceTest {
@Test
void idpMetadataUri_classpath_is_resolved() throws Exception {
var s = new ApplicationProperties.Security.SAML2();
s.setIdpMetadataUri("classpath:saml/dummy.txt");
try (InputStream in = s.getIdpMetadataUri()) {
assertNotNull(in, "Classpath InputStream should not be null");
String txt = new String(in.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(txt.contains("ok"));
}
}
@Test
void spCert_idpCert_privateKey_null_classpath_and_filesystem() throws Exception {
var s = new ApplicationProperties.Security.SAML2();
s.setSpCert(null);
s.setIdpCert(null);
s.setPrivateKey(null);
assertNull(s.getSpCert());
assertNull(s.getIdpCert());
assertNull(s.getPrivateKey());
s.setSpCert("classpath:saml/dummy.txt");
s.setIdpCert("classpath:saml/dummy.txt");
s.setPrivateKey("classpath:saml/dummy.txt");
Resource sp = s.getSpCert();
Resource idp = s.getIdpCert();
Resource pk = s.getPrivateKey();
assertTrue(sp.exists());
assertTrue(idp.exists());
assertTrue(pk.exists());
Path tmp = Files.createTempFile("spdf-key-", ".pem");
Files.writeString(tmp, "KEY");
s.setPrivateKey(tmp.toString());
Resource pkFs = s.getPrivateKey();
assertNotNull(pkFs);
assertTrue(pkFs.exists());
}
}
File diff suppressed because it is too large Load Diff
@@ -1 +0,0 @@
ok
@@ -1,300 +0,0 @@
package stirling.software.SPDF.controller.api;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Stream;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.Dependency;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.SPDF.service.SignatureService;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
@Slf4j
@RestController
@RequestMapping("/api/v1/ui-data")
@Tag(name = "UI Data", description = "APIs for React UI data")
public class UIDataController {
private final ApplicationProperties applicationProperties;
private final SignatureService signatureService;
private final UserServiceInterface userService;
private final ResourceLoader resourceLoader;
private final RuntimePathConfig runtimePathConfig;
public UIDataController(
ApplicationProperties applicationProperties,
SignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService,
ResourceLoader resourceLoader,
RuntimePathConfig runtimePathConfig) {
this.applicationProperties = applicationProperties;
this.signatureService = signatureService;
this.userService = userService;
this.resourceLoader = resourceLoader;
this.runtimePathConfig = runtimePathConfig;
}
@GetMapping("/home")
@Operation(summary = "Get home page data")
public ResponseEntity<HomeData> getHomeData() {
String showSurvey = System.getenv("SHOW_SURVEY");
boolean showSurveyValue = showSurvey == null || "true".equalsIgnoreCase(showSurvey);
HomeData data = new HomeData();
data.setShowSurveyFromDocker(showSurveyValue);
return ResponseEntity.ok(data);
}
@GetMapping("/licenses")
@Operation(summary = "Get third-party licenses data")
public ResponseEntity<LicensesData> getLicensesData() {
LicensesData data = new LicensesData();
Resource resource = new ClassPathResource("static/3rdPartyLicenses.json");
try {
InputStream is = resource.getInputStream();
String json = new String(is.readAllBytes(), StandardCharsets.UTF_8);
ObjectMapper mapper = new ObjectMapper();
Map<String, List<Dependency>> licenseData =
mapper.readValue(json, new TypeReference<>() {});
data.setDependencies(licenseData.get("dependencies"));
} catch (IOException e) {
log.error("Failed to load licenses data", e);
data.setDependencies(Collections.emptyList());
}
return ResponseEntity.ok(data);
}
@GetMapping("/pipeline")
@Operation(summary = "Get pipeline configuration data")
public ResponseEntity<PipelineData> getPipelineData() {
PipelineData data = new PipelineData();
List<String> pipelineConfigs = new ArrayList<>();
List<Map<String, String>> pipelineConfigsWithNames = new ArrayList<>();
if (new java.io.File(runtimePathConfig.getPipelineDefaultWebUiConfigs()).exists()) {
try (Stream<Path> paths =
Files.walk(Paths.get(runtimePathConfig.getPipelineDefaultWebUiConfigs()))) {
List<Path> jsonFiles =
paths.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".json"))
.toList();
for (Path jsonFile : jsonFiles) {
String content = Files.readString(jsonFile, StandardCharsets.UTF_8);
pipelineConfigs.add(content);
}
for (String config : pipelineConfigs) {
Map<String, Object> jsonContent =
new ObjectMapper()
.readValue(config, new TypeReference<Map<String, Object>>() {});
String name = (String) jsonContent.get("name");
if (name == null || name.length() < 1) {
String filename =
jsonFiles
.get(pipelineConfigs.indexOf(config))
.getFileName()
.toString();
name = filename.substring(0, filename.lastIndexOf('.'));
}
Map<String, String> configWithName = new HashMap<>();
configWithName.put("json", config);
configWithName.put("name", name);
pipelineConfigsWithNames.add(configWithName);
}
} catch (IOException e) {
log.error("Failed to load pipeline configs", e);
}
}
if (pipelineConfigsWithNames.isEmpty()) {
Map<String, String> configWithName = new HashMap<>();
configWithName.put("json", "");
configWithName.put("name", "No preloaded configs found");
pipelineConfigsWithNames.add(configWithName);
}
data.setPipelineConfigsWithNames(pipelineConfigsWithNames);
data.setPipelineConfigs(pipelineConfigs);
return ResponseEntity.ok(data);
}
@GetMapping("/sign")
@Operation(summary = "Get signature form data")
public ResponseEntity<SignData> getSignData() {
String username = "";
if (userService != null) {
username = userService.getCurrentUsername();
}
List<SignatureFile> signatures = signatureService.getAvailableSignatures(username);
List<FontResource> fonts = getFontNames();
SignData data = new SignData();
data.setSignatures(signatures);
data.setFonts(fonts);
return ResponseEntity.ok(data);
}
@GetMapping("/ocr-pdf")
@Operation(summary = "Get OCR PDF data")
public ResponseEntity<OcrData> getOcrPdfData() {
List<String> languages = getAvailableTesseractLanguages();
OcrData data = new OcrData();
data.setLanguages(languages);
return ResponseEntity.ok(data);
}
private List<String> getAvailableTesseractLanguages() {
String tessdataDir = applicationProperties.getSystem().getTessdataDir();
java.io.File[] files = new java.io.File(tessdataDir).listFiles();
if (files == null) {
return Collections.emptyList();
}
return Arrays.stream(files)
.filter(file -> file.getName().endsWith(".traineddata"))
.map(file -> file.getName().replace(".traineddata", ""))
.filter(lang -> !"osd".equalsIgnoreCase(lang))
.sorted()
.toList();
}
private List<FontResource> getFontNames() {
List<FontResource> fontNames = new ArrayList<>();
fontNames.addAll(getFontNamesFromLocation("classpath:static/fonts/*.woff2"));
fontNames.addAll(
getFontNamesFromLocation(
"file:"
+ InstallationPathConfig.getStaticPath()
+ "fonts"
+ java.io.File.separator
+ "*"));
return fontNames;
}
private List<FontResource> getFontNamesFromLocation(String locationPattern) {
try {
Resource[] resources =
GeneralUtils.getResourcesFromLocationPattern(locationPattern, resourceLoader);
return Arrays.stream(resources)
.map(
resource -> {
try {
String filename = resource.getFilename();
if (filename != null) {
int lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex != -1) {
String name = filename.substring(0, lastDotIndex);
String extension = filename.substring(lastDotIndex + 1);
return new FontResource(name, extension);
}
}
return null;
} catch (Exception e) {
throw ExceptionUtils.createRuntimeException(
"error.fontLoadingFailed",
"Error processing font file",
e);
}
})
.filter(Objects::nonNull)
.toList();
} catch (Exception e) {
throw ExceptionUtils.createRuntimeException(
"error.fontDirectoryReadFailed", "Failed to read font directory", e);
}
}
// Data classes
@Data
public static class HomeData {
private boolean showSurveyFromDocker;
}
@Data
public static class LicensesData {
private List<Dependency> dependencies;
}
@Data
public static class PipelineData {
private List<Map<String, String>> pipelineConfigsWithNames;
private List<String> pipelineConfigs;
}
@Data
public static class SignData {
private List<SignatureFile> signatures;
private List<FontResource> fonts;
}
@Data
public static class OcrData {
private List<String> languages;
}
@Data
public static class FontResource {
private String name;
private String extension;
private String type;
public FontResource(String name, String extension) {
this.name = name;
this.extension = extension;
this.type = getFormatFromExtension(extension);
}
private static String getFormatFromExtension(String extension) {
switch (extension) {
case "ttf":
return "truetype";
case "woff":
return "woff";
case "woff2":
return "woff2";
case "eot":
return "embedded-opentype";
case "svg":
return "svg";
default:
return "";
}
}
}
}
@@ -1,130 +0,0 @@
package stirling.software.SPDF.controller.api.misc;
import java.util.HashMap;
import java.util.Map;
import org.springframework.context.ApplicationContext;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
@RestController
@Tag(name = "Config", description = "Configuration APIs")
@RequestMapping("/api/v1/config")
@RequiredArgsConstructor
@Hidden
public class ConfigController {
private final ApplicationProperties applicationProperties;
private final ApplicationContext applicationContext;
private final EndpointConfiguration endpointConfiguration;
@GetMapping("/app-config")
public ResponseEntity<Map<String, Object>> getAppConfig() {
Map<String, Object> configData = new HashMap<>();
try {
// Get AppConfig bean
AppConfig appConfig = applicationContext.getBean(AppConfig.class);
// Extract key configuration values from AppConfig
configData.put("baseUrl", appConfig.getBaseUrl());
configData.put("contextPath", appConfig.getContextPath());
configData.put("serverPort", appConfig.getServerPort());
// Extract values from ApplicationProperties
configData.put("appName", applicationProperties.getUi().getAppName());
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
configData.put("homeDescription", applicationProperties.getUi().getHomeDescription());
configData.put("languages", applicationProperties.getUi().getLanguages());
// Security settings
configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin());
// System settings
configData.put(
"enableAlphaFunctionality",
applicationProperties.getSystem().getEnableAlphaFunctionality());
configData.put(
"enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
// Premium/Enterprise settings
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
// Legal settings
configData.put(
"termsAndConditions", applicationProperties.getLegal().getTermsAndConditions());
configData.put("privacyPolicy", applicationProperties.getLegal().getPrivacyPolicy());
configData.put("cookiePolicy", applicationProperties.getLegal().getCookiePolicy());
configData.put("impressum", applicationProperties.getLegal().getImpressum());
configData.put(
"accessibilityStatement",
applicationProperties.getLegal().getAccessibilityStatement());
// Try to get EEAppConfig values if available
try {
if (applicationContext.containsBean("runningProOrHigher")) {
configData.put(
"runningProOrHigher",
applicationContext.getBean("runningProOrHigher", Boolean.class));
}
if (applicationContext.containsBean("runningEE")) {
configData.put(
"runningEE", applicationContext.getBean("runningEE", Boolean.class));
}
if (applicationContext.containsBean("license")) {
configData.put("license", applicationContext.getBean("license", String.class));
}
if (applicationContext.containsBean("GoogleDriveEnabled")) {
configData.put(
"GoogleDriveEnabled",
applicationContext.getBean("GoogleDriveEnabled", Boolean.class));
}
if (applicationContext.containsBean("SSOAutoLogin")) {
configData.put(
"SSOAutoLogin",
applicationContext.getBean("SSOAutoLogin", Boolean.class));
}
} catch (Exception e) {
// EE features not available, continue without them
}
return ResponseEntity.ok(configData);
} catch (Exception e) {
// Return basic config if there are any issues
configData.put("error", "Unable to retrieve full configuration");
return ResponseEntity.ok(configData);
}
}
@GetMapping("/endpoint-enabled")
public ResponseEntity<Boolean> isEndpointEnabled(
@RequestParam(name = "endpoint") String endpoint) {
boolean enabled = endpointConfiguration.isEndpointEnabled(endpoint);
return ResponseEntity.ok(enabled);
}
@GetMapping("/endpoints-enabled")
public ResponseEntity<Map<String, Boolean>> areEndpointsEnabled(
@RequestParam(name = "endpoints") String endpoints) {
Map<String, Boolean> result = new HashMap<>();
String[] endpointArray = endpoints.split(",");
for (String endpoint : endpointArray) {
String trimmedEndpoint = endpoint.trim();
result.put(trimmedEndpoint, endpointConfiguration.isEndpointEnabled(trimmedEndpoint));
}
return ResponseEntity.ok(result);
}
}
@@ -1,17 +0,0 @@
package stirling.software.SPDF.controller.web;
import org.springframework.web.bind.annotation.GetMapping;
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
public class ReactRoutingController {
@GetMapping("/{path:^(?!api|static|robots\\.txt|favicon\\.ico)[^\\.]*$}")
public String forwardRootPaths() {
return "forward:/index.html";
}
@GetMapping("/{path:^(?!api|static)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public String forwardNestedPaths() {
return "forward:/index.html";
}
}
@@ -1,390 +0,0 @@
function compareVersions(version1, version2) {
const v1 = version1.split(".");
const v2 = version2.split(".");
for (let i = 0; i < v1.length || i < v2.length; i++) {
const n1 = parseInt(v1[i]) || 0;
const n2 = parseInt(v2[i]) || 0;
if (n1 > n2) {
return 1;
} else if (n1 < n2) {
return -1;
}
}
return 0;
}
function getDownloadUrl() {
// Only show download for non-Docker installations
if (machineType === 'Docker' || machineType === 'Kubernetes') {
return null;
}
const baseUrl = 'https://files.stirlingpdf.com/';
// Determine file based on machine type and security
if (machineType === 'Server-jar') {
return baseUrl + (activeSecurity ? 'Stirling-PDF-with-login.jar' : 'Stirling-PDF.jar');
}
// Client installations
if (machineType.startsWith('Client-')) {
const os = machineType.replace('Client-', ''); // win, mac, unix
const type = activeSecurity ? '-server-security' : '-server';
if (os === 'unix') {
return baseUrl + os + type + '.jar';
} else if (os === 'win') {
return baseUrl + os + '-installer.exe';
} else if (os === 'mac') {
return baseUrl + os + '-installer.dmg';
}
}
return null;
}
// Function to get translated priority text
function getTranslatedPriority(priority) {
switch(priority?.toLowerCase()) {
case 'urgent': return updatePriorityUrgent;
case 'normal': return updatePriorityNormal;
case 'minor': return updatePriorityMinor;
case 'low': return updatePriorityLow;
default: return priority?.toUpperCase() || 'NORMAL';
}
}
async function getUpdateSummary() {
// Map Java License enum to API types
let type = 'normal';
if (licenseType === 'PRO') {
type = 'pro';
} else if (licenseType === 'ENTERPRISE') {
type = 'enterprise';
}
const url = `https://supabase.stirling.com/functions/v1/updates?from=${currentVersion}&type=${type}&login=${activeSecurity}&summary=true`;
console.log("Fetching update summary from:", url);
try {
const response = await fetch(url);
console.log("Response status:", response.status);
if (response.status === 200) {
const data = await response.json();
return data;
} else {
console.error("Failed to fetch update summary from Supabase:", response.status);
return null;
}
} catch (error) {
console.error("Failed to fetch update summary from Supabase:", error);
return null;
}
}
async function getFullUpdateInfo() {
// Map Java License enum to API types
let type = 'normal';
if (licenseType === 'PRO') {
type = 'pro';
} else if (licenseType === 'ENTERPRISE') {
type = 'enterprise';
}
const url = `https://supabase.stirling.com/functions/v1/updates?from=${currentVersion}&type=${type}&login=${activeSecurity}&summary=false`;
console.log("Fetching full update info from:", url);
try {
const response = await fetch(url);
console.log("Full update response status:", response.status);
if (response.status === 200) {
const data = await response.json();
return data;
} else {
console.error("Failed to fetch full update info from Supabase:", response.status);
return null;
}
} catch (error) {
console.error("Failed to fetch full update info from Supabase:", error);
return null;
}
}
async function getCurrentVersionFromBypass() {
const url = "https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/master/build.gradle";
try {
const response = await fetch(url);
if (response.status === 200) {
const text = await response.text();
const versionRegex = /version\s*=\s*['"](\d+\.\d+\.\d+)['"]/;
const match = versionRegex.exec(text);
if (match) {
return match[1];
}
}
throw new Error("Version number not found");
} catch (error) {
console.error("Failed to fetch latest version from build.gradle:", error);
return ""; // Return an empty string if the fetch fails
}
}
async function checkForUpdate() {
// Initialize the update button as hidden
var updateBtn = document.getElementById("update-btn") || null;
var updateLink = document.getElementById("update-link") || null;
var updateLinkLegacy = document.getElementById("update-link-legacy") || null;
if (updateBtn !== null) {
updateBtn.style.display = "none";
updateBtn.classList.remove("btn-danger", "btn-warning", "btn-outline-primary");
}
if (updateLink !== null) {
updateLink.style.display = "none";
}
if (updateLinkLegacy !== null) {
console.log("hidden!");
if (!updateLinkLegacy.classList.contains("visually-hidden")) {
updateLinkLegacy.classList.add("visually-hidden");
}
}
const updateSummary = await getUpdateSummary();
if (!updateSummary) {
console.log("No update summary available");
return;
}
console.log("updateSummary=", updateSummary);
console.log("currentVersion=" + currentVersion);
console.log("latestVersion=" + updateSummary.latest_version);
if (updateSummary.latest_version && compareVersions(updateSummary.latest_version, currentVersion) > 0) {
const priority = updateSummary.max_priority || 'normal';
if (updateBtn != null) {
// Style button based on priority
if (priority === 'urgent') {
updateBtn.classList.add("btn-danger");
updateBtn.innerHTML = urgentUpdateAvailable;
} else if (priority === 'normal') {
updateBtn.classList.add("btn-warning");
updateBtn.innerHTML = updateAvailableText;
} else {
updateBtn.classList.add("btn-outline-primary");
updateBtn.innerHTML = updateAvailableText;
}
// Store summary for initial display
updateBtn.setAttribute('data-update-summary', JSON.stringify(updateSummary));
updateBtn.style.display = "block";
// Add click handler for update details modal
updateBtn.onclick = function(e) {
e.preventDefault();
showUpdateModal();
};
}
if (updateLink !== null) {
document.getElementById("update-link").style.display = "flex";
}
if (updateLinkLegacy !== null) {
document.getElementById("app-update").innerHTML = updateAvailable.replace("{0}", '<b>' + currentVersion + '</b>').replace("{1}", '<b>' + updateSummary.latest_version + '</b>');
if (updateLinkLegacy.classList.contains("visually-hidden")) {
updateLinkLegacy.classList.remove("visually-hidden");
}
}
console.log("visible");
} else {
if (updateLinkLegacy !== null) {
if (!updateLinkLegacy.classList.contains("visually-hidden")) {
updateLinkLegacy.classList.add("visually-hidden");
}
}
console.log("hidden");
}
}
async function showUpdateModal() {
// Close settings modal if open
const settingsModal = bootstrap.Modal.getInstance(document.getElementById('settingsModal'));
if (settingsModal) {
settingsModal.hide();
}
// Get summary data from button
const updateBtn = document.getElementById("update-btn");
const summaryData = JSON.parse(updateBtn.getAttribute('data-update-summary'));
// Utility function to escape HTML special characters
function escapeHtml(str) {
if (typeof str !== 'string') return str;
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/\//g, '&#x2F;');
}
// Create initial modal with loading state
const initialModalHtml = `
<div class="modal fade" id="updateModal" tabindex="-1" role="dialog" aria-labelledby="updateModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" role="document" style="max-height: 80vh;">
<div class="modal-content" style="max-height: 80vh;">
<div class="modal-header">
<h5 class="modal-title" id="updateModalLabel">${updateModalTitle}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div class="modal-body" id="updateModalBody" style="max-height: 60vh; overflow-y: auto;">
<div class="update-summary mb-4">
<div class="row mb-3">
<div class="${summaryData.latest_stable_version ? 'col-4' : 'col-6'} text-center">
<small class="text-muted">${updateCurrent}</small><br>
<strong>${escapeHtml(currentVersion)}</strong>
</div>
<div class="${summaryData.latest_stable_version ? 'col-4' : 'col-6'} text-center">
<small class="text-muted">${updateLatest}</small><br>
<strong class="text-primary">${escapeHtml(summaryData.latest_version)}</strong>
</div>
${summaryData.latest_stable_version ? `
<div class="col-4 text-center">
<small class="text-muted">${updateLatestStable}</small><br>
<strong class="text-success">${escapeHtml(summaryData.latest_stable_version)}</strong>
</div>
` : ''}
</div>
<div class="alert ${summaryData.max_priority === 'urgent' ? 'alert-danger' : 'alert-warning'}" role="alert">
<strong>${updatePriority}:</strong> ${getTranslatedPriority(summaryData.max_priority)}
${summaryData.recommended_action ? `<br><strong>${updateRecommendedAction}:</strong> ${escapeHtml(summaryData.recommended_action)}` : ''}
</div>
</div>
${summaryData.any_breaking ? `
<div class="alert alert-warning" role="alert">
<h6><strong>${updateBreakingChangesDetected}</strong></h6>
<p>${updateBreakingChangesMessage}</p>
</div>
` : ''}
${summaryData.migration_guides && summaryData.migration_guides.length > 0 ? `
<div class="migration-guides mb-4">
<h6>${updateMigrationGuides}</h6>
<ul class="list-group">
${summaryData.migration_guides.map(guide => `
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>${updateVersion} ${escapeHtml(guide.version)}:</strong> ${escapeHtml(guide.notes)}
</div>
<a href="${escapeHtml(guide.url)}" target="_blank" class="btn btn-sm btn-outline-primary">${updateViewGuide}</a>
</li>
`).join('')}
</ul>
</div>
` : ''}
<div class="text-center">
<div class="spinner-border text-primary" role="status" id="loadingSpinner">
<span class="visually-hidden">${updateLoadingDetailedInfo}</span>
</div>
<p class="mt-2">${updateLoadingDetailedInfo}</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">${updateClose}</button>
<a href="https://github.com/Stirling-Tools/Stirling-PDF/releases" target="_blank" class="btn btn-outline-primary">${updateViewAllReleases}</a>
${getDownloadUrl() ? `<a href="${escapeHtml(getDownloadUrl())}" class="btn btn-success" target="_blank">${updateDownloadLatest}</a>` : ''}
</div>
</div>
</div>
</div>
`;
// Remove existing modal if present
const existingModal = document.getElementById('updateModal');
if (existingModal) {
existingModal.remove();
}
// Add modal to body
document.body.insertAdjacentHTML('beforeend', initialModalHtml);
// Show modal
const modal = new bootstrap.Modal(document.getElementById('updateModal'));
modal.show();
// Fetch full update info
const fullUpdateInfo = await getFullUpdateInfo();
// Update modal with full information
const modalBody = document.getElementById('updateModalBody');
if (fullUpdateInfo && fullUpdateInfo.new_versions) {
const storedMode = localStorage.getItem("dark-mode");
const isDarkMode = storedMode === "on" ||
(storedMode === null && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
const darkClasses = isDarkMode ? {
accordionItem: 'bg-dark border-secondary text-light',
accordionButton: 'bg-dark text-light border-secondary',
accordionBody: 'bg-dark text-light'
} : {
accordionItem: '',
accordionButton: '',
accordionBody: ''
};
const detailedVersionsHtml = `
<div class="detailed-versions mt-4">
<h6>${updateAvailableUpdates}</h6>
<div class="accordion" id="versionsAccordion">
${fullUpdateInfo.new_versions.map((version, index) => `
<div class="accordion-item" style="border-color: var(--md-sys-color-outline);">
<h2 class="accordion-header" id="heading${index}">
<button class="accordion-button ${index === 0 ? '' : 'collapsed'}" style="color: var(--md-sys-color-on-surface); background-color:
var(--md-sys-color-surface);" type="button" data-bs-toggle="collapse"
data-bs-target="#collapse${index}" aria-expanded="${index === 0 ? 'true' : 'false'}" aria-controls="collapse${index}">
<div class="d-flex justify-content-between w-100 me-3">
<span><strong>${updateVersion} ${version.version}</strong></span>
<span class="badge ${version.priority === 'urgent' ? 'bg-danger' : version.priority === 'normal' ? 'bg-warning' : 'bg-secondary'}">${getTranslatedPriority(version.priority)}</span>
</div>
</button>
</h2>
<div id="collapse${index}" class="accordion-collapse collapse ${index === 0 ? 'show' : ''}"
aria-labelledby="heading${index}" data-bs-parent="#versionsAccordion">
<div class="accordion-body" style="color: var(--md-sys-color-on-surface); background-color:
var(--md-sys-color-surface-bright);">
<h6>${version.announcement.title}</h6>
<p>${version.announcement.message}</p>
${version.compatibility.breaking_changes ? `
<div class="alert alert-warning alert-sm" role="alert">
<small><strong>⚠️ ${updateBreakingChanges}</strong> ${version.compatibility.breaking_description || updateBreakingChangesDefault}</small>
${version.compatibility.migration_guide_url ? `<br><a href="${version.compatibility.migration_guide_url}" target="_blank" class="btn btn-sm btn-outline-warning mt-1">${updateMigrationGuide}</a>` : ''}
</div>
` : ''}
</div>
</div>
</div>
`).join('')}
</div>
</div>
`;
// Remove loading spinner and add detailed info
const spinner = document.getElementById('loadingSpinner');
if (spinner) {
spinner.parentElement.remove();
}
modalBody.insertAdjacentHTML('beforeend', detailedVersionsHtml);
} else {
// Remove loading spinner if failed to load
const spinner = document.getElementById('loadingSpinner');
if (spinner) {
spinner.parentElement.innerHTML = `<p class="text-muted">${updateUnableToLoadDetails}</p>`;
}
}
}
document.addEventListener("DOMContentLoaded", (event) => {
checkForUpdate();
});
@@ -1,818 +0,0 @@
document.addEventListener("DOMContentLoaded", function () {
const bookmarksContainer = document.getElementById("bookmarks-container");
const errorMessageContainer = document.getElementById("error-message-container");
const addBookmarkBtn = document.getElementById("addBookmarkBtn");
const bookmarkDataInput = document.getElementById("bookmarkData");
let bookmarks = [];
let counter = 0; // Used for generating unique IDs
// callback function on file input change to extract bookmarks from PDF
async function getBookmarkDataFromPdf(event) {
if (!event.target.files || event.target.files.length === 0) {
return;
}
const formData = new FormData();
formData.append("file", event.target.files[0]);
try {
// Call the API to extract bookmarks using fetchWithCsrf for CSRF protection
const response = await fetchWithCsrf("/api/v1/general/extract-bookmarks", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`Failed to fetch API: ${response.status} ${response.statusText}`);
}
const extractedBookmarks = await response.json();
return extractedBookmarks;
} catch (error) {
throw new Error("Error extracting bookmark data:", error);
}
}
// callback function on file input change to extract bookmarks from JSON
async function getBookmarkDataFromJson(event) {
if (!event.target.files || event.target.files.length === 0) {
return;
}
const file = event.target.files[0];
try {
const fileText = await file.text();
const jsonData = JSON.parse(fileText);
return jsonData;
} catch (error) {
throw new Error(`Error extracting bookmark data: error while reading or parsing JSON file: ${error.message}`);
}
}
// display new bookmark data given by a callback function that loads or fetches the data
async function loadBookmarks(getBookmarkDataCallback) {
// reset bookmarks
bookmarks = [];
updateBookmarksUI();
showLoadingIndicator();
try {
// Get new bookmarks from the callback
const newBookmarks = await getBookmarkDataCallback();
// Convert extracted bookmarks to our format with IDs
if (newBookmarks && newBookmarks.length > 0) {
bookmarks = newBookmarks.map(convertExtractedBookmark);
}
} catch (error) {
bookmarks = [];
throw new Error(`Error loading bookmarks: ${error}`);
} finally {
removeLoadingIndicator();
updateBookmarksUI();
}
}
// Add event listener to the file input to extract existing bookmarks
document.getElementById("fileInput-input").addEventListener("change", async function (event) {
try {
await loadBookmarks(async function () {
return getBookmarkDataFromPdf(event);
});
} catch {
showErrorMessage("Failed to extract bookmarks. You can still create new ones.");
}
});
function showLoadingIndicator() {
const loadingEl = document.createElement("div");
loadingEl.className = "alert alert-info";
loadingEl.textContent = "Loading bookmarks from PDF...";
loadingEl.id = "loading-bookmarks";
errorMessageContainer.innerHTML = "";
bookmarksContainer.innerHTML = "";
bookmarksContainer.appendChild(loadingEl);
}
function removeLoadingIndicator() {
const loadingEl = document.getElementById("loading-bookmarks");
if (loadingEl) {
loadingEl.remove();
}
}
function showErrorMessage(message) {
const errorEl = document.createElement("div");
errorEl.className = "alert alert-danger";
errorEl.textContent = message;
errorMessageContainer.appendChild(errorEl);
}
function showEmptyState() {
const emptyStateEl = document.createElement("div");
emptyStateEl.className = "empty-bookmarks mb-3";
emptyStateEl.innerHTML = `
<span class="material-symbols-rounded mb-2" style="font-size: 48px;">bookmark_add</span>
<h5>No bookmarks found</h5>
<p class="mb-3">This PDF doesn't have any bookmarks yet. Add your first bookmark to get started.</p>
<button type="button" class="btn btn-primary btn-add-first-bookmark">
<span class="material-symbols-rounded">add</span> Add First Bookmark
</button>
`;
// Add event listener to the "Add First Bookmark" button
emptyStateEl.querySelector(".btn-add-first-bookmark").addEventListener("click", function () {
addBookmark(null, "New Bookmark", 1);
emptyStateEl.remove();
});
bookmarksContainer.appendChild(emptyStateEl);
}
// Function to convert extracted bookmarks to our format with IDs
function convertExtractedBookmark(bookmark) {
counter++;
const result = {
id: Date.now() + counter, // Generate a unique ID
title: bookmark.title || "Untitled Bookmark",
pageNumber: bookmark.pageNumber || 1,
children: [],
expanded: false, // All bookmarks start collapsed for better visibility
};
// Convert children recursively
if (bookmark.children && bookmark.children.length > 0) {
result.children = bookmark.children.map((child) => {
return convertExtractedBookmark(child);
});
}
return result;
}
// Add bookmark button click handler
addBookmarkBtn.addEventListener("click", function (e) {
e.preventDefault();
addBookmark();
});
// Add form submit handler to update JSON data
document.getElementById("editTocForm").addEventListener("submit", function () {
updateBookmarkData();
});
function addBookmark(parent = null, title = "", pageNumber = 1) {
counter++;
const newBookmark = {
id: Date.now() + counter,
title: title || "New Bookmark",
pageNumber: pageNumber || 1,
children: [],
expanded: false, // New bookmarks start collapsed
};
if (parent === null) {
bookmarks.push(newBookmark);
} else {
const parentBookmark = findBookmark(bookmarks, parent);
if (parentBookmark) {
parentBookmark.children.push(newBookmark);
parentBookmark.expanded = true; // Auto-expand the parent when adding a child
} else {
// Add to root level if parent not found
bookmarks.push(newBookmark);
}
}
updateBookmarksUI();
// After updating UI, find and focus the new bookmark's title field
setTimeout(() => {
const newElement = document.querySelector(`[data-id="${newBookmark.id}"]`);
if (newElement) {
const titleInput = newElement.querySelector(".bookmark-title");
if (titleInput) {
titleInput.focus();
titleInput.select();
}
// Scroll to the new element
newElement.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, 50);
}
function findBookmark(bookmarkArray, id) {
for (const bookmark of bookmarkArray) {
if (bookmark.id === id) {
return bookmark;
}
if (bookmark.children.length > 0) {
const found = findBookmark(bookmark.children, id);
if (found) return found;
}
}
return null;
}
// Find the parent bookmark of a given bookmark by ID
function findParentBookmark(bookmarkArray, id, parent = null) {
for (const bookmark of bookmarkArray) {
if (bookmark.id === id) {
return parent; // Return the parent ID (or null if top-level)
}
if (bookmark.children.length > 0) {
const found = findParentBookmark(bookmark.children, id, bookmark.id);
if (found !== undefined) return found;
}
}
return undefined; // Not found at this level
}
function removeBookmark(id) {
// Remove from top level
const index = bookmarks.findIndex((b) => b.id === id);
if (index !== -1) {
bookmarks.splice(index, 1);
updateBookmarksUI();
return;
}
// Remove from children
function removeFromChildren(bookmarkArray, id) {
for (const bookmark of bookmarkArray) {
const childIndex = bookmark.children.findIndex((b) => b.id === id);
if (childIndex !== -1) {
bookmark.children.splice(childIndex, 1);
return true;
}
if (removeFromChildren(bookmark.children, id)) {
return true;
}
}
return false;
}
if (removeFromChildren(bookmarks, id)) {
updateBookmarksUI();
}
// If no bookmarks left, show empty state
if (bookmarks.length === 0) {
showEmptyState();
}
}
function toggleBookmarkExpanded(id) {
const bookmark = findBookmark(bookmarks, id);
if (bookmark) {
bookmark.expanded = !bookmark.expanded;
updateBookmarksUI();
}
}
function updateBookmarkData() {
// Create a clean version without the IDs for submission
const cleanBookmarks = bookmarks.map(cleanBookmark);
bookmarkDataInput.value = JSON.stringify(cleanBookmarks);
}
function cleanBookmark(bookmark) {
return {
title: bookmark.title,
pageNumber: bookmark.pageNumber,
children: bookmark.children.map(cleanBookmark),
};
}
function updateBookmarksUI() {
if (!bookmarksContainer) {
return;
}
// Only clear the container if there are no error messages or loading indicators
if (!document.querySelector("#bookmarks-container .alert")) {
bookmarksContainer.innerHTML = "";
}
// Check if there are bookmarks to display
if (bookmarks.length === 0 && !document.querySelector(".empty-bookmarks")) {
showEmptyState();
} else {
// Remove empty state if it exists and there are bookmarks
const emptyState = document.querySelector(".empty-bookmarks");
if (emptyState && bookmarks.length > 0) {
emptyState.remove();
}
// Create bookmark elements
bookmarks.forEach((bookmark) => {
const bookmarkElement = createBookmarkElement(bookmark);
bookmarksContainer.appendChild(bookmarkElement);
});
}
updateBookmarkData();
// Initialize tooltips for dynamically added elements
if (typeof $ !== "undefined") {
$('[data-bs-toggle="tooltip"]').tooltip();
}
}
// Create the main bookmark element with collapsible interface
function createBookmarkElement(bookmark, level = 0) {
const bookmarkEl = document.createElement("div");
bookmarkEl.className = "bookmark-item";
bookmarkEl.dataset.id = bookmark.id;
bookmarkEl.dataset.level = level;
// Create the header (always visible part)
const header = createBookmarkHeader(bookmark, level);
bookmarkEl.appendChild(header);
// Create the content (collapsible part)
const content = document.createElement("div");
content.className = "bookmark-content";
if (!bookmark.expanded) {
content.style.display = "none";
}
// Main input row
const inputRow = createInputRow(bookmark);
content.appendChild(inputRow);
bookmarkEl.appendChild(content);
// Add children container if has children and expanded
if (bookmark.children && bookmark.children.length > 0) {
const childrenContainer = createChildrenContainer(bookmark, level);
if (bookmark.expanded) {
bookmarkEl.appendChild(childrenContainer);
}
}
return bookmarkEl;
}
// Create the header that's always visible
function createBookmarkHeader(bookmark, level) {
const header = document.createElement("div");
header.className = "bookmark-header";
if (!bookmark.expanded) {
header.classList.add("collapsed");
}
// Left side of header with expand/collapse and info
const headerLeft = document.createElement("div");
headerLeft.className = "d-flex align-items-center";
// Toggle expand/collapse icon with child count
const toggleContainer = document.createElement("div");
toggleContainer.className = "d-flex align-items-center";
toggleContainer.style.marginRight = "8px";
// Only show toggle if has children
if (bookmark.children && bookmark.children.length > 0) {
// Create toggle icon
const toggleIcon = document.createElement("span");
toggleIcon.className = "material-symbols-rounded toggle-icon me-1";
toggleIcon.textContent = "expand_more";
toggleIcon.style.cursor = "pointer";
toggleContainer.appendChild(toggleIcon);
// Add child count indicator
const childCount = document.createElement("span");
childCount.className = "badge rounded-pill";
// Use theme-appropriate badge color
const isDarkMode = document.documentElement.getAttribute("data-bs-theme") === "dark";
childCount.classList.add(isDarkMode ? "bg-info" : "bg-secondary");
childCount.style.fontSize = "0.7rem";
childCount.style.padding = "0.2em 0.5em";
childCount.textContent = bookmark.children.length;
childCount.setAttribute("data-bs-toggle", "tooltip");
childCount.setAttribute("data-bs-placement", "top");
childCount.title = `${bookmark.children.length} child bookmark${bookmark.children.length > 1 ? "s" : ""}`;
toggleContainer.appendChild(childCount);
} else {
// Add spacer if no children
const spacer = document.createElement("span");
spacer.style.width = "24px";
spacer.style.display = "inline-block";
toggleContainer.appendChild(spacer);
}
headerLeft.appendChild(toggleContainer);
// Level indicator for nested items
if (level > 0) {
// Add relationship indicator visual line
const relationshipIndicator = document.createElement("div");
relationshipIndicator.className = "bookmark-relationship-indicator";
const line = document.createElement("div");
line.className = "relationship-line";
relationshipIndicator.appendChild(line);
const arrow = document.createElement("div");
arrow.className = "relationship-arrow";
relationshipIndicator.appendChild(arrow);
header.appendChild(relationshipIndicator);
// Text indicator
const levelIndicator = document.createElement("span");
levelIndicator.className = "bookmark-level-indicator";
levelIndicator.textContent = `Child`;
headerLeft.appendChild(levelIndicator);
}
// Title preview
const titlePreview = document.createElement("span");
titlePreview.className = "bookmark-title-preview";
titlePreview.textContent = bookmark.title;
headerLeft.appendChild(titlePreview);
// Page number preview
const pagePreview = document.createElement("span");
pagePreview.className = "bookmark-page-preview";
pagePreview.textContent = `Page ${bookmark.pageNumber}`;
headerLeft.appendChild(pagePreview);
// Right side of header with action buttons
const headerRight = document.createElement("div");
headerRight.className = "bookmark-actions-header";
// Quick add buttons with clear visual distinction - using Stirling-PDF's tooltip system
const quickAddChildButton = createButton("subdirectory_arrow_right", "btn-add-child", "Add child bookmark", function (e) {
e.preventDefault();
e.stopPropagation();
addBookmark(bookmark.id);
});
const quickAddSiblingButton = createButton("add", "btn-add-sibling", "Add sibling bookmark", function (e) {
e.preventDefault();
e.stopPropagation();
// Find parent of current bookmark
const parentId = findParentBookmark(bookmarks, bookmark.id);
addBookmark(parentId, "", bookmark.pageNumber); // Same level as current bookmark
});
// Quick remove button
const quickRemoveButton = createButton("delete", "btn-outline-danger", "Remove bookmark", function (e) {
e.preventDefault();
e.stopPropagation();
if (
confirm(
"Are you sure you want to remove this bookmark" + (bookmark.children.length > 0 ? " and all its children?" : "?")
)
) {
removeBookmark(bookmark.id);
}
});
headerRight.appendChild(quickAddChildButton);
headerRight.appendChild(quickAddSiblingButton);
headerRight.appendChild(quickRemoveButton);
// Assemble header
header.appendChild(headerLeft);
header.appendChild(headerRight);
// Add click handler for expansion toggle
header.addEventListener("click", function (e) {
// Only toggle if not clicking on buttons
if (!e.target.closest("button")) {
toggleBookmarkExpanded(bookmark.id);
}
});
return header;
}
function createInputRow(bookmark) {
const row = document.createElement("div");
row.className = "row";
// Title input
row.appendChild(createTitleInputElement(bookmark));
// Page input
row.appendChild(createPageInputElement(bookmark));
return row;
}
function createTitleInputElement(bookmark) {
const titleCol = document.createElement("div");
titleCol.className = "col-md-8";
const titleGroup = document.createElement("div");
titleGroup.className = "mb-3";
const titleLabel = document.createElement("label");
titleLabel.textContent = "Title";
titleLabel.className = "form-label";
const titleInput = document.createElement("input");
titleInput.type = "text";
titleInput.className = "form-control bookmark-title";
titleInput.value = bookmark.title;
titleInput.addEventListener("input", function () {
bookmark.title = this.value;
updateBookmarkData();
// Also update the preview in the header
const header = titleInput.closest(".bookmark-item").querySelector(".bookmark-title-preview");
if (header) {
header.textContent = this.value;
}
});
titleGroup.appendChild(titleLabel);
titleGroup.appendChild(titleInput);
titleCol.appendChild(titleGroup);
return titleCol;
}
function createPageInputElement(bookmark) {
const pageCol = document.createElement("div");
pageCol.className = "col-md-4";
const pageGroup = document.createElement("div");
pageGroup.className = "mb-3";
const pageLabel = document.createElement("label");
pageLabel.textContent = "Page";
pageLabel.className = "form-label";
const pageInput = document.createElement("input");
pageInput.type = "number";
pageInput.className = "form-control bookmark-page";
pageInput.value = bookmark.pageNumber;
pageInput.min = 1;
pageInput.addEventListener("input", function () {
bookmark.pageNumber = parseInt(this.value) || 1;
updateBookmarkData();
// Also update the preview in the header
const header = pageInput.closest(".bookmark-item").querySelector(".bookmark-page-preview");
if (header) {
header.textContent = `Page ${bookmark.pageNumber}`;
}
});
pageGroup.appendChild(pageLabel);
pageGroup.appendChild(pageInput);
pageCol.appendChild(pageGroup);
return pageCol;
}
function createButton(icon, className, title, clickHandler) {
const button = document.createElement("button");
button.type = "button";
button.className = `btn ${className} btn-bookmark-action`;
button.innerHTML = `<span class="material-symbols-rounded">${icon}</span>`;
// Use Bootstrap tooltips
button.setAttribute("data-bs-toggle", "tooltip");
button.setAttribute("data-bs-placement", "top");
button.title = title;
button.addEventListener("click", clickHandler);
return button;
}
function createChildrenContainer(bookmark, level) {
const childrenContainer = document.createElement("div");
childrenContainer.className = "bookmark-children";
bookmark.children.forEach((child) => {
childrenContainer.appendChild(createBookmarkElement(child, level + 1));
});
return childrenContainer;
}
// Update the add bookmark button appearance with clear visual cue
addBookmarkBtn.innerHTML = '<span class="material-symbols-rounded">add</span> Add Top-level Bookmark';
addBookmarkBtn.className = "btn btn-primary btn-add-bookmark top-level";
// Use Bootstrap tooltips
addBookmarkBtn.setAttribute("data-bs-toggle", "tooltip");
addBookmarkBtn.setAttribute("data-bs-placement", "top");
addBookmarkBtn.title = "Add a new top-level bookmark";
// Add icon to empty state button as well
const updateEmptyStateButton = function () {
const emptyStateBtn = document.querySelector(".btn-add-first-bookmark");
if (emptyStateBtn) {
emptyStateBtn.innerHTML = '<span class="material-symbols-rounded">add</span> Add First Bookmark';
emptyStateBtn.setAttribute("data-bs-toggle", "tooltip");
emptyStateBtn.setAttribute("data-bs-placement", "top");
emptyStateBtn.title = "Add first bookmark";
// Initialize tooltips for the empty state button
if (typeof $ !== "undefined") {
$('[data-bs-toggle="tooltip"]').tooltip();
}
}
};
// Initialize with an empty state if no bookmarks
if (bookmarks.length === 0) {
showEmptyState();
updateEmptyStateButton();
}
// Add bookmarks Import/Export functionality
// Import/Export button references
const importDefaultBtn = document.getElementById("importDefaultBtn");
const exportDefaultBtn = document.getElementById("exportDefaultBtn");
const importUploadJsonFileInput = document.getElementById("importUploadJsonFileInput");
const importPasteFromClipboardBtn = document.getElementById("importPasteFromClipboardBtn");
const exportDownloadJsonFileBtn = document.getElementById("exportDownloadJsonFileBtn");
const exportCopyToClipboardBtn = document.getElementById("exportCopyToClipboardBtn");
// display import/export from/to clipboard buttons if supported
if (navigator.clipboard && navigator.clipboard.readText) {
importPasteFromClipboardBtn.parentElement.classList.remove("d-none");
}
if (navigator.clipboard && navigator.clipboard.writeText) {
exportCopyToClipboardBtn.parentElement.classList.remove("d-none");
}
function flashButtonSuccess(button) {
const originalClass = button.className;
button.classList.remove("btn-outline-primary");
button.classList.add("btn-success", "success-flash");
setTimeout(() => {
button.className = originalClass;
}, 1000);
}
// Import handlers
async function handleJsonFileInputChange(event) {
try {
await loadBookmarks(async function () {
return getBookmarkDataFromJson(event);
});
flashButtonSuccess(importDefaultBtn);
} catch (error) {
console.error(`Failed to import bookmarks from JSON file: ${error.message}`);
}
}
async function importBookmarksFromClipboard() {
console.log("Importing bookmarks from clipboard...");
try {
await loadBookmarks(async function () {
const clipboardText = await navigator.clipboard.readText();
if (!clipboardText) return [];
return JSON.parse(clipboardText);
});
flashButtonSuccess(importDefaultBtn);
} catch (error) {
console.error(`Failed to import bookmarks from clipboard: ${error.message}`);
}
}
async function handleBookmarksPasteFromClipboard(event) {
// do not override normal paste behavior on input fields
if (event.target.tagName.toLowerCase() === "input") return;
try {
await loadBookmarks(async function () {
const clipboardText = event.clipboardData?.getData("text/plain");
if (!clipboardText) return [];
return JSON.parse(clipboardText);
});
flashButtonSuccess(importDefaultBtn);
} catch (error) {
console.error(`Failed to import bookmarks from clipboard (ctrl-v): ${error.message}`);
}
}
// Export handlers
async function exportBookmarksToJson() {
console.log("Exporting bookmarks to JSON...");
try {
const bookmarkData = bookmarkDataInput.value;
const blob = new Blob([bookmarkData], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "bookmarks.json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
flashButtonSuccess(exportDefaultBtn);
} catch (error) {
console.error(`Failed to export bookmarks to JSON: ${error.message}`);
}
}
async function exportBookmarksToClipboard() {
const bookmarkData = bookmarkDataInput.value;
try {
await navigator.clipboard.writeText(bookmarkData);
flashButtonSuccess(exportDefaultBtn);
} catch (error) {
console.error(`Failed to export bookmarks to clipboard: ${error.message}`);
}
}
async function handleBookmarksCopyToClipboard(event) {
// do not override normal copy behavior on input fields
if (event.target.tagName.toLowerCase() === "input") return;
const bookmarkData = bookmarkDataInput.value;
try {
event.clipboardData.setData("text/plain", bookmarkData);
event.preventDefault();
flashButtonSuccess(exportDefaultBtn);
} catch (error) {
console.error(`Failed to export bookmarks to clipboard (ctrl-c): ${error.message}`);
}
}
// register event listeners for import/export functions
importUploadJsonFileInput.addEventListener("change", handleJsonFileInputChange);
importPasteFromClipboardBtn.addEventListener("click", importBookmarksFromClipboard);
exportDownloadJsonFileBtn.addEventListener("click", exportBookmarksToJson);
exportCopyToClipboardBtn.addEventListener("click", exportBookmarksToClipboard);
document.body.addEventListener("copy", handleBookmarksCopyToClipboard);
document.body.addEventListener("paste", handleBookmarksPasteFromClipboard);
// set default actions
// importDefaultBtn is already handled by being a label for the file input
exportDefaultBtn.addEventListener("click", exportBookmarksToJson);
// Listen for theme changes to update badge colors
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (mutation.attributeName === "data-bs-theme") {
const isDarkMode = document.documentElement.getAttribute("data-bs-theme") === "dark";
document.querySelectorAll(".badge").forEach((badge) => {
badge.classList.remove("bg-secondary", "bg-info");
badge.classList.add(isDarkMode ? "bg-info" : "bg-secondary");
});
}
});
});
observer.observe(document.documentElement, { attributes: true });
// Add visual enhancement to clearly show the top-level/child relationship
document.addEventListener("mouseover", function (e) {
// When hovering over add buttons, highlight their relationship targets
const button = e.target.closest(".btn-add-child, .btn-add-sibling");
if (button) {
if (button.classList.contains("btn-add-child")) {
// Highlight parent-child relationship
const bookmarkItem = button.closest(".bookmark-item");
if (bookmarkItem) {
bookmarkItem.style.boxShadow = "0 0 0 2px var(--btn-add-child-border, #198754)";
}
} else if (button.classList.contains("btn-add-sibling")) {
// Highlight sibling relationship
const bookmarkItem = button.closest(".bookmark-item");
if (bookmarkItem) {
// Find siblings
const parent = bookmarkItem.parentElement;
const siblings = parent.querySelectorAll(":scope > .bookmark-item");
siblings.forEach((sibling) => {
if (sibling !== bookmarkItem) {
sibling.style.boxShadow = "0 0 0 2px var(--btn-add-sibling-border, #0d6efd)";
}
});
}
}
}
});
document.addEventListener("mouseout", function (e) {
// Remove highlights when not hovering
const button = e.target.closest(".btn-add-child, .btn-add-sibling");
if (button) {
// Remove all highlights
document.querySelectorAll(".bookmark-item").forEach((item) => {
item.style.boxShadow = "";
});
}
});
});
@@ -1,143 +0,0 @@
window.onload = function () {
var items = document.querySelectorAll(".dropdown-item, .nav-link");
var dummyContainer = document.createElement("div");
dummyContainer.style.position = "absolute";
dummyContainer.style.visibility = "hidden";
dummyContainer.style.whiteSpace = "nowrap"; // Ensure we measure full width
document.body.appendChild(dummyContainer);
var maxWidth = 0;
items.forEach(function (item) {
var clone = item.cloneNode(true);
dummyContainer.appendChild(clone);
var width = clone.offsetWidth;
if (width > maxWidth) {
maxWidth = width;
}
dummyContainer.removeChild(clone);
});
document.body.removeChild(dummyContainer);
// Store max width for later use
window.navItemMaxWidth = maxWidth;
};
document.querySelector("#navbarSearchInput").addEventListener("input", function (e) {
var searchText = e.target.value.trim().toLowerCase();
var items = document.querySelectorAll("a.dropdown-item[data-bs-tags]");
var resultsBox = document.querySelector("#searchResults");
resultsBox.innerHTML = "";
if (searchText !== "") {
var addedResults = new Set();
items.forEach(function (item) {
var titleElement = item.querySelector(".icon-text");
var iconElement = item.querySelector(".material-symbols-rounded, .icon");
var itemHref = item.getAttribute("href");
var tags = item.getAttribute("data-bs-tags") || "";
if (titleElement && iconElement && itemHref !== "#") {
var title = titleElement.innerText.trim();
if (
(title.toLowerCase().includes(searchText) || tags.toLowerCase().includes(searchText)) &&
!addedResults.has(itemHref)
) {
var dropdownItem = document.createElement("div");
dropdownItem.className = "dropdown-item d-flex justify-content-between align-items-center";
var contentWrapper = document.createElement("div");
contentWrapper.className = "d-flex align-items-center flex-grow-1";
contentWrapper.style.textDecoration = "none";
contentWrapper.style.color = "inherit";
var divElement = item.querySelector("div");
if (divElement) {
var originalContent = divElement.cloneNode(true);
contentWrapper.appendChild(originalContent);
} else {
// Fallback: create content manually if div is not found
var fallbackContent = document.createElement("div");
fallbackContent.innerHTML = item.innerHTML;
contentWrapper.appendChild(fallbackContent);
}
contentWrapper.onclick = function () {
window.location.href = itemHref;
};
dropdownItem.appendChild(contentWrapper);
resultsBox.appendChild(dropdownItem);
addedResults.add(itemHref);
}
}
});
}
resultsBox.style.width = window.navItemMaxWidth + "px";
});
document.addEventListener('DOMContentLoaded', function () {
const searchDropdown = document.getElementById('searchDropdown');
const searchInput = document.getElementById('navbarSearchInput');
// Check if elements are missing and skip initialization if necessary
if (!searchDropdown || !searchInput) {
console.warn('Search dropdown or input not found. Skipping initialization.');
return;
}
const dropdownMenu = searchDropdown.querySelector('.dropdown-menu');
if (!dropdownMenu) {
console.warn('Dropdown menu not found within the search dropdown. Skipping initialization.');
return;
}
// Create a single dropdown instance
const dropdownInstance = new bootstrap.Dropdown(searchDropdown);
// Handle click for mobile
searchDropdown.addEventListener('click', function (e) {
e.preventDefault();
const isOpen = dropdownMenu.classList.contains('show');
// Close all other open dropdowns
document.querySelectorAll('.navbar-nav .dropdown-menu.show').forEach((menu) => {
if (menu !== dropdownMenu) {
const parentDropdown = menu.closest('.dropdown');
if (parentDropdown) {
const parentToggle = parentDropdown.querySelector('[data-bs-toggle="dropdown"]');
if (parentToggle) {
let instance = bootstrap.Dropdown.getInstance(parentToggle);
if (!instance) {
instance = new bootstrap.Dropdown(parentToggle);
}
instance.hide();
}
}
}
});
if (!isOpen) {
dropdownInstance.show();
setTimeout(() => searchInput.focus(), 150);
} else {
dropdownInstance.hide();
}
});
// Hide dropdown if it's open and user clicks outside
document.addEventListener('click', function (e) {
if (!searchDropdown.contains(e.target) && dropdownMenu.classList.contains('show')) {
dropdownInstance.hide();
}
});
// Keep dropdown open if search input is clicked
searchInput.addEventListener('click', function (e) {
e.stopPropagation();
});
});
@@ -1,176 +0,0 @@
<!DOCTYPE html>
<html th:lang="${#locale.language}"
th:dir="#{language.direction}"
th:data-language="${#locale.toString()}"
xmlns:th="https://www.thymeleaf.org">
<head>
<th:block
th:insert="~{fragments/common :: head(title=#{editTableOfContents.title}, header=#{editTableOfContents.header})}">
</th:block>
<link rel="stylesheet"
th:href="@{'/css/edit-table-of-contents.css'}">
</head>
<body>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon edit">bookmark_add</span>
<span class="tool-header-text"
th:text="#{editTableOfContents.header}"></span>
</div>
<form th:action="@{'/api/v1/general/edit-table-of-contents'}"
method="post"
enctype="multipart/form-data"
id="editTocForm">
<div
th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='application/pdf')}">
</div>
<div class="mb-3 form-check">
<input type="checkbox"
class="form-check-input"
id="replaceExisting"
name="replaceExisting"
checked>
<label class="form-check-label"
for="replaceExisting"
th:text="#{editTableOfContents.replaceExisting}"></label>
<input type="hidden"
name="replaceExisting"
value="false" />
</div>
<div class="bookmark-editor">
<h5 th:text="#{editTableOfContents.editorTitle}"></h5>
<p th:text="#{editTableOfContents.editorDesc}"></p>
<div id="error-message-container">
<!-- Error messages will be added here dynamically -->
</div>
<div id="bookmarks-container">
<!-- Bookmarks will be added here dynamically -->
</div>
<div class="bookmark-actions">
<button type="button"
id="addBookmarkBtn"
class="btn btn-outline-primary"
th:text="#{editTableOfContents.addBookmark}"></button>
<div class="d-flex flex-wrap justify-content-end gap-2">
<!-- Import Split Button -->
<div class="btn-group">
<label
id="importDefaultBtn"
for="importUploadJsonFileInput"
class="btn btn-outline-primary"
style="border-top-left-radius: 1.25rem !important; border-bottom-left-radius: 1.25rem !important;"
th:text="#{editTableOfContents.importBookmarksDefault}">
</label>
<button type="button"
class="btn btn-outline-primary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false">
<span class="visually-hidden">Toggle Import Options</span>
</button>
<ul class="dropdown-menu">
<li><label class="dropdown-item"
id="importUploadJsonFileBtn"
for="importUploadJsonFileInput"
style="cursor: pointer;"
th:text="#{editTableOfContents.importBookmarksFromJsonFile}"></label></li>
<li class="d-none"><a class="dropdown-item"
href="#bookmarks-container"
id="importPasteFromClipboardBtn"
th:text="#{editTableOfContents.importBookmarksFromClipboard}"></a></li>
</ul>
</div>
<!-- Export Split Button -->
<div class="btn-group">
<button type="button"
id="exportDefaultBtn"
class="btn btn-outline-primary"
th:text="#{editTableOfContents.exportBookmarksDefault}"></button>
<button type="button"
class="btn btn-outline-primary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false">
<span class="visually-hidden">Toggle Export Options</span>
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item"
href="#bookmarks-container"
id="exportDownloadJsonFileBtn"
th:text="#{editTableOfContents.exportBookmarksAsJson}"></a></li>
<li class="d-none"><a class="dropdown-item"
href="#bookmarks-container"
id="exportCopyToClipboardBtn"
th:text="#{editTableOfContents.exportBookmarksAsText}"></a></li>
</ul>
</div>
</div>
</div>
<!-- Hidden field to store JSON data -->
<input type="hidden"
id="bookmarkData"
name="bookmarkData"
value="[]">
</div>
<p>
<a class="btn btn-outline-primary"
data-bs-toggle="collapse"
href="#info"
role="button"
aria-expanded="false"
aria-controls="info"
th:text="#{info}"></a>
</p>
<div class="collapse"
id="info">
<p th:text="#{editTableOfContents.desc.1}"></p>
<p th:text="#{editTableOfContents.desc.2}"></p>
<p th:text="#{editTableOfContents.desc.3}"></p>
</div>
<br>
<button type="submit"
id="submitBtn"
class="btn btn-primary"
th:text="#{editTableOfContents.submit}"></button>
</form>
<!-- Hidden file input for JSON import (outside of form)-->
<input type="file"
id="importUploadJsonFileInput"
accept="application/json"
style="display: none;">
</div>
</div>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
<script th:src="@{'/js/pages/edit-table-of-contents.js'}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize Bootstrap tooltips
if (typeof $ !== 'undefined') {
$('[data-bs-toggle="tooltip"]').tooltip();
}
});
</script>
</body>
</html>
@@ -1,71 +0,0 @@
package stirling.software.proprietary.config;
import java.util.Map;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.service.ApiCreditService;
@Configuration
@ConfigurationProperties(prefix = "api.credit-system")
@Data
@Slf4j
public class ApiCreditConfiguration {
private boolean enabled = true;
private boolean excludeSettings = true;
private boolean excludeActuator = true;
private int defaultCreditCost = 1;
/**
* Default monthly credit limits by role. Override in application.yml/properties. Note:
* Integer.MAX_VALUE is treated as "unlimited".
*/
private Map<String, Integer> defaultCreditLimits =
Map.of(
"ROLE_SYSTEM_ADMIN", Integer.MAX_VALUE,
"ROLE_ORG_ADMIN", 10000,
"ROLE_TEAM_LEAD", 5000,
"ROLE_ADMIN", Integer.MAX_VALUE,
"ROLE_USER", 50,
"ROLE_DEMO_USER", 20,
"STIRLING-PDF-BACKEND-API-USER", Integer.MAX_VALUE);
@Bean
public CommandLineRunner initializeDefaultCreditLimits(ApiCreditService creditService) {
return args -> {
if (!enabled) {
log.info("API credit system is disabled");
return;
}
log.info("Initializing default API credit limits...");
initializeDefaults(creditService);
log.info("Default API credit limits initialized successfully");
};
}
private void initializeDefaults(ApiCreditService creditService) {
for (Map.Entry<String, Integer> entry : defaultCreditLimits.entrySet()) {
String roleName = entry.getKey();
Integer creditLimit = entry.getValue();
try {
Role.fromString(roleName);
creditService.createOrUpdateRoleDefault(roleName, creditLimit);
log.debug(
"Set default credit limit for role {} to {}/month",
roleName,
creditLimit == Integer.MAX_VALUE ? "unlimited" : creditLimit);
} catch (IllegalArgumentException e) {
log.warn("Skipping unknown role in credit system config: {}", roleName);
}
}
}
}
@@ -1,173 +0,0 @@
package stirling.software.proprietary.controller.api;
import java.time.YearMonth;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.service.ApiCreditService;
@RestController
@RequestMapping("/api/v1/credits")
@RequiredArgsConstructor
@Slf4j
@Tag(
name = "API Credits",
description = "Endpoints for managing and viewing API credit limits and usage")
public class ApiCreditController {
private final ApiCreditService creditService;
private final UserService userService;
public record CreditMetricsResponse(
int creditsConsumed,
int monthlyCredits,
int remaining,
String scope,
String month,
boolean isPooled,
long resetEpochMillis) {}
public record UpdateCreditLimitRequest(int monthlyCredits, Boolean isActive) {}
public record CreateUserCreditConfigRequest(
String username, int monthlyCredits, boolean isActive) {}
public record CreateOrgCreditConfigRequest(
String organizationName, int monthlyCredits, boolean isPooled, boolean isActive) {}
@Operation(
summary = "Get current user's credit metrics",
description =
"Returns the current user's credit consumption, limits, and remaining credits for the current month")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Credit metrics retrieved successfully"),
@ApiResponse(responseCode = "401", description = "User not authenticated")
})
@GetMapping("/my-usage")
public ResponseEntity<CreditMetricsResponse> getMyCredits(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(401).build();
}
User user =
userService
.findByUsername(authentication.getName())
.orElseThrow(() -> new RuntimeException("User not found"));
ApiCreditService.CreditMetrics metrics = creditService.getUserCreditMetrics(user);
YearMonth nextMonth = metrics.month().plusMonths(1);
ZonedDateTime resetTime = nextMonth.atDay(1).atStartOfDay(ZoneOffset.UTC);
long resetEpochMillis = resetTime.toInstant().toEpochMilli();
CreditMetricsResponse response =
new CreditMetricsResponse(
metrics.creditsConsumed(),
metrics.monthlyCredits(),
metrics.remaining(),
metrics.scope(),
metrics.month().toString(),
metrics.isPooled(),
resetEpochMillis);
return ResponseEntity.ok(response);
}
@Operation(
summary = "Get user credit metrics",
description = "Returns credit metrics for a specific user (admin only)")
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
@GetMapping("/user/{username}")
public ResponseEntity<CreditMetricsResponse> getUserCredits(
@Parameter(description = "Username to get credit metrics for") @PathVariable
String username) {
User user = userService.findByUsername(username).orElse(null);
if (user == null) {
return ResponseEntity.notFound().build();
}
ApiCreditService.CreditMetrics metrics = creditService.getUserCreditMetrics(user);
YearMonth nextMonth = metrics.month().plusMonths(1);
ZonedDateTime resetTime = nextMonth.atDay(1).atStartOfDay(ZoneOffset.UTC);
long resetEpochMillis = resetTime.toInstant().toEpochMilli();
CreditMetricsResponse response =
new CreditMetricsResponse(
metrics.creditsConsumed(),
metrics.monthlyCredits(),
metrics.remaining(),
metrics.scope(),
metrics.month().toString(),
metrics.isPooled(),
resetEpochMillis);
return ResponseEntity.ok(response);
}
@Operation(
summary = "Create user-specific credit limit",
description = "Create a credit limit configuration for a specific user (admin only)")
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
@PostMapping("/config/user")
public ResponseEntity<?> createUserCreditConfig(
@RequestBody CreateUserCreditConfigRequest request) {
try {
User user = userService.findByUsername(request.username()).orElse(null);
if (user == null) {
return ResponseEntity.badRequest().body("User not found: " + request.username());
}
creditService.createUserCreditConfig(user, request.monthlyCredits(), request.isActive());
return ResponseEntity.ok().body("User credit configuration created successfully");
} catch (Exception e) {
log.error("Error creating user credit config", e);
return ResponseEntity.badRequest().body("Error: " + e.getMessage());
}
}
@Operation(
summary = "Update role default credit limits",
description = "Update the default credit limit for a specific role (admin only)")
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
@PutMapping("/config/role/{roleName}")
public ResponseEntity<?> updateRoleDefault(
@Parameter(description = "Role name to update") @PathVariable String roleName,
@RequestBody UpdateCreditLimitRequest request) {
try {
creditService.createOrUpdateRoleDefault(roleName, request.monthlyCredits());
return ResponseEntity.ok().body("Role default updated successfully");
} catch (Exception e) {
log.error("Error updating role default", e);
return ResponseEntity.badRequest().body("Error: " + e.getMessage());
}
}
@Operation(
summary = "Get credit system status",
description = "Returns basic information about the credit system configuration")
@GetMapping("/status")
public ResponseEntity<?> getCreditSystemStatus() {
return ResponseEntity.ok()
.body(
"API Credit System is active. Use /api/v1/credits/my-usage to check your credit balance.");
}
}
@@ -1,484 +0,0 @@
package stirling.software.proprietary.controller.api;
import static stirling.software.common.util.ProviderUtils.validateProvider;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Security;
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2.Client;
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
import stirling.software.common.model.FileInfo;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.oauth2.GitHubProvider;
import stirling.software.common.model.oauth2.GoogleProvider;
import stirling.software.common.model.oauth2.KeycloakProvider;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.model.dto.TeamWithUserCountDTO;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
import stirling.software.proprietary.security.database.repository.SessionRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.SessionEntity;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.DatabaseService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@Slf4j
@RestController
@RequestMapping("/api/v1/proprietary/ui-data")
@Tag(name = "Proprietary UI Data", description = "APIs for React UI data (Proprietary features)")
@EnterpriseEndpoint
public class ProprietaryUIDataController {
private final ApplicationProperties applicationProperties;
private final AuditConfigurationProperties auditConfig;
private final SessionPersistentRegistry sessionPersistentRegistry;
private final UserRepository userRepository;
private final TeamRepository teamRepository;
private final SessionRepository sessionRepository;
private final DatabaseService databaseService;
private final boolean runningEE;
private final ObjectMapper objectMapper;
public ProprietaryUIDataController(
ApplicationProperties applicationProperties,
AuditConfigurationProperties auditConfig,
SessionPersistentRegistry sessionPersistentRegistry,
UserRepository userRepository,
TeamRepository teamRepository,
SessionRepository sessionRepository,
DatabaseService databaseService,
ObjectMapper objectMapper,
@Qualifier("runningEE") boolean runningEE) {
this.applicationProperties = applicationProperties;
this.auditConfig = auditConfig;
this.sessionPersistentRegistry = sessionPersistentRegistry;
this.userRepository = userRepository;
this.teamRepository = teamRepository;
this.sessionRepository = sessionRepository;
this.databaseService = databaseService;
this.objectMapper = objectMapper;
this.runningEE = runningEE;
}
@GetMapping("/audit-dashboard")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Get audit dashboard data")
public ResponseEntity<AuditDashboardData> getAuditDashboardData() {
AuditDashboardData data = new AuditDashboardData();
data.setAuditEnabled(auditConfig.isEnabled());
data.setAuditLevel(auditConfig.getAuditLevel());
data.setAuditLevelInt(auditConfig.getLevel());
data.setRetentionDays(auditConfig.getRetentionDays());
data.setAuditLevels(AuditLevel.values());
data.setAuditEventTypes(AuditEventType.values());
return ResponseEntity.ok(data);
}
@GetMapping("/login")
@Operation(summary = "Get login page data")
public ResponseEntity<LoginData> getLoginData() {
LoginData data = new LoginData();
Map<String, String> providerList = new HashMap<>();
Security securityProps = applicationProperties.getSecurity();
OAUTH2 oauth = securityProps.getOauth2();
if (oauth != null && oauth.getEnabled()) {
if (oauth.isSettingsValid()) {
String firstChar = String.valueOf(oauth.getProvider().charAt(0));
String clientName =
oauth.getProvider().replaceFirst(firstChar, firstChar.toUpperCase());
providerList.put("/oauth2/authorization/" + oauth.getProvider(), clientName);
}
Client client = oauth.getClient();
if (client != null) {
GoogleProvider google = client.getGoogle();
if (validateProvider(google)) {
providerList.put(
"/oauth2/authorization/" + google.getName(), google.getClientName());
}
GitHubProvider github = client.getGithub();
if (validateProvider(github)) {
providerList.put(
"/oauth2/authorization/" + github.getName(), github.getClientName());
}
KeycloakProvider keycloak = client.getKeycloak();
if (validateProvider(keycloak)) {
providerList.put(
"/oauth2/authorization/" + keycloak.getName(),
keycloak.getClientName());
}
}
}
SAML2 saml2 = securityProps.getSaml2();
if (securityProps.isSaml2Active()
&& applicationProperties.getSystem().getEnableAlphaFunctionality()
&& applicationProperties.getPremium().isEnabled()) {
String samlIdp = saml2.getProvider();
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
if (!applicationProperties.getPremium().getProFeatures().isSsoAutoLogin()) {
providerList.put(saml2AuthenticationPath, samlIdp + " (SAML 2)");
}
}
// Remove null entries
providerList
.entrySet()
.removeIf(entry -> entry.getKey() == null || entry.getValue() == null);
data.setProviderList(providerList);
data.setLoginMethod(securityProps.getLoginMethod());
data.setAltLogin(!providerList.isEmpty() && securityProps.isAltLogin());
return ResponseEntity.ok(data);
}
@GetMapping("/admin-settings")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@Operation(summary = "Get admin settings data")
public ResponseEntity<AdminSettingsData> getAdminSettingsData(Authentication authentication) {
List<User> allUsers = userRepository.findAllWithTeam();
Iterator<User> iterator = allUsers.iterator();
Map<String, String> roleDetails = Role.getAllRoleDetails();
Map<String, Boolean> userSessions = new HashMap<>();
Map<String, Date> userLastRequest = new HashMap<>();
int activeUsers = 0;
int disabledUsers = 0;
while (iterator.hasNext()) {
User user = iterator.next();
if (user != null) {
boolean shouldRemove = false;
// Check if user is an INTERNAL_API_USER
for (Authority authority : user.getAuthorities()) {
if (authority.getAuthority().equals(Role.INTERNAL_API_USER.getRoleId())) {
shouldRemove = true;
roleDetails.remove(Role.INTERNAL_API_USER.getRoleId());
break;
}
}
// Check if user is part of the Internal team
if (user.getTeam() != null
&& user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
shouldRemove = true;
}
if (shouldRemove) {
iterator.remove();
continue;
}
// Session status and last request time
int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval();
boolean hasActiveSession = false;
Date lastRequest = null;
Optional<SessionEntity> latestSession =
sessionPersistentRegistry.findLatestSession(user.getUsername());
if (latestSession.isPresent()) {
SessionEntity sessionEntity = latestSession.get();
Date lastAccessedTime = sessionEntity.getLastRequest();
Instant now = Instant.now();
Instant expirationTime =
lastAccessedTime
.toInstant()
.plus(maxInactiveInterval, ChronoUnit.SECONDS);
if (now.isAfter(expirationTime)) {
sessionPersistentRegistry.expireSession(sessionEntity.getSessionId());
} else {
hasActiveSession = !sessionEntity.isExpired();
}
lastRequest = sessionEntity.getLastRequest();
} else {
lastRequest = new Date(0);
}
userSessions.put(user.getUsername(), hasActiveSession);
userLastRequest.put(user.getUsername(), lastRequest);
if (hasActiveSession) activeUsers++;
if (!user.isEnabled()) disabledUsers++;
}
}
// Sort users by active status and last request date
List<User> sortedUsers =
allUsers.stream()
.sorted(
(u1, u2) -> {
boolean u1Active = userSessions.get(u1.getUsername());
boolean u2Active = userSessions.get(u2.getUsername());
if (u1Active && !u2Active) return -1;
if (!u1Active && u2Active) return 1;
Date u1LastRequest =
userLastRequest.getOrDefault(
u1.getUsername(), new Date(0));
Date u2LastRequest =
userLastRequest.getOrDefault(
u2.getUsername(), new Date(0));
return u2LastRequest.compareTo(u1LastRequest);
})
.toList();
List<Team> allTeams =
teamRepository.findAll().stream()
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME))
.toList();
AdminSettingsData data = new AdminSettingsData();
data.setUsers(sortedUsers);
data.setCurrentUsername(authentication.getName());
data.setRoleDetails(roleDetails);
data.setUserSessions(userSessions);
data.setUserLastRequest(userLastRequest);
data.setTotalUsers(allUsers.size());
data.setActiveUsers(activeUsers);
data.setDisabledUsers(disabledUsers);
data.setTeams(allTeams);
data.setMaxPaidUsers(applicationProperties.getPremium().getMaxUsers());
return ResponseEntity.ok(data);
}
@GetMapping("/account")
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@Operation(summary = "Get account page data")
public ResponseEntity<AccountData> getAccountData(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(401).build();
}
Object principal = authentication.getPrincipal();
String username = null;
boolean isOAuth2Login = false;
boolean isSaml2Login = false;
if (principal instanceof UserDetails detailsUser) {
username = detailsUser.getUsername();
} else if (principal instanceof OAuth2User oAuth2User) {
username = oAuth2User.getName();
isOAuth2Login = true;
} else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) {
username = saml2User.name();
isSaml2Login = true;
}
if (username == null) {
return ResponseEntity.status(401).build();
}
Optional<User> user = userRepository.findByUsernameIgnoreCaseWithSettings(username);
if (user.isEmpty()) {
return ResponseEntity.status(404).build();
}
String settingsJson;
try {
settingsJson = objectMapper.writeValueAsString(user.get().getSettings());
} catch (JsonProcessingException e) {
log.error("Error converting settings map", e);
return ResponseEntity.status(500).build();
}
AccountData data = new AccountData();
data.setUsername(username);
data.setRole(user.get().getRolesAsString());
data.setSettings(settingsJson);
data.setChangeCredsFlag(user.get().isFirstLogin());
data.setOAuth2Login(isOAuth2Login);
data.setSaml2Login(isSaml2Login);
return ResponseEntity.ok(data);
}
@GetMapping("/teams")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@Operation(summary = "Get teams list data")
public ResponseEntity<TeamsData> getTeamsData() {
List<TeamWithUserCountDTO> allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount();
List<TeamWithUserCountDTO> teamsWithCounts =
allTeamsWithCounts.stream()
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME))
.toList();
List<Object[]> teamActivities = sessionRepository.findLatestActivityByTeam();
Map<Long, Date> teamLastRequest = new HashMap<>();
for (Object[] result : teamActivities) {
Long teamId = (Long) result[0];
Date lastActivity = (Date) result[1];
teamLastRequest.put(teamId, lastActivity);
}
TeamsData data = new TeamsData();
data.setTeamsWithCounts(teamsWithCounts);
data.setTeamLastRequest(teamLastRequest);
return ResponseEntity.ok(data);
}
@GetMapping("/teams/{id}")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@Operation(summary = "Get team details data")
public ResponseEntity<TeamDetailsData> getTeamDetailsData(@PathVariable("id") Long id) {
Team team =
teamRepository
.findById(id)
.orElseThrow(() -> new RuntimeException("Team not found"));
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return ResponseEntity.status(403).build();
}
List<User> teamUsers = userRepository.findAllByTeamId(id);
List<User> allUsers = userRepository.findAllWithTeam();
List<User> availableUsers =
allUsers.stream()
.filter(
user ->
(user.getTeam() == null
|| !user.getTeam().getId().equals(id))
&& (user.getTeam() == null
|| !user.getTeam()
.getName()
.equals(
TeamService
.INTERNAL_TEAM_NAME)))
.toList();
List<Object[]> userSessions = sessionRepository.findLatestSessionByTeamId(id);
Map<String, Date> userLastRequest = new HashMap<>();
for (Object[] result : userSessions) {
String username = (String) result[0];
Date lastRequest = (Date) result[1];
userLastRequest.put(username, lastRequest);
}
TeamDetailsData data = new TeamDetailsData();
data.setTeam(team);
data.setTeamUsers(teamUsers);
data.setAvailableUsers(availableUsers);
data.setUserLastRequest(userLastRequest);
return ResponseEntity.ok(data);
}
@GetMapping("/database")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@Operation(summary = "Get database management data")
public ResponseEntity<DatabaseData> getDatabaseData() {
List<FileInfo> backupList = databaseService.getBackupList();
String dbVersion = databaseService.getH2Version();
boolean isVersionUnknown = "Unknown".equalsIgnoreCase(dbVersion);
DatabaseData data = new DatabaseData();
data.setBackupFiles(backupList);
data.setDatabaseVersion(dbVersion);
data.setVersionUnknown(isVersionUnknown);
return ResponseEntity.ok(data);
}
// Data classes
@Data
public static class AuditDashboardData {
private boolean auditEnabled;
private AuditLevel auditLevel;
private int auditLevelInt;
private int retentionDays;
private AuditLevel[] auditLevels;
private AuditEventType[] auditEventTypes;
}
@Data
public static class LoginData {
private Map<String, String> providerList;
private String loginMethod;
private boolean altLogin;
}
@Data
public static class AdminSettingsData {
private List<User> users;
private String currentUsername;
private Map<String, String> roleDetails;
private Map<String, Boolean> userSessions;
private Map<String, Date> userLastRequest;
private int totalUsers;
private int activeUsers;
private int disabledUsers;
private List<Team> teams;
private int maxPaidUsers;
}
@Data
public static class AccountData {
private String username;
private String role;
private String settings;
private boolean changeCredsFlag;
private boolean oAuth2Login;
private boolean saml2Login;
}
@Data
public static class TeamsData {
private List<TeamWithUserCountDTO> teamsWithCounts;
private Map<Long, Date> teamLastRequest;
}
@Data
public static class TeamDetailsData {
private Team team;
private List<User> teamUsers;
private List<User> availableUsers;
private Map<String, Date> userLastRequest;
}
@Data
public static class DatabaseData {
private List<FileInfo> backupFiles;
private String databaseVersion;
private boolean versionUnknown;
}
}
@@ -1,119 +0,0 @@
package stirling.software.proprietary.model;
import java.io.Serializable;
import java.time.Instant;
import java.time.YearMonth;
import java.util.HashSet;
import java.util.Set;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import stirling.software.proprietary.model.converter.YearMonthStringConverter;
@Entity
@Table(
name = "anonymous_api_credit_usage",
uniqueConstraints = {
@UniqueConstraint(
name = "uq_anon_credit_fingerprint_month",
columnNames = {"fingerprint", "month"})
},
indexes = {
@Index(name = "idx_anon_credit_fingerprint", columnList = "fingerprint"),
@Index(name = "idx_anon_credit_month", columnList = "month"),
@Index(name = "idx_anon_credit_ip", columnList = "ip_address")
})
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
public class AnonymousCreditUsage implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
@EqualsAndHashCode.Include
@ToString.Include
private Long id;
@NotNull
@Column(name = "fingerprint", nullable = false, length = 128)
@ToString.Include
private String fingerprint;
@NotNull
@Column(name = "month", nullable = false, length = 7)
@Convert(converter = YearMonthStringConverter.class)
private YearMonth month;
@NotNull
@Column(name = "credits_consumed", nullable = false)
@Builder.Default
private Integer creditsConsumed = 0;
@NotNull
@Column(name = "credits_allocated", nullable = false)
@Builder.Default
private Integer creditsAllocated = 0;
@Column(name = "ip_address", length = 45)
private String ipAddress;
@Column(name = "user_agent", length = 512)
private String userAgent;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "anonymous_api_related_fingerprints",
joinColumns = @JoinColumn(name = "usage_id"))
@Column(name = "related_fingerprint")
@Builder.Default
private Set<String> relatedFingerprints = new HashSet<>();
@Column(name = "abuse_score")
@Builder.Default
private Integer abuseScore = 0;
@Column(name = "is_blocked")
@Builder.Default
private Boolean isBlocked = false;
@Column(name = "last_access")
private Instant lastAccess;
@CreationTimestamp
@Column(name = "created_at", updatable = false, nullable = false)
private Instant createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
@Column(name = "version")
private Long version;
@PreUpdate
public void preUpdate() {
this.lastAccess = Instant.now();
}
public int getRemainingCredits() {
return Math.max(0, creditsAllocated - creditsConsumed);
}
public boolean hasCreditsRemaining(int creditCost) {
return getRemainingCredits() >= creditCost;
}
}
@@ -1,132 +0,0 @@
package stirling.software.proprietary.model;
import java.io.Serializable;
import java.time.Instant;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.*;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import stirling.software.proprietary.security.model.User;
@Entity
@Table(
name = "api_credit_configs",
uniqueConstraints = {
@UniqueConstraint(
name = "uq_user_credit_cfg",
columnNames = {"scope_type", "user_id"}),
@UniqueConstraint(
name = "uq_org_credit_cfg",
columnNames = {"scope_type", "org_id"}),
@UniqueConstraint(
name = "uq_role_credit_cfg",
columnNames = {"scope_type", "role_name"})
},
indexes = {
@Index(name = "idx_credit_cfg_user", columnList = "user_id"),
@Index(name = "idx_credit_cfg_org", columnList = "org_id"),
@Index(name = "idx_credit_cfg_role", columnList = "role_name"),
@Index(name = "idx_credit_cfg_scope", columnList = "scope_type")
})
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
public class ApiCreditConfig implements Serializable {
private static final long serialVersionUID = 1L;
public enum ScopeType {
USER,
ORGANIZATION,
ROLE_DEFAULT
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
@EqualsAndHashCode.Include
@ToString.Include
private Long id;
@NotNull
@Column(name = "scope_type", nullable = false, length = 20)
@Enumerated(EnumType.STRING)
private ScopeType scopeType;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = true)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "org_id", nullable = true)
private Organization organization;
@Column(name = "role_name", nullable = true, length = 50)
private String roleName;
@NotNull
@Min(0)
@Column(name = "monthly_credits", nullable = false)
private Integer monthlyCredits;
@NotNull
@Builder.Default
@Column(name = "is_pooled", nullable = false)
private Boolean isPooled = false;
@NotNull
@Builder.Default
@Column(name = "is_active", nullable = false)
private Boolean isActive = true;
@CreationTimestamp
@Column(name = "created_at", updatable = false, nullable = false)
private Instant createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
@Column(name = "version")
private Long version;
@PrePersist
@PreUpdate
protected void validateScope() {
int nonNullCount = 0;
if (user != null) nonNullCount++;
if (organization != null) nonNullCount++;
if (roleName != null) nonNullCount++;
if (nonNullCount != 1) {
throw new IllegalStateException(
"Exactly one of user, organization, or roleName must be set");
}
if (user != null && scopeType != ScopeType.USER) {
throw new IllegalStateException("ScopeType must be USER when user is set");
}
if (organization != null && scopeType != ScopeType.ORGANIZATION) {
throw new IllegalStateException(
"ScopeType must be ORGANIZATION when organization is set");
}
if (roleName != null && scopeType != ScopeType.ROLE_DEFAULT) {
throw new IllegalStateException("ScopeType must be ROLE_DEFAULT when roleName is set");
}
if (Boolean.TRUE.equals(isPooled) && scopeType != ScopeType.ORGANIZATION) {
throw new IllegalStateException("isPooled can only be true for ORGANIZATION scope");
}
}
}
@@ -1,127 +0,0 @@
package stirling.software.proprietary.model;
import java.io.Serializable;
import java.time.Instant;
import java.time.YearMonth;
import java.time.ZoneOffset;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.*;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import stirling.software.proprietary.security.model.User;
@Entity
@Table(
name = "api_credit_usage",
uniqueConstraints = {
@UniqueConstraint(
name = "uq_credit_user_month",
columnNames = {"user_id", "month_key"}),
@UniqueConstraint(
name = "uq_credit_org_month",
columnNames = {"org_id", "month_key"})
},
indexes = {
@Index(name = "idx_credit_usage_user_month", columnList = "user_id, month_key"),
@Index(name = "idx_credit_usage_org_month", columnList = "org_id, month_key"),
@Index(name = "idx_credit_usage_month", columnList = "month_key")
})
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
public class ApiCreditUsage implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
@EqualsAndHashCode.Include
@ToString.Include
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = true)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "org_id", nullable = true)
private Organization organization;
@NotNull
@Column(name = "month_key", nullable = false, length = 7)
@ToString.Include
private YearMonth monthKey;
@NotNull
@Min(0)
@Builder.Default
@Column(name = "credits_consumed", nullable = false)
private Integer creditsConsumed = 0;
@NotNull
@Min(0)
@Builder.Default
@Column(name = "credits_allocated", nullable = false)
private Integer creditsAllocated = 0;
@CreationTimestamp
@Column(name = "created_at", updatable = false, nullable = false)
private Instant createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
@Column(name = "version")
private Long version;
@PrePersist
@PreUpdate
protected void validateScope() {
if ((user == null && organization == null) || (user != null && organization != null)) {
throw new IllegalStateException("Exactly one of user or organization must be set");
}
}
public static YearMonth getCurrentMonth() {
return YearMonth.now(ZoneOffset.UTC);
}
public static ApiCreditUsage forUser(User user, int creditsAllocated) {
return ApiCreditUsage.builder()
.user(user)
.monthKey(getCurrentMonth())
.creditsConsumed(0)
.creditsAllocated(creditsAllocated)
.build();
}
public static ApiCreditUsage forOrganization(Organization org, int creditsAllocated) {
return ApiCreditUsage.builder()
.organization(org)
.monthKey(getCurrentMonth())
.creditsConsumed(0)
.creditsAllocated(creditsAllocated)
.build();
}
public int getRemainingCredits() {
return Math.max(0, creditsAllocated - creditsConsumed);
}
public boolean hasCreditsRemaining(int creditCost) {
return getRemainingCredits() >= creditCost;
}
}
@@ -1,83 +0,0 @@
package stirling.software.proprietary.model;
import java.util.UUID;
import jakarta.servlet.http.HttpServletResponse;
import stirling.software.proprietary.security.model.User;
/** Holds context information for credit-based API requests */
public class CreditRequestContext {
private final String requestId;
private final User user;
private final String ipAddress;
private final String userAgent;
private final int creditCost;
private final String endpoint;
private boolean creditsPreChecked = false;
private HttpServletResponse httpResponse;
public CreditRequestContext(
String requestId,
User user,
String ipAddress,
String userAgent,
int creditCost,
String endpoint) {
this.requestId = requestId;
this.user = user;
this.ipAddress = ipAddress;
this.userAgent = userAgent;
this.creditCost = creditCost;
this.endpoint = endpoint;
}
public String getRequestId() {
return requestId;
}
public User getUser() {
return user;
}
public String getIpAddress() {
return ipAddress;
}
public String getUserAgent() {
return userAgent;
}
public int getCreditCost() {
return creditCost;
}
public String getEndpoint() {
return endpoint;
}
public boolean isCreditsPreChecked() {
return creditsPreChecked;
}
public void setCreditsPreChecked(boolean preChecked) {
this.creditsPreChecked = preChecked;
}
public boolean isAnonymous() {
return user == null;
}
public HttpServletResponse getHttpResponse() {
return httpResponse;
}
public void setHttpResponse(HttpServletResponse httpResponse) {
this.httpResponse = httpResponse;
}
/** Generate a unique identifier for this request */
public static String generateRequestId() {
return UUID.randomUUID().toString();
}
}
@@ -1,20 +0,0 @@
package stirling.software.proprietary.model;
/** Classification of API call failures for credit charging purposes */
public enum FailureType {
/**
* Client-side errors that don't consume credits and don't count toward failure limit Examples:
* 400 Bad Request, 401 Unauthorized, 403 Forbidden, 422 Unprocessable Entity
*/
CLIENT_ERROR,
/**
* Processing errors that occur after validation passes - these count toward consecutive
* failures Examples: 500 Internal Server Error, processing exceptions, timeout during PDF
* manipulation
*/
PROCESSING_ERROR,
/** Successful processing - resets failure counter and consumes credits */
SUCCESS
}
@@ -1,45 +0,0 @@
package stirling.software.proprietary.model;
import java.io.Serializable;
import java.util.HashSet;
import java.util.Set;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "organizations")
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
public class Organization implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "org_id")
private Long id;
@Column(name = "name", unique = true, nullable = false)
private String name;
@Column(name = "description")
private String description;
@OneToMany(mappedBy = "organization", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Team> teams = new HashSet<>();
public void addTeam(Team team) {
teams.add(team);
team.setOrganization(this);
}
public void removeTeam(Team team) {
teams.remove(team);
team.setOrganization(null);
}
}
@@ -1,23 +0,0 @@
package stirling.software.proprietary.model.converter;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
@Converter(autoApply = true)
public class YearMonthStringConverter implements AttributeConverter<YearMonth, String> {
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
@Override
public String convertToDatabaseColumn(YearMonth yearMonth) {
return yearMonth != null ? yearMonth.format(FORMATTER) : null;
}
@Override
public YearMonth convertToEntityAttribute(String dbData) {
return dbData != null ? YearMonth.parse(dbData, FORMATTER) : null;
}
}
@@ -1,15 +0,0 @@
package stirling.software.proprietary.model.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OrganizationWithTeamCountDTO {
private Long id;
private String name;
private String description;
private Long teamCount;
}
@@ -1,633 +0,0 @@
package stirling.software.proprietary.security.controller.api;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.util.HtmlUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.GeneralUtils;
import stirling.software.proprietary.security.model.api.admin.SettingValueResponse;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest;
@Controller
@Tag(name = "Admin Settings", description = "Admin-only Settings Management APIs")
@RequestMapping("/api/v1/admin/settings")
@RequiredArgsConstructor
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
@Slf4j
public class AdminSettingsController {
private final ApplicationProperties applicationProperties;
private final ObjectMapper objectMapper;
// Track settings that have been modified but not yet applied (require restart)
private static final ConcurrentHashMap<String, Object> pendingChanges =
new ConcurrentHashMap<>();
// Define specific sensitive field names that contain secret values
private static final Set<String> SENSITIVE_FIELD_NAMES =
new HashSet<>(
Arrays.asList(
// Passwords
"password",
"dbpassword",
"mailpassword",
"smtppassword",
// OAuth/API secrets
"clientsecret",
"apisecret",
"secret",
// API tokens
"apikey",
"accesstoken",
"refreshtoken",
"token",
// Specific secret keys (not all keys, and excluding premium.key)
"key", // automaticallyGenerated.key
"enterprisekey",
"licensekey"));
@GetMapping
@Operation(
summary = "Get all application settings",
description =
"Retrieve all current application settings. Use includePending=true to include settings that will take effect after restart. Admin access required.")
@ApiResponses(
value = {
@ApiResponse(responseCode = "200", description = "Settings retrieved successfully"),
@ApiResponse(
responseCode = "403",
description = "Access denied - Admin role required")
})
public ResponseEntity<?> getSettings(
@RequestParam(value = "includePending", defaultValue = "false")
boolean includePending) {
log.debug("Admin requested all application settings (includePending={})", includePending);
// Convert ApplicationProperties to Map
Map<String, Object> settings = objectMapper.convertValue(applicationProperties, Map.class);
if (includePending && !pendingChanges.isEmpty()) {
// Merge pending changes into the settings map
settings = mergePendingChanges(settings, pendingChanges);
}
// Mask sensitive fields after merging
Map<String, Object> maskedSettings = maskSensitiveFields(settings);
return ResponseEntity.ok(maskedSettings);
}
@GetMapping("/delta")
@Operation(
summary = "Get pending settings changes",
description =
"Retrieve settings that have been modified but not yet applied (require restart). Admin access required.")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "Pending changes retrieved successfully"),
@ApiResponse(
responseCode = "403",
description = "Access denied - Admin role required")
})
public ResponseEntity<?> getSettingsDelta() {
Map<String, Object> response = new HashMap<>();
// Mask sensitive fields in pending changes
response.put("pendingChanges", maskSensitiveFields(new HashMap<>(pendingChanges)));
response.put("hasPendingChanges", !pendingChanges.isEmpty());
response.put("count", pendingChanges.size());
log.debug("Admin requested pending changes - found {} settings", pendingChanges.size());
return ResponseEntity.ok(response);
}
@PutMapping
@Operation(
summary = "Update application settings (delta updates)",
description =
"Update specific application settings using dot notation keys. Only sends changed values. Changes take effect on restart. Admin access required.")
@ApiResponses(
value = {
@ApiResponse(responseCode = "200", description = "Settings updated successfully"),
@ApiResponse(responseCode = "400", description = "Invalid setting key or value"),
@ApiResponse(
responseCode = "403",
description = "Access denied - Admin role required"),
@ApiResponse(
responseCode = "500",
description = "Failed to save settings to configuration file")
})
public ResponseEntity<String> updateSettings(
@Valid @RequestBody UpdateSettingsRequest request) {
try {
Map<String, Object> settings = request.getSettings();
if (settings == null || settings.isEmpty()) {
return ResponseEntity.badRequest().body("No settings provided to update");
}
int updatedCount = 0;
for (Map.Entry<String, Object> entry : settings.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
if (!isValidSettingKey(key)) {
return ResponseEntity.badRequest()
.body("Invalid setting key format: " + HtmlUtils.htmlEscape(key));
}
log.info("Admin updating setting: {} = {}", key, value);
GeneralUtils.saveKeyToSettings(key, value);
// Track this as a pending change
pendingChanges.put(key, value);
updatedCount++;
}
return ResponseEntity.ok(
String.format(
"Successfully updated %d setting(s). Changes will take effect on application restart.",
updatedCount));
} catch (IOException e) {
log.error("Failed to save settings to file: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(GENERIC_FILE_ERROR);
} catch (IllegalArgumentException e) {
log.error("Invalid setting key or value: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(GENERIC_INVALID_SETTING);
} catch (Exception e) {
log.error("Unexpected error while updating settings: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(GENERIC_SERVER_ERROR);
}
}
@GetMapping("/section/{sectionName}")
@Operation(
summary = "Get specific settings section",
description =
"Retrieve settings for a specific section (e.g., security, system, ui). Admin access required.")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "Section settings retrieved successfully"),
@ApiResponse(responseCode = "400", description = "Invalid section name"),
@ApiResponse(
responseCode = "403",
description = "Access denied - Admin role required")
})
public ResponseEntity<?> getSettingsSection(@PathVariable String sectionName) {
try {
Object sectionData = getSectionData(sectionName);
if (sectionData == null) {
return ResponseEntity.badRequest()
.body(
"Invalid section name: "
+ HtmlUtils.htmlEscape(sectionName)
+ ". Valid sections: "
+ String.join(", ", VALID_SECTION_NAMES));
}
log.debug("Admin requested settings section: {}", sectionName);
return ResponseEntity.ok(sectionData);
} catch (IllegalArgumentException e) {
log.error("Invalid section name {}: {}", sectionName, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body("Invalid section name: " + HtmlUtils.htmlEscape(sectionName));
} catch (Exception e) {
log.error("Error retrieving section {}: {}", sectionName, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to retrieve section.");
}
}
@PutMapping("/section/{sectionName}")
@Operation(
summary = "Update specific settings section",
description = "Update all settings within a specific section. Admin access required.")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "Section settings updated successfully"),
@ApiResponse(responseCode = "400", description = "Invalid section name or data"),
@ApiResponse(
responseCode = "403",
description = "Access denied - Admin role required"),
@ApiResponse(responseCode = "500", description = "Failed to save settings")
})
public ResponseEntity<String> updateSettingsSection(
@PathVariable String sectionName, @Valid @RequestBody Map<String, Object> sectionData) {
try {
if (sectionData == null || sectionData.isEmpty()) {
return ResponseEntity.badRequest().body("No section data provided to update");
}
if (!isValidSectionName(sectionName)) {
return ResponseEntity.badRequest()
.body(
"Invalid section name: "
+ HtmlUtils.htmlEscape(sectionName)
+ ". Valid sections: "
+ String.join(", ", VALID_SECTION_NAMES));
}
int updatedCount = 0;
for (Map.Entry<String, Object> entry : sectionData.entrySet()) {
String propertyKey = entry.getKey();
String fullKey = sectionName + "." + propertyKey;
Object value = entry.getValue();
if (!isValidSettingKey(fullKey)) {
return ResponseEntity.badRequest()
.body("Invalid setting key format: " + HtmlUtils.htmlEscape(fullKey));
}
log.info("Admin updating section setting: {} = {}", fullKey, value);
GeneralUtils.saveKeyToSettings(fullKey, value);
// Track this as a pending change
pendingChanges.put(fullKey, value);
updatedCount++;
}
String escapedSectionName = HtmlUtils.htmlEscape(sectionName);
return ResponseEntity.ok(
String.format(
"Successfully updated %d setting(s) in section '%s'. Changes will take effect on application restart.",
updatedCount, escapedSectionName));
} catch (IOException e) {
log.error("Failed to save section settings to file: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(GENERIC_FILE_ERROR);
} catch (IllegalArgumentException e) {
log.error("Invalid section data: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(GENERIC_INVALID_SECTION);
} catch (Exception e) {
log.error("Unexpected error while updating section settings: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(GENERIC_SERVER_ERROR);
}
}
@GetMapping("/key/{key}")
@Operation(
summary = "Get specific setting value",
description =
"Retrieve value for a specific setting key using dot notation. Admin access required.")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "Setting value retrieved successfully"),
@ApiResponse(responseCode = "400", description = "Invalid setting key"),
@ApiResponse(
responseCode = "403",
description = "Access denied - Admin role required")
})
public ResponseEntity<?> getSettingValue(@PathVariable String key) {
try {
if (!isValidSettingKey(key)) {
return ResponseEntity.badRequest()
.body("Invalid setting key format: " + HtmlUtils.htmlEscape(key));
}
Object value = getSettingByKey(key);
if (value == null) {
return ResponseEntity.badRequest()
.body("Setting key not found: " + HtmlUtils.htmlEscape(key));
}
log.debug("Admin requested setting: {}", key);
return ResponseEntity.ok(new SettingValueResponse(key, value));
} catch (IllegalArgumentException e) {
log.error("Invalid setting key {}: {}", key, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body("Invalid setting key: " + HtmlUtils.htmlEscape(key));
} catch (Exception e) {
log.error("Error retrieving setting {}: {}", key, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to retrieve setting.");
}
}
@PutMapping("/key/{key}")
@Operation(
summary = "Update specific setting value",
description =
"Update value for a specific setting key using dot notation. Admin access required.")
@ApiResponses(
value = {
@ApiResponse(responseCode = "200", description = "Setting updated successfully"),
@ApiResponse(responseCode = "400", description = "Invalid setting key or value"),
@ApiResponse(
responseCode = "403",
description = "Access denied - Admin role required"),
@ApiResponse(responseCode = "500", description = "Failed to save setting")
})
public ResponseEntity<String> updateSettingValue(
@PathVariable String key, @Valid @RequestBody UpdateSettingValueRequest request) {
try {
if (!isValidSettingKey(key)) {
return ResponseEntity.badRequest()
.body("Invalid setting key format: " + HtmlUtils.htmlEscape(key));
}
Object value = request.getValue();
log.info("Admin updating single setting: {} = {}", key, value);
GeneralUtils.saveKeyToSettings(key, value);
// Track this as a pending change
pendingChanges.put(key, value);
String escapedKey = HtmlUtils.htmlEscape(key);
return ResponseEntity.ok(
String.format(
"Successfully updated setting '%s'. Changes will take effect on application restart.",
escapedKey));
} catch (IOException e) {
log.error("Failed to save setting to file: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(GENERIC_FILE_ERROR);
} catch (IllegalArgumentException e) {
log.error("Invalid setting key or value: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(GENERIC_INVALID_SETTING);
} catch (Exception e) {
log.error("Unexpected error while updating setting: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(GENERIC_SERVER_ERROR);
}
}
private Object getSectionData(String sectionName) {
if (sectionName == null || sectionName.trim().isEmpty()) {
return null;
}
return switch (sectionName.toLowerCase()) {
case "security" -> applicationProperties.getSecurity();
case "system" -> applicationProperties.getSystem();
case "ui" -> applicationProperties.getUi();
case "endpoints" -> applicationProperties.getEndpoints();
case "metrics" -> applicationProperties.getMetrics();
case "mail" -> applicationProperties.getMail();
case "premium" -> applicationProperties.getPremium();
case "processexecutor", "processExecutor" -> applicationProperties.getProcessExecutor();
case "autopipeline", "autoPipeline" -> applicationProperties.getAutoPipeline();
case "legal" -> applicationProperties.getLegal();
default -> null;
};
}
private boolean isValidSectionName(String sectionName) {
return getSectionData(sectionName) != null;
}
private static final java.util.Set<String> VALID_SECTION_NAMES =
java.util.Set.of(
"security",
"system",
"ui",
"endpoints",
"metrics",
"mail",
"premium",
"processExecutor",
"processexecutor",
"autoPipeline",
"autopipeline",
"legal");
// Pattern to validate safe property paths - only alphanumeric, dots, and underscores
private static final Pattern SAFE_KEY_PATTERN = Pattern.compile("^[a-zA-Z0-9._]+$");
private static final int MAX_NESTING_DEPTH = 10;
// Security: Generic error messages to prevent information disclosure
private static final String GENERIC_INVALID_SETTING = "Invalid setting key or value.";
private static final String GENERIC_INVALID_SECTION = "Invalid section data provided.";
private static final String GENERIC_SERVER_ERROR = "Internal server error occurred.";
private static final String GENERIC_FILE_ERROR =
"Failed to save settings to configuration file.";
private boolean isValidSettingKey(String key) {
if (key == null || key.trim().isEmpty()) {
return false;
}
// Check against pattern to prevent injection attacks
if (!SAFE_KEY_PATTERN.matcher(key).matches()) {
return false;
}
// Prevent excessive nesting depth
String[] parts = key.split("\\.");
if (parts.length > MAX_NESTING_DEPTH) {
return false;
}
// Ensure first part is a valid section name
if (parts.length > 0 && !VALID_SECTION_NAMES.contains(parts[0].toLowerCase())) {
return false;
}
return true;
}
private Object getSettingByKey(String key) {
if (key == null || key.trim().isEmpty()) {
return null;
}
String[] parts = key.split("\\.", 2);
if (parts.length < 2) {
return null;
}
String sectionName = parts[0];
String propertyPath = parts[1];
Object section = getSectionData(sectionName);
if (section == null) {
return null;
}
try {
return getNestedProperty(section, propertyPath, 0);
} catch (NoSuchFieldException | IllegalAccessException e) {
log.warn("Failed to get nested property {}: {}", key, e.getMessage());
return null;
}
}
private Object getNestedProperty(Object obj, String propertyPath, int depth)
throws NoSuchFieldException, IllegalAccessException {
if (obj == null) {
return null;
}
// Prevent excessive recursion depth
if (depth > MAX_NESTING_DEPTH) {
throw new IllegalAccessException("Maximum nesting depth exceeded");
}
try {
// Use Jackson ObjectMapper for safer property access
@SuppressWarnings("unchecked")
Map<String, Object> objectMap = objectMapper.convertValue(obj, Map.class);
String[] parts = propertyPath.split("\\.", 2);
String currentProperty = parts[0];
if (!objectMap.containsKey(currentProperty)) {
throw new NoSuchFieldException("Property not found: " + currentProperty);
}
Object value = objectMap.get(currentProperty);
if (parts.length == 1) {
return value;
} else {
return getNestedProperty(value, parts[1], depth + 1);
}
} catch (IllegalArgumentException e) {
// If Jackson fails, the property doesn't exist or isn't accessible
throw new NoSuchFieldException("Property not accessible: " + propertyPath);
}
}
/**
* Recursively mask sensitive fields in settings map. Sensitive fields are replaced with a
* status indicator showing if they're configured.
*/
@SuppressWarnings("unchecked")
private Map<String, Object> maskSensitiveFields(Map<String, Object> settings) {
return maskSensitiveFieldsWithPath(settings, "");
}
@SuppressWarnings("unchecked")
private Map<String, Object> maskSensitiveFieldsWithPath(
Map<String, Object> settings, String path) {
Map<String, Object> masked = new HashMap<>();
for (Map.Entry<String, Object> entry : settings.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
String currentPath = path.isEmpty() ? key : path + "." + key;
if (value instanceof Map) {
// Recursively mask nested objects
masked.put(
key, maskSensitiveFieldsWithPath((Map<String, Object>) value, currentPath));
} else if (isSensitiveFieldWithPath(key, currentPath)) {
// Mask sensitive fields with status indicator
masked.put(key, createMaskedValue(value));
} else {
// Keep non-sensitive fields as-is
masked.put(key, value);
}
}
return masked;
}
/** Check if a field name indicates sensitive data with full path context */
private boolean isSensitiveFieldWithPath(String fieldName, String fullPath) {
String lowerField = fieldName.toLowerCase();
String lowerPath = fullPath.toLowerCase();
// Don't mask premium.key specifically
if ("key".equals(lowerField) && "premium.key".equals(lowerPath)) {
return false;
}
// Direct match with sensitive field names
if (SENSITIVE_FIELD_NAMES.contains(lowerField)) {
return true;
}
// Check for fields containing 'password' or 'secret'
return lowerField.contains("password") || lowerField.contains("secret");
}
/** Create a masked representation for sensitive fields */
private Object createMaskedValue(Object originalValue) {
if (originalValue == null
|| (originalValue instanceof String && ((String) originalValue).trim().isEmpty())) {
return originalValue; // Keep empty/null values as-is
} else {
return "********";
}
}
/** Merge pending changes into the settings map using dot notation keys */
@SuppressWarnings("unchecked")
private Map<String, Object> mergePendingChanges(
Map<String, Object> settings, Map<String, Object> pendingChanges) {
// Create a deep copy of the settings to avoid modifying the original
Map<String, Object> mergedSettings = new HashMap<>(settings);
for (Map.Entry<String, Object> pendingEntry : pendingChanges.entrySet()) {
String dotNotationKey = pendingEntry.getKey();
Object pendingValue = pendingEntry.getValue();
// Split the dot notation key into parts
String[] keyParts = dotNotationKey.split("\\.");
// Navigate to the parent object and set the value
Map<String, Object> currentMap = mergedSettings;
// Navigate through all parts except the last one
for (int i = 0; i < keyParts.length - 1; i++) {
String keyPart = keyParts[i];
// Get or create the nested map
Object nested = currentMap.get(keyPart);
if (!(nested instanceof Map)) {
// Create a new nested map if it doesn't exist or isn't a map
nested = new HashMap<String, Object>();
currentMap.put(keyPart, nested);
}
currentMap = (Map<String, Object>) nested;
}
// Set the final value
String finalKey = keyParts[keyParts.length - 1];
currentMap.put(finalKey, pendingValue);
}
return mergedSettings;
}
}
@@ -1,216 +0,0 @@
package stirling.software.proprietary.security.controller.api;
import java.util.List;
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.config.PremiumEndpoint;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.RoleBasedAuthorizationService;
@RestController
@RequestMapping("/api/v1/org-admin")
@Tag(name = "Organization Admin", description = "Organization Admin Management APIs")
@Slf4j
@RequiredArgsConstructor
@PremiumEndpoint
@PreAuthorize(
"@roleBasedAuthorizationService.canManageOrgUsers() or @roleBasedAuthorizationService.canManageOrgTeams()")
public class OrgAdminController {
private final TeamRepository teamRepository;
private final UserRepository userRepository;
private final RoleBasedAuthorizationService authorizationService;
/** Get all teams in the org admin's organization */
@GetMapping("/teams")
@PreAuthorize("@roleBasedAuthorizationService.canManageOrgTeams()")
public ResponseEntity<List<Team>> getOrganizationTeams() {
User currentUser = authorizationService.getCurrentUser();
if (currentUser == null || currentUser.getOrganization() == null) {
return ResponseEntity.badRequest().build();
}
List<Team> teams =
teamRepository.findByOrganizationId(currentUser.getOrganization().getId());
return ResponseEntity.ok(teams);
}
/** Get all users in the org admin's organization */
@GetMapping("/users")
public ResponseEntity<List<User>> getOrganizationUsers() {
if (!authorizationService.canManageOrgUsers()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
User currentUser = authorizationService.getCurrentUser();
if (currentUser == null || currentUser.getOrganization() == null) {
return ResponseEntity.badRequest().build();
}
// Get all users in teams belonging to this organization
List<Team> orgTeams =
teamRepository.findByOrganizationId(currentUser.getOrganization().getId());
List<User> orgUsers =
orgTeams.stream().flatMap(team -> team.getUsers().stream()).distinct().toList();
return ResponseEntity.ok(orgUsers);
}
/** Assign a user to a team within the organization */
@PostMapping("/assign-user-to-team")
@Transactional
public ResponseEntity<?> assignUserToTeam(
@RequestParam("userId") Long userId, @RequestParam("teamId") Long teamId) {
if (!authorizationService.canManageOrgUsers()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to manage organization users");
}
Optional<User> userOpt = userRepository.findById(userId);
Optional<Team> teamOpt = teamRepository.findById(teamId);
if (userOpt.isEmpty() || teamOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
Team team = teamOpt.get();
if (!authorizationService.canAddUserToTeam(userId, team)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to add user to this team");
}
// Assign user to team
user.setTeam(team);
userRepository.save(user);
return ResponseEntity.ok().body("User assigned to team successfully");
}
/** Promote a user to team lead */
@PostMapping("/promote-to-team-lead")
@Transactional
public ResponseEntity<?> promoteToTeamLead(@RequestParam("userId") Long userId) {
if (!authorizationService.canManageUser(userId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to manage this user");
}
if (!authorizationService.canAssignRole(Role.TEAM_LEAD)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to assign team lead role");
}
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
// User must be in a team to become a team lead
if (user.getTeam() == null) {
return ResponseEntity.badRequest()
.body("User must be assigned to a team before becoming a team lead");
}
user.setUserRole(Role.TEAM_LEAD);
userRepository.save(user);
return ResponseEntity.ok().body("User promoted to team lead successfully");
}
/** Demote a team lead to regular user */
@PostMapping("/demote-from-team-lead")
@Transactional
public ResponseEntity<?> demoteFromTeamLead(@RequestParam("userId") Long userId) {
if (!authorizationService.canManageUser(userId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to manage this user");
}
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
user.setUserRole(Role.USER);
userRepository.save(user);
return ResponseEntity.ok().body("User demoted from team lead successfully");
}
/** Create a new team in the organization */
@PostMapping("/create-team")
@Transactional
public ResponseEntity<?> createTeam(@RequestParam("teamName") String teamName) {
if (!authorizationService.canManageOrgTeams()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to create teams");
}
User currentUser = authorizationService.getCurrentUser();
if (currentUser == null || currentUser.getOrganization() == null) {
return ResponseEntity.badRequest()
.body("Org admin must be assigned to an organization");
}
Organization organization = currentUser.getOrganization();
// Check if team name already exists in the organization
if (teamRepository.existsByNameIgnoreCaseAndOrganizationId(
teamName, organization.getId())) {
return ResponseEntity.badRequest()
.body("Team with name '" + teamName + "' already exists in this organization");
}
Team newTeam = new Team();
newTeam.setName(teamName);
newTeam.setOrganization(organization);
Team savedTeam = teamRepository.save(newTeam);
return ResponseEntity.ok(savedTeam);
}
/** Remove a user from the organization (removes from their team) */
@PostMapping("/remove-user")
@Transactional
public ResponseEntity<?> removeUserFromOrganization(@RequestParam("userId") Long userId) {
if (!authorizationService.canRemoveUserFromTeam(userId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to remove this user");
}
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
user.setTeam(null);
user.setUserRole(Role.USER); // Reset to basic user role
userRepository.save(user);
return ResponseEntity.ok().body("User removed from organization successfully");
}
}
@@ -1,99 +0,0 @@
package stirling.software.proprietary.security.controller.api;
import java.util.List;
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.model.dto.OrganizationWithTeamCountDTO;
import stirling.software.proprietary.security.repository.OrganizationRepository;
import stirling.software.proprietary.security.service.OrganizationService;
import stirling.software.proprietary.security.service.RoleBasedAuthorizationService;
@RestController
@RequestMapping("/api/v1/organizations")
@RequiredArgsConstructor
@PreAuthorize("@roleBasedAuthorizationService.canManageOrganizations()")
public class OrganizationController {
private final OrganizationRepository organizationRepository;
private final OrganizationService organizationService;
private final RoleBasedAuthorizationService authorizationService;
@GetMapping
public ResponseEntity<List<OrganizationWithTeamCountDTO>> getAllOrganizations() {
List<OrganizationWithTeamCountDTO> organizations =
organizationRepository.findAllOrganizationsWithTeamCount();
return ResponseEntity.ok(organizations);
}
@GetMapping("/{id}")
@PreAuthorize(
"@roleBasedAuthorizationService.canViewOrganization(@organizationRepository.findById(#id).orElse(null))")
public ResponseEntity<Organization> getOrganization(@PathVariable Long id) {
Optional<Organization> organizationOpt = organizationRepository.findById(id);
if (organizationOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
Organization organization = organizationOpt.get();
return ResponseEntity.ok(organization);
}
@PostMapping
public ResponseEntity<?> createOrganization(@RequestBody Organization organization) {
if (organizationRepository.existsByNameIgnoreCase(organization.getName())) {
return ResponseEntity.badRequest()
.body("Organization with name '" + organization.getName() + "' already exists");
}
Organization savedOrganization = organizationRepository.save(organization);
return ResponseEntity.status(HttpStatus.CREATED).body(savedOrganization);
}
@PutMapping("/{id}")
public ResponseEntity<?> updateOrganization(
@PathVariable Long id, @RequestBody Organization organization) {
Optional<Organization> existingOrganization = organizationRepository.findById(id);
if (existingOrganization.isEmpty()) {
return ResponseEntity.notFound().build();
}
if (organizationRepository.existsByNameIgnoreCase(organization.getName())
&& !existingOrganization.get().getName().equalsIgnoreCase(organization.getName())) {
return ResponseEntity.badRequest()
.body("Organization with name '" + organization.getName() + "' already exists");
}
organization.setId(id);
Organization savedOrganization = organizationRepository.save(organization);
return ResponseEntity.ok(savedOrganization);
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteOrganization(@PathVariable Long id) {
Optional<Organization> organization = organizationRepository.findById(id);
if (organization.isEmpty()) {
return ResponseEntity.notFound().build();
}
// Prevent deletion of default organizations
if (OrganizationService.DEFAULT_ORG_NAME.equals(organization.get().getName())
|| OrganizationService.INTERNAL_ORG_NAME.equals(organization.get().getName())) {
return ResponseEntity.badRequest().body("Cannot delete system organizations");
}
if (!organization.get().getTeams().isEmpty()) {
return ResponseEntity.badRequest()
.body("Cannot delete organization with existing teams");
}
organizationRepository.deleteById(id);
return ResponseEntity.ok().build();
}
}
@@ -1,169 +0,0 @@
package stirling.software.proprietary.security.controller.api;
import java.util.List;
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.config.PremiumEndpoint;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.RoleBasedAuthorizationService;
@RestController
@RequestMapping("/api/v1/team-lead")
@Tag(name = "Team Lead", description = "Team Lead Management APIs")
@Slf4j
@RequiredArgsConstructor
@PremiumEndpoint
@PreAuthorize("@roleBasedAuthorizationService.canManageTeamUsers()")
public class TeamLeadController {
private final TeamRepository teamRepository;
private final UserRepository userRepository;
private final RoleBasedAuthorizationService authorizationService;
/** Get team members that the current team lead can manage */
@GetMapping("/my-team-members")
public ResponseEntity<List<User>> getMyTeamMembers() {
if (!authorizationService.canManageTeamUsers()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
User currentUser = authorizationService.getCurrentUser();
if (currentUser == null || currentUser.getTeam() == null) {
return ResponseEntity.badRequest().build();
}
List<User> teamMembers = userRepository.findByTeam(currentUser.getTeam());
return ResponseEntity.ok(teamMembers);
}
/** Add a user to the team lead's team */
@PostMapping("/add-member")
@Transactional
public ResponseEntity<?> addMemberToMyTeam(@RequestParam("userId") Long userId) {
User currentUser = authorizationService.getCurrentUser();
if (currentUser == null || currentUser.getTeam() == null) {
return ResponseEntity.badRequest().body("Team lead must be assigned to a team");
}
if (!authorizationService.canAddUserToTeam(userId, currentUser.getTeam())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to add users to this team");
}
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
// Check if user is already in a team
if (user.getTeam() != null) {
return ResponseEntity.badRequest()
.body("User is already assigned to team: " + user.getTeam().getName());
}
// Assign user to team
user.setTeam(currentUser.getTeam());
userRepository.save(user);
return ResponseEntity.ok().body("User added to team successfully");
}
/** Remove a user from the team lead's team */
@PostMapping("/remove-member")
@Transactional
public ResponseEntity<?> removeMemberFromMyTeam(@RequestParam("userId") Long userId) {
if (!authorizationService.canRemoveUserFromTeam(userId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to remove this user");
}
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
User currentUser = authorizationService.getCurrentUser();
// Prevent team leads from removing themselves
if (currentUser != null && currentUser.getId().equals(userId)) {
return ResponseEntity.badRequest()
.body("Team leads cannot remove themselves from the team");
}
// Remove user from team
user.setTeam(null);
userRepository.save(user);
return ResponseEntity.ok().body("User removed from team successfully");
}
/** Update a team member's role (team leads can only assign USER role) */
@PostMapping("/update-member-role")
@Transactional
public ResponseEntity<?> updateMemberRole(
@RequestParam("userId") Long userId, @RequestParam("role") String roleString) {
if (!authorizationService.canManageUser(userId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to manage this user");
}
try {
Role newRole = Role.fromString(roleString);
// Team leads can only assign USER role
if (!authorizationService.canAssignRole(newRole)) {
return ResponseEntity.badRequest()
.body("Not authorized to assign role: " + newRole.getRoleName());
}
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
user.setUserRole(newRole);
userRepository.save(user);
return ResponseEntity.ok().body("User role updated successfully");
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body("Invalid role: " + roleString);
}
}
/** Get team information for the current team lead */
@GetMapping("/my-team")
public ResponseEntity<Team> getMyTeam() {
User currentUser = authorizationService.getCurrentUser();
if (currentUser == null || currentUser.getTeam() == null) {
return ResponseEntity.badRequest().build();
}
if (!authorizationService.canManageTeam(currentUser.getTeam())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
return ResponseEntity.ok(currentUser.getTeam());
}
}
@@ -1,311 +0,0 @@
package stirling.software.proprietary.security.filter;
import java.io.IOException;
import java.lang.reflect.Method;
import java.time.YearMonth;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerExecutionChain;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.proprietary.model.CreditRequestContext;
import stirling.software.proprietary.security.matcher.ApiJobEndpointMatcher;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.service.ApiCreditService;
import stirling.software.proprietary.service.CreditContextManager;
@Component
@Order(1)
@RequiredArgsConstructor
@Slf4j
public class ApiCreditFilter extends OncePerRequestFilter {
private final ApiCreditService creditService;
private final UserService userService;
private final ApiJobEndpointMatcher apiJobEndpointMatcher;
private final RequestMappingHandlerMapping handlerMapping;
private final ObjectMapper objectMapper;
private final CreditContextManager contextManager;
@Value("${api.credit-system.enabled:true}")
private boolean creditSystemEnabled;
@Value("${api.credit-system.anonymous.enabled:true}")
private boolean anonymousCreditSystemEnabled;
@Value("${api.credit-system.default-credit-cost:1}")
private int defaultCreditCost;
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (!shouldApplyCreditSystem(request)) {
filterChain.doFilter(request, response);
return;
}
try {
// Determine credit cost from annotation
int creditCost = getCreditCostForEndpoint(request);
User user = getCurrentUser();
String ipAddress = getClientIpAddress(request);
String userAgent = request.getHeader("User-Agent");
// Create request context for tracking
String requestId = CreditRequestContext.generateRequestId();
CreditRequestContext context =
new CreditRequestContext(
requestId,
user,
ipAddress,
userAgent,
creditCost,
request.getRequestURI());
contextManager.setContext(context);
ApiCreditService.CreditStatus status;
if (user == null) {
// Handle anonymous users with same pre-check approach as authenticated users
if (!anonymousCreditSystemEnabled) {
filterChain.doFilter(request, response);
return;
}
status = creditService.preCheckAnonymousCredits(ipAddress, userAgent, creditCost);
context.setCreditsPreChecked(true);
addCreditHeaders(response, status);
if (!status.allowed()) {
handleAnonymousCreditExceeded(response, status);
return;
}
} else {
// Handle authenticated users with pre-check approach
status = creditService.preCheckCredits(user, creditCost);
context.setCreditsPreChecked(true);
addCreditHeaders(response, status);
if (!status.allowed()) {
handleCreditExceeded(response, user, status);
return;
}
}
filterChain.doFilter(request, response);
} finally {
// Always clear context at end of request
contextManager.clearContext();
}
}
private boolean shouldApplyCreditSystem(HttpServletRequest request) {
if (!creditSystemEnabled) {
return false;
}
// Use the shared matcher to determine if this endpoint should be credit-limited
return apiJobEndpointMatcher.matches(request);
}
private int getCreditCostForEndpoint(HttpServletRequest request) {
try {
HandlerExecutionChain chain = handlerMapping.getHandler(request);
if (chain == null) {
return defaultCreditCost;
}
Object handler = chain.getHandler();
if (!(handler instanceof HandlerMethod handlerMethod)) {
return defaultCreditCost;
}
Method method = handlerMethod.getMethod();
AutoJobPostMapping annotation = method.getAnnotation(AutoJobPostMapping.class);
if (annotation == null) {
return defaultCreditCost;
}
// Use resourceWeight as credit cost, with minimum of 1 and maximum of 100
return Math.max(1, Math.min(100, annotation.resourceWeight()));
} catch (Exception e) {
log.debug(
"Could not determine credit cost for {}: {}",
request.getRequestURI(),
e.getMessage());
return defaultCreditCost;
}
}
// TODO: improve with Redis and async in future V2.1
private User getCurrentUser() {
try {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return null;
}
String username = authentication.getName();
if ("anonymousUser".equals(username)) {
return null;
}
return userService.findByUsername(username).orElse(null);
} catch (Exception e) {
log.error("Error getting user for rate limiting: {}", e.getMessage());
return null;
}
}
private void addCreditHeaders(
HttpServletResponse response, ApiCreditService.CreditStatus status) {
// Calculate window length (seconds in current month)
YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
YearMonth nextMonth = currentMonth.plusMonths(1);
ZonedDateTime startOfMonth = currentMonth.atDay(1).atStartOfDay(ZoneOffset.UTC);
ZonedDateTime startOfNextMonth = nextMonth.atDay(1).atStartOfDay(ZoneOffset.UTC);
long windowSeconds = java.time.Duration.between(startOfMonth, startOfNextMonth).getSeconds();
// Use standard RateLimit headers (IETF draft-ietf-httpapi-ratelimit-headers) adapted for
// credits
response.setHeader("RateLimit-Limit", String.valueOf(status.monthlyCredits()));
response.setHeader("RateLimit-Remaining", String.valueOf(status.remaining()));
response.setHeader(
"RateLimit-Reset",
String.valueOf(getSecondsUntilReset())); // Delta seconds to reset
response.setHeader(
"RateLimit-Policy",
String.format(
"%d;w=%d;comment=\"%s\"",
status.monthlyCredits(), windowSeconds, status.scope()));
response.setHeader("X-Credits-Used-This-Month", String.valueOf(status.creditsConsumed()));
// Add Retry-After for 429 responses
if (!status.allowed()) {
response.setHeader("Retry-After", String.valueOf(getSecondsUntilReset()));
}
}
private long getNextMonthResetEpochMillis() {
YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
YearMonth nextMonth = currentMonth.plusMonths(1);
ZonedDateTime resetTime = nextMonth.atDay(1).atStartOfDay(ZoneOffset.UTC);
return resetTime.toInstant().toEpochMilli();
}
private long getSecondsUntilReset() {
return (getNextMonthResetEpochMillis() - System.currentTimeMillis()) / 1000;
}
private void handleCreditExceeded(
HttpServletResponse response, User user, ApiCreditService.CreditStatus status)
throws IOException {
log.warn("Credit limit exceeded for user: {} - {}", user.getUsername(), status.reason());
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
var errorResponse =
new CreditErrorResponse(
"Credit limit exceeded",
status.reason(),
status.creditsConsumed(),
status.monthlyCredits(),
status.remaining(),
status.scope(),
getNextMonthResetEpochMillis());
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
}
private record CreditErrorResponse(
String error,
String message,
int creditsConsumed,
int monthlyCredits,
int creditsRemaining,
String scope,
long resetEpochMillis) {}
private String getClientIpAddress(HttpServletRequest request) {
// Check for proxy headers
String[] headers = {
"X-Forwarded-For",
"X-Real-IP",
"Proxy-Client-IP",
"WL-Proxy-Client-IP",
"HTTP_X_FORWARDED_FOR",
"HTTP_X_FORWARDED",
"HTTP_X_CLUSTER_CLIENT_IP",
"HTTP_CLIENT_IP",
"HTTP_FORWARDED_FOR",
"HTTP_FORWARDED",
"HTTP_VIA",
"REMOTE_ADDR"
};
for (String header : headers) {
String ip = request.getHeader(header);
if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
// Handle comma-separated IPs (in case of multiple proxies)
int commaIndex = ip.indexOf(',');
if (commaIndex > 0) {
ip = ip.substring(0, commaIndex).trim();
}
return ip;
}
}
// Fallback to remote address
return request.getRemoteAddr();
}
private void handleAnonymousCreditExceeded(
HttpServletResponse response, ApiCreditService.CreditStatus status) throws IOException {
log.warn("Anonymous credit limit exceeded - {}", status.reason());
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
var errorResponse =
new CreditErrorResponse(
"Credit limit exceeded",
status.reason() + " - Please login for higher limits",
status.creditsConsumed(),
status.monthlyCredits(),
status.remaining(),
status.scope(),
getNextMonthResetEpochMillis());
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
}
}
@@ -1,164 +0,0 @@
package stirling.software.proprietary.security.filter;
import java.io.IOException;
import java.time.YearMonth;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpServletResponseWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.CreditRequestContext;
import stirling.software.proprietary.model.FailureType;
import stirling.software.proprietary.service.ApiCreditService;
import stirling.software.proprietary.service.CreditContextManager;
/**
* Filter that runs after API processing to record credit outcomes based on response status and any
* exceptions that occurred
*/
@Component
@Order(100) // Run after ApiCreditFilter (Order=1) and other processing
@RequiredArgsConstructor
@Slf4j
public class CreditOutcomeFilter extends OncePerRequestFilter {
private final CreditContextManager contextManager;
private final ApiCreditService creditService;
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// Only process if we have credit context (meaning this was a credit-tracked request)
CreditRequestContext context = contextManager.getContext();
if (context == null || !context.isCreditsPreChecked()) {
filterChain.doFilter(request, response);
return;
}
// Set response in context for header updates later
context.setHttpResponse(response);
// Wrap response to capture status without buffering body
StatusCaptureResponseWrapper responseWrapper = new StatusCaptureResponseWrapper(response);
Exception processingException = null;
try {
filterChain.doFilter(request, responseWrapper);
} catch (Exception e) {
processingException = e;
throw e; // Re-throw to maintain normal exception handling
} finally {
// Record the outcome based on response status and any exception
recordCreditOutcome(context, responseWrapper.getStatusCode(), processingException);
}
}
private void recordCreditOutcome(
CreditRequestContext context, int httpStatus, Exception exception) {
try {
FailureType outcome = ApiCreditService.determineFailureType(httpStatus, exception);
if (context.isAnonymous()) {
// For anonymous users, just log the outcome (credits already consumed)
creditService.recordAnonymousRequestOutcome(
context.getIpAddress(),
context.getUserAgent(),
context.getCreditCost(),
outcome);
} else {
// For authenticated users, this determines if/how credits are charged
ApiCreditService.CreditStatus status = creditService.recordRequestOutcome(
context.getUser(), context.getCreditCost(), outcome);
// Update response headers to reflect post-charge state
if (status != null) {
updateCreditHeaders(context.getHttpResponse(), status, context.getCreditCost());
}
}
} catch (Exception e) {
log.error(
"Error recording credit outcome for request {}: {}",
context.getRequestId(),
e.getMessage(),
e);
// On error recording outcome, default to charging credits to be safe
if (!context.isAnonymous()) {
try {
creditService.recordRequestOutcome(
context.getUser(),
context.getCreditCost(),
FailureType.PROCESSING_ERROR);
} catch (Exception e2) {
log.error("Failed to record fallback credit charge: {}", e2.getMessage());
}
}
}
}
/**
* Lightweight response wrapper that captures HTTP status without buffering the response body.
* This avoids memory issues with large PDF responses while still allowing us to track outcomes.
*/
private static class StatusCaptureResponseWrapper extends HttpServletResponseWrapper {
private int httpStatus = HttpServletResponse.SC_OK;
public StatusCaptureResponseWrapper(HttpServletResponse response) {
super(response);
}
@Override
public void setStatus(int sc) {
this.httpStatus = sc;
super.setStatus(sc);
}
@Override
public void sendError(int sc) throws IOException {
this.httpStatus = sc;
super.sendError(sc);
}
@Override
public void sendError(int sc, String msg) throws IOException {
this.httpStatus = sc;
super.sendError(sc, msg);
}
public int getStatusCode() {
return httpStatus;
}
}
private void updateCreditHeaders(HttpServletResponse response, ApiCreditService.CreditStatus status, int creditCost) {
// Calculate window length (seconds in current month)
YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
YearMonth nextMonth = currentMonth.plusMonths(1);
ZonedDateTime startOfMonth = currentMonth.atDay(1).atStartOfDay(ZoneOffset.UTC);
ZonedDateTime startOfNextMonth = nextMonth.atDay(1).atStartOfDay(ZoneOffset.UTC);
long windowSeconds = java.time.Duration.between(startOfMonth, startOfNextMonth).getSeconds();
long resetSeconds = java.time.Duration.between(ZonedDateTime.now(ZoneOffset.UTC), startOfNextMonth).getSeconds();
// Update headers to reflect post-charge state
response.setHeader("RateLimit-Limit", String.valueOf(status.monthlyCredits()));
response.setHeader("RateLimit-Remaining", String.valueOf(status.remaining()));
response.setHeader("RateLimit-Reset", String.valueOf(resetSeconds));
response.setHeader("RateLimit-Policy", String.format("%d;w=%d;comment=\"%s\"", status.monthlyCredits(), windowSeconds, status.scope()));
response.setHeader("X-Credits-Used-This-Month", String.valueOf(status.creditsConsumed()));
response.setHeader("X-Credit-Cost", String.valueOf(creditCost));
}
}
@@ -1,95 +0,0 @@
package stirling.software.proprietary.security.matcher;
import java.lang.reflect.Method;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerExecutionChain;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
/**
* Shared matcher component to determine if a request should be subject to anonymous API access and
* credit limiting. This ensures consistent behavior between UserAuthenticationFilter and
* ApiCreditFilter.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class ApiJobEndpointMatcher {
private final RequestMappingHandlerMapping handlerMapping;
@Value("${api.credit-system.exclude-settings:true}")
private boolean excludeSettings;
@Value("${api.credit-system.exclude-actuator:true}")
private boolean excludeActuator;
/**
* Determines if a request matches the criteria for API job endpoints that should be
* credit-limited and allowed for anonymous access.
*
* @param request the HTTP request to check
* @return true if the request is a POST to an @AutoJobPostMapping endpoint
*/
public boolean matches(HttpServletRequest request) {
// Only POST requests are considered
if (!"POST".equalsIgnoreCase(request.getMethod())) {
return false;
}
String path = request.getRequestURI();
// Apply exclusion rules
if (excludeActuator && path != null && path.startsWith("/actuator")) {
return false;
}
if (excludeSettings && isSettingsEndpoint(path)) {
return false;
}
// Check if the handler method has @AutoJobPostMapping annotation
return hasAutoJobPostMapping(request);
}
private boolean hasAutoJobPostMapping(HttpServletRequest request) {
try {
HandlerExecutionChain chain = handlerMapping.getHandler(request);
if (chain == null) {
return false;
}
Object handler = chain.getHandler();
if (!(handler instanceof HandlerMethod handlerMethod)) {
return false;
}
Method method = handlerMethod.getMethod();
return method.isAnnotationPresent(AutoJobPostMapping.class);
} catch (Exception e) {
log.trace(
"Could not resolve handler for {}: {}",
request.getRequestURI(),
e.getMessage());
return false;
}
}
private boolean isSettingsEndpoint(String path) {
return path != null
&& (path.contains("/settings")
|| path.contains("/update-enable-analytics")
|| path.contains("/config")
|| path.contains("/preferences"));
}
}
@@ -1,193 +0,0 @@
package stirling.software.proprietary.security.model;
import java.io.Serializable;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import jakarta.persistence.*;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
@Entity
@Table(name = "users")
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
public class User implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long id;
@Column(name = "username", unique = true)
private String username;
@Column(name = "password")
private String password;
@Column(name = "apiKey")
private String apiKey;
@Column(name = "enabled")
private boolean enabled;
@Column(name = "isFirstLogin")
private Boolean isFirstLogin = false;
@Column(name = "roleName")
private String roleName;
@Column(name = "authenticationtype")
private String authenticationType;
@OneToMany(
fetch = FetchType.EAGER,
cascade = CascadeType.ALL,
mappedBy = "user",
orphanRemoval = true)
private Set<Authority> authorities = new HashSet<>();
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "team_id")
private Team team;
@ElementCollection
@MapKeyColumn(name = "setting_key")
@Lob
@Column(name = "setting_value", columnDefinition = "text")
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
private Map<String, String> settings = new HashMap<>(); // Key-value pairs of settings.
public String getRoleName() {
return Role.getRoleNameByRoleId(getRolesAsString());
}
public boolean isFirstLogin() {
return isFirstLogin != null && isFirstLogin;
}
public void setFirstLogin(boolean isFirstLogin) {
this.isFirstLogin = isFirstLogin;
}
public void setAuthenticationType(AuthenticationType authenticationType) {
this.authenticationType = authenticationType.toString().toLowerCase();
}
public void addAuthorities(Set<Authority> authorities) {
this.authorities.addAll(authorities);
}
public void addAuthority(Authority authority) {
this.authorities.add(authority);
}
public String getRolesAsString() {
return this.authorities.stream()
.map(Authority::getAuthority)
.collect(Collectors.joining(", "));
}
public boolean hasPassword() {
return this.password != null && !this.password.isEmpty();
}
public stirling.software.proprietary.model.Organization getOrganization() {
return this.team != null ? this.team.getOrganization() : null;
}
// Role-based permission methods
public Role getUserRole() {
String roleString = getRolesAsString();
if (roleString == null || roleString.isEmpty()) return Role.USER;
try {
return Role.fromString(roleString);
} catch (IllegalArgumentException e) {
return Role.USER; // Default fallback
}
}
public boolean isSystemAdmin() {
Role role = getUserRole();
return role.isSystemAdmin();
}
public boolean isOrgAdmin() {
Role role = getUserRole();
return role.isOrgAdmin();
}
public boolean isTeamLead() {
Role role = getUserRole();
return role.isTeamLead();
}
public boolean canManageUser(User otherUser) {
// System admins can manage anyone
if (isSystemAdmin()) return true;
// Org admins can manage users in their organization
if (isOrgAdmin()) {
stirling.software.proprietary.model.Organization thisOrg = getOrganization();
stirling.software.proprietary.model.Organization otherOrg = otherUser.getOrganization();
return thisOrg != null && otherOrg != null && thisOrg.getId().equals(otherOrg.getId());
}
// Team leads can manage users in their team
if (isTeamLead()) {
return this.team != null
&& otherUser.team != null
&& this.team.getId().equals(otherUser.team.getId());
}
return false;
}
public boolean canManageTeam(stirling.software.proprietary.model.Team targetTeam) {
if (targetTeam == null) return false;
// System admins can manage any team
if (isSystemAdmin()) return true;
// Org admins can manage teams in their organization
if (isOrgAdmin()) {
stirling.software.proprietary.model.Organization thisOrg = getOrganization();
stirling.software.proprietary.model.Organization teamOrg = targetTeam.getOrganization();
return thisOrg != null && teamOrg != null && thisOrg.getId().equals(teamOrg.getId());
}
// Team leads can only manage their own team
if (isTeamLead()) {
return this.team != null && this.team.getId().equals(targetTeam.getId());
}
return false;
}
public void setUserRole(Role role) {
// Clear existing authorities
this.authorities.clear();
// Add new authority
Authority authority = new Authority();
authority.setAuthority(role.getRoleId());
authority.setUser(this);
this.authorities.add(authority);
}
}
@@ -1,22 +0,0 @@
package stirling.software.proprietary.security.model.api.admin;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "Response containing a setting key and its current value")
public class SettingValueResponse {
@Schema(
description = "The setting key in dot notation (e.g., 'system.enableAnalytics')",
example = "system.enableAnalytics")
private String key;
@Schema(description = "The current value of the setting", example = "true")
private Object value;
}
@@ -1,16 +0,0 @@
package stirling.software.proprietary.security.model.api.admin;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@Schema(description = "Request to update a single setting value")
public class UpdateSettingValueRequest {
@NotNull
@Schema(description = "The new value for the setting", example = "false")
private Object value;
}
@@ -1,23 +0,0 @@
package stirling.software.proprietary.security.model.api.admin;
import java.util.Map;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@Schema(description = "Request to update multiple application settings using delta updates")
public class UpdateSettingsRequest {
@NotNull
@NotEmpty
@Schema(
description =
"Map of setting keys to their new values using dot notation. Only changed values need to be included for delta updates.",
example = "{\"system.enableAnalytics\": false, \"ui.appName\": \"My PDF Tool\"}")
private Map<String, Object> settings;
}
@@ -1,123 +0,0 @@
package stirling.software.proprietary.security.repository;
import java.time.Instant;
import java.time.YearMonth;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import jakarta.persistence.LockModeType;
import stirling.software.proprietary.model.AnonymousCreditUsage;
@Repository
public interface AnonymousCreditUsageRepository extends JpaRepository<AnonymousCreditUsage, Long> {
Optional<AnonymousCreditUsage> findByFingerprintAndMonth(String fingerprint, YearMonth month);
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<AnonymousCreditUsage> findByFingerprintAndMonthForUpdate(String fingerprint, YearMonth month);
@Query(
"SELECT u FROM AnonymousCreditUsage u WHERE u.fingerprint = :fingerprint ORDER BY u.month DESC")
List<AnonymousCreditUsage> findByFingerprintOrderByMonthDesc(
@Param("fingerprint") String fingerprint);
@Query(
"SELECT u FROM AnonymousCreditUsage u WHERE u.ipAddress = :ipAddress AND u.month = :month")
List<AnonymousCreditUsage> findByIpAddressAndMonth(
@Param("ipAddress") String ipAddress, @Param("month") YearMonth month);
@Query(
"SELECT u FROM AnonymousCreditUsage u WHERE u.isBlocked = true ORDER BY u.updatedAt DESC")
List<AnonymousCreditUsage> findAllBlockedUsers();
@Query(
"SELECT u FROM AnonymousCreditUsage u WHERE u.abuseScore >= :threshold ORDER BY u.abuseScore DESC, u.updatedAt DESC")
List<AnonymousCreditUsage> findHighAbuseScoreUsers(@Param("threshold") int threshold);
@Query(
"SELECT u FROM AnonymousCreditUsage u WHERE u.month = :month ORDER BY u.creditsConsumed DESC")
List<AnonymousCreditUsage> findTopAnonymousConsumersByMonth(@Param("month") YearMonth month);
@Modifying
@Query(
"UPDATE AnonymousCreditUsage u SET u.isBlocked = :blocked WHERE u.fingerprint = :fingerprint")
int updateBlockedStatus(
@Param("fingerprint") String fingerprint, @Param("blocked") boolean blocked);
@Modifying
@Query("DELETE FROM AnonymousCreditUsage u WHERE u.month < :cutoffMonth")
int deleteOldRecords(@Param("cutoffMonth") YearMonth cutoffMonth);
@Query(
"SELECT COUNT(u) FROM AnonymousCreditUsage u WHERE u.month = :month AND u.lastAccess >= :since")
long countActiveAnonymousUsers(@Param("month") YearMonth month, @Param("since") Instant since);
@Query(
"""
SELECT u FROM AnonymousCreditUsage u
WHERE u.fingerprint IN :fingerprints
AND u.month = :month
""")
List<AnonymousCreditUsage> findRelatedFingerprints(
@Param("fingerprints") List<String> fingerprints, @Param("month") YearMonth month);
default boolean consumeAnonymousCredits(
String fingerprint, YearMonth month, int creditCost, int monthlyCredits,
String ipAddress, String userAgent) {
for (int attempt = 0; attempt < 2; attempt++) {
try {
// Use pessimistic locking to prevent concurrent overspending
Optional<AnonymousCreditUsage> existingUsage = findByFingerprintAndMonthForUpdate(fingerprint, month);
AnonymousCreditUsage usage =
existingUsage.orElseGet(
() -> {
// Create new usage record if it doesn't exist
AnonymousCreditUsage newUsage = AnonymousCreditUsage.builder()
.fingerprint(fingerprint)
.month(month)
.creditsConsumed(0)
.creditsAllocated(monthlyCredits)
.ipAddress(ipAddress)
.userAgent(userAgent)
.abuseScore(0)
.isBlocked(false)
.build();
return saveAndFlush(newUsage);
});
if (Boolean.TRUE.equals(usage.getIsBlocked())) {
return false;
}
// Check if credits are available
if (!usage.hasCreditsRemaining(creditCost)) {
return false;
}
// Consume credits atomically
usage.setCreditsConsumed(usage.getCreditsConsumed() + creditCost);
usage.setCreditsAllocated(monthlyCredits);
usage.setLastAccess(java.time.Instant.now());
saveAndFlush(usage);
return true;
} catch (org.springframework.dao.DataIntegrityViolationException e) {
// Another thread created/updated concurrently; retry once
if (attempt == 1) {
throw e; // Re-throw if second attempt fails
}
// Continue to retry
}
}
return false;
}
}
@@ -1,37 +0,0 @@
package stirling.software.proprietary.security.repository;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import stirling.software.proprietary.model.ApiCreditConfig;
import stirling.software.proprietary.model.ApiCreditConfig.ScopeType;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.security.model.User;
@Repository
public interface ApiCreditConfigRepository extends JpaRepository<ApiCreditConfig, Long> {
Optional<ApiCreditConfig> findByUserAndIsActiveTrue(User user);
Optional<ApiCreditConfig> findByOrganizationAndIsActiveTrue(Organization organization);
Optional<ApiCreditConfig> findByScopeTypeAndRoleNameAndIsActiveTrue(
ScopeType scopeType, String roleName);
@Query(
"""
SELECT c
FROM ApiCreditConfig c
WHERE c.isActive = true
AND c.scopeType = stirling.software.proprietary.model.ApiCreditConfig$ScopeType.ROLE_DEFAULT
AND c.roleName = :roleName
""")
Optional<ApiCreditConfig> findDefaultForRole(@Param("roleName") String roleName);
List<ApiCreditConfig> findAllByIsActiveTrueOrderByCreatedAtDesc();
}
@@ -1,127 +0,0 @@
package stirling.software.proprietary.security.repository;
import java.time.YearMonth;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import jakarta.persistence.LockModeType;
import stirling.software.proprietary.model.ApiCreditUsage;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.security.model.User;
@Repository
public interface ApiCreditUsageRepository extends JpaRepository<ApiCreditUsage, Long> {
Optional<ApiCreditUsage> findByUserAndMonthKey(User user, YearMonth monthKey);
Optional<ApiCreditUsage> findByOrganizationAndMonthKey(
Organization organization, YearMonth monthKey);
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<ApiCreditUsage> findByUserAndMonthKeyForUpdate(User user, YearMonth monthKey);
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<ApiCreditUsage> findByOrganizationAndMonthKeyForUpdate(
Organization organization, YearMonth monthKey);
@Query(
"SELECT COALESCE(u.creditsConsumed, 0) FROM ApiCreditUsage u WHERE u.user = :user AND u.monthKey = :month")
int getUserCreditsConsumed(@Param("user") User user, @Param("month") YearMonth month);
@Query(
"SELECT COALESCE(u.creditsConsumed, 0) FROM ApiCreditUsage u WHERE u.organization = :org AND u.monthKey = :month")
int getOrgCreditsConsumed(@Param("org") Organization org, @Param("month") YearMonth month);
// Note: Native MySQL INSERT ON DUPLICATE KEY UPDATE method removed for database portability
// Use consumeUserCredits() default method instead which handles all database engines
default boolean consumeUserCredits(
User user, YearMonth month, int creditCost, int monthlyCredits) {
for (int attempt = 0; attempt < 2; attempt++) {
try {
// Use pessimistic locking to prevent concurrent overspending
Optional<ApiCreditUsage> existingUsage = findByUserAndMonthKeyForUpdate(user, month);
ApiCreditUsage usage =
existingUsage.orElseGet(
() -> {
// Create new usage record if it doesn't exist
ApiCreditUsage newUsage = ApiCreditUsage.forUser(user, monthlyCredits);
return saveAndFlush(newUsage);
});
// Check if credits are available
if (!usage.hasCreditsRemaining(creditCost)) {
return false;
}
// Consume credits atomically
usage.setCreditsConsumed(usage.getCreditsConsumed() + creditCost);
usage.setCreditsAllocated(monthlyCredits);
saveAndFlush(usage);
return true;
} catch (org.springframework.dao.DataIntegrityViolationException e) {
// Another thread created/updated concurrently; retry once
if (attempt == 1) {
throw e; // Re-throw if second attempt fails
}
// Continue to retry
}
}
return false;
}
default boolean consumeOrgCredits(
Organization org, YearMonth month, int creditCost, int monthlyCredits) {
for (int attempt = 0; attempt < 2; attempt++) {
try {
// Use pessimistic locking to prevent concurrent overspending
Optional<ApiCreditUsage> existingUsage = findByOrganizationAndMonthKeyForUpdate(org, month);
ApiCreditUsage usage =
existingUsage.orElseGet(
() -> {
// Create new usage record if it doesn't exist
ApiCreditUsage newUsage =
ApiCreditUsage.forOrganization(org, monthlyCredits);
return saveAndFlush(newUsage);
});
// Check if credits are available
if (!usage.hasCreditsRemaining(creditCost)) {
return false;
}
// Consume credits atomically
usage.setCreditsConsumed(usage.getCreditsConsumed() + creditCost);
usage.setCreditsAllocated(monthlyCredits);
saveAndFlush(usage);
return true;
} catch (org.springframework.dao.DataIntegrityViolationException e) {
// Another thread created/updated concurrently; retry once
if (attempt == 1) {
throw e; // Re-throw if second attempt fails
}
// Continue to retry
}
}
return false;
}
List<ApiCreditUsage> findByUserOrderByMonthKeyDesc(User user);
List<ApiCreditUsage> findByOrganizationOrderByMonthKeyDesc(Organization organization);
@Query(
"SELECT u FROM ApiCreditUsage u WHERE u.monthKey = :month ORDER BY u.creditsConsumed DESC")
List<ApiCreditUsage> findTopConsumersByMonth(@Param("month") YearMonth month);
}
@@ -1,23 +0,0 @@
package stirling.software.proprietary.security.repository;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.model.dto.OrganizationWithTeamCountDTO;
@Repository
public interface OrganizationRepository extends JpaRepository<Organization, Long> {
Optional<Organization> findByName(String name);
@Query(
"SELECT new stirling.software.proprietary.model.dto.OrganizationWithTeamCountDTO(o.id, o.name, o.description, COUNT(t)) "
+ "FROM Organization o LEFT JOIN o.teams t GROUP BY o.id, o.name, o.description")
List<OrganizationWithTeamCountDTO> findAllOrganizationsWithTeamCount();
boolean existsByNameIgnoreCase(String name);
}
@@ -1,43 +0,0 @@
package stirling.software.proprietary.security.service;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.security.repository.OrganizationRepository;
@Service
@RequiredArgsConstructor
public class OrganizationService {
private final OrganizationRepository organizationRepository;
public static final String DEFAULT_ORG_NAME = "Default Organization";
public static final String INTERNAL_ORG_NAME = "Internal Organization";
public Organization getOrCreateDefaultOrganization() {
return organizationRepository
.findByName(DEFAULT_ORG_NAME)
.orElseGet(
() -> {
Organization defaultOrg = new Organization();
defaultOrg.setName(DEFAULT_ORG_NAME);
defaultOrg.setDescription("Default organization for initial setup");
return organizationRepository.save(defaultOrg);
});
}
public Organization getOrCreateInternalOrganization() {
return organizationRepository
.findByName(INTERNAL_ORG_NAME)
.orElseGet(
() -> {
Organization internalOrg = new Organization();
internalOrg.setName(INTERNAL_ORG_NAME);
internalOrg.setDescription(
"Internal organization for system operations");
return organizationRepository.save(internalOrg);
});
}
}
@@ -1,79 +0,0 @@
package stirling.software.proprietary.security.service;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
@Service
@RequiredArgsConstructor
public class OrganizationValidationService {
private final TeamRepository teamRepository;
/**
* Validates that a user has access to a specific team. Users can only access teams within their
* own organization.
*/
public boolean canUserAccessTeam(User user, Team team) {
if (user == null || team == null) {
return false;
}
Organization userOrg = user.getOrganization();
Organization teamOrg = team.getOrganization();
if (userOrg == null || teamOrg == null) {
return false;
}
return userOrg.getId().equals(teamOrg.getId());
}
/** Validates that a user has access to a specific team by ID. */
public boolean canUserAccessTeam(User user, Long teamId) {
if (user == null || teamId == null) {
return false;
}
Organization userOrg = user.getOrganization();
if (userOrg == null) {
return false;
}
return teamRepository
.findById(teamId)
.map(team -> userOrg.getId().equals(team.getOrganization().getId()))
.orElse(false);
}
/** Validates that two users belong to the same organization. */
public boolean areUsersInSameOrganization(User user1, User user2) {
if (user1 == null || user2 == null) {
return false;
}
Organization org1 = user1.getOrganization();
Organization org2 = user2.getOrganization();
if (org1 == null || org2 == null) {
return false;
}
return org1.getId().equals(org2.getId());
}
/** Validates that a team belongs to a specific organization. */
public boolean isTeamInOrganization(Team team, Organization organization) {
if (team == null || organization == null) {
return false;
}
Organization teamOrg = team.getOrganization();
return teamOrg != null && teamOrg.getId().equals(organization.getId());
}
}
@@ -1,128 +0,0 @@
package stirling.software.proprietary.security.service;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
@Service
@RequiredArgsConstructor
public class RoleBasedAuthorizationService {
private final UserRepository userRepository;
/** Gets the current authenticated user */
public User getCurrentUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return null;
}
String username = authentication.getName();
return userRepository.findByUsername(username).orElse(null);
}
/** Checks if current user can manage users across all organizations (System Admin) */
public boolean canManageAllUsers() {
User currentUser = getCurrentUser();
return currentUser != null && currentUser.isSystemAdmin();
}
/** Checks if current user can manage users within their organization (Org Admin or above) */
public boolean canManageOrgUsers() {
User currentUser = getCurrentUser();
return currentUser != null && currentUser.isOrgAdmin();
}
/** Checks if current user can manage team members (Team Lead or above) */
public boolean canManageTeamUsers() {
User currentUser = getCurrentUser();
return currentUser != null && currentUser.isTeamLead();
}
/** Checks if current user can manage a specific user */
public boolean canManageUser(Long userId) {
User currentUser = getCurrentUser();
if (currentUser == null) return false;
User targetUser = userRepository.findById(userId).orElse(null);
if (targetUser == null) return false;
return currentUser.canManageUser(targetUser);
}
/** Checks if current user can manage a specific team */
public boolean canManageTeam(Team team) {
User currentUser = getCurrentUser();
if (currentUser == null || team == null) return false;
return currentUser.canManageTeam(team);
}
/** Checks if current user can manage teams within their organization */
public boolean canManageOrgTeams() {
User currentUser = getCurrentUser();
return currentUser != null && currentUser.isOrgAdmin();
}
/** Checks if current user can create/manage organizations (System Admin only) */
public boolean canManageOrganizations() {
return canManageAllUsers();
}
/** Checks if current user can assign roles */
public boolean canAssignRole(Role targetRole) {
User currentUser = getCurrentUser();
if (currentUser == null) return false;
// Users can only assign roles that are lower than or equal to their own
return currentUser.getUserRole().hasAuthorityOver(targetRole);
}
/** Checks if current user can remove a user from their team/organization */
public boolean canRemoveUserFromTeam(Long userId) {
return canManageUser(userId);
}
/** Checks if current user can add a user to a specific team */
public boolean canAddUserToTeam(Long userId, Team team) {
User currentUser = getCurrentUser();
if (currentUser == null || team == null) return false;
// Must be able to manage both the user and the target team
return canManageUser(userId) && currentUser.canManageTeam(team);
}
/** Gets the highest role the current user can assign to others */
public Role getMaxAssignableRole() {
User currentUser = getCurrentUser();
if (currentUser == null) return Role.USER;
return switch (currentUser.getUserRole()) {
case SYSTEM_ADMIN, ADMIN -> Role.ORG_ADMIN; // System admins can create org admins
case ORG_ADMIN -> Role.TEAM_LEAD; // Org admins can create team leads
case TEAM_LEAD -> Role.USER; // Team leads can only create regular users
default -> Role.USER;
};
}
/** Checks if current user can view organization details */
public boolean canViewOrganization(Organization organization) {
User currentUser = getCurrentUser();
if (currentUser == null || organization == null) return false;
// System admins can view any org
if (currentUser.isSystemAdmin()) return true;
// Org admins and team leads can view their own organization
Organization userOrg = currentUser.getOrganization();
return userOrg != null && userOrg.getId().equals(organization.getId());
}
}
@@ -1,763 +0,0 @@
package stirling.software.proprietary.service;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.YearMonth;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.AnonymousCreditUsage;
import stirling.software.proprietary.model.ApiCreditConfig;
import stirling.software.proprietary.model.FailureType;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.security.repository.AnonymousCreditUsageRepository;
import stirling.software.proprietary.security.repository.ApiCreditConfigRepository;
import stirling.software.proprietary.security.repository.ApiCreditUsageRepository;
import stirling.software.proprietary.security.model.User;
@Service
@RequiredArgsConstructor
@Slf4j
public class ApiCreditService {
private final ApiCreditConfigRepository configRepository;
private final ApiCreditUsageRepository usageRepository;
private final AnonymousCreditUsageRepository anonymousUsageRepository;
// In-memory tracking of consecutive failures per user and anonymous users (for simple
// implementation)
// TODO: Move to Redis or database for production clustering
private final ConcurrentHashMap<String, Integer> consecutiveFailures =
new ConcurrentHashMap<>();
@Value("${api.credit-system.anonymous.enabled:true}")
private boolean anonymousCreditSystemEnabled;
@Value("${api.credit-system.anonymous.monthly-credits:10}")
private int anonymousMonthlyCredits;
@Value("${api.credit-system.anonymous.abuse-threshold:3}")
private int abuseThreshold;
public record CreditStatus(
boolean allowed,
int creditsConsumed,
int monthlyCredits,
int remaining,
String scope,
String reason) {}
public record CreditMetrics(
int creditsConsumed,
int monthlyCredits,
int remaining,
String scope,
YearMonth month,
boolean isPooled) {}
// TODO: improve with Redis and async in future V2.1
@Transactional
public CreditStatus checkAndConsumeCredits(User user, int creditCost) {
if (user == null) {
return new CreditStatus(false, 0, 0, 0, "NONE", "No user provided");
}
Organization org = user.getOrganization();
String roleName = user.getRoleName();
YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
Optional<ApiCreditConfig> configOpt = resolveEffectiveConfig(user, org, roleName);
if (configOpt.isEmpty()) {
log.warn(
"No credit config found for user: {}, org: {}, role: {}",
user.getUsername(),
org != null ? org.getName() : "null",
roleName);
return new CreditStatus(
true,
0,
Integer.MAX_VALUE,
Integer.MAX_VALUE,
"UNLIMITED",
"No credit limit configured");
}
ApiCreditConfig config = configOpt.get();
if (!config.getIsActive()) {
return new CreditStatus(
true,
0,
Integer.MAX_VALUE,
Integer.MAX_VALUE,
"DISABLED",
"Credit system disabled");
}
String scope = determineScope(config);
int monthlyCredits = config.getMonthlyCredits();
boolean success;
int currentCreditsConsumed;
switch (config.getScopeType()) {
case USER -> {
success =
usageRepository.consumeUserCredits(
user, currentMonth, creditCost, monthlyCredits);
currentCreditsConsumed = usageRepository.getUserCreditsConsumed(user, currentMonth);
}
case ORGANIZATION -> {
if (org == null) {
return new CreditStatus(
false,
0,
0,
0,
scope,
"User has no organization but org-level limit is configured");
}
boolean pooled = Boolean.TRUE.equals(config.getIsPooled());
if (pooled) {
success =
usageRepository.consumeOrgCredits(
org, currentMonth, creditCost, monthlyCredits);
currentCreditsConsumed =
usageRepository.getOrgCreditsConsumed(org, currentMonth);
} else {
success =
usageRepository.consumeUserCredits(
user, currentMonth, creditCost, monthlyCredits);
currentCreditsConsumed =
usageRepository.getUserCreditsConsumed(user, currentMonth);
}
}
case ROLE_DEFAULT -> {
// Role defaults are per-user (not pooled)
success =
usageRepository.consumeUserCredits(
user, currentMonth, creditCost, monthlyCredits);
currentCreditsConsumed = usageRepository.getUserCreditsConsumed(user, currentMonth);
}
default -> {
log.error("Unexpected scope type: {}", config.getScopeType());
return new CreditStatus(false, 0, 0, 0, scope, "Invalid configuration");
}
}
int remaining = Math.max(0, monthlyCredits - currentCreditsConsumed);
if (!success) {
return new CreditStatus(
false,
currentCreditsConsumed,
monthlyCredits,
remaining,
scope,
String.format(
"Monthly credit limit of %d would be exceeded. "
+ "Current consumption: %d, requested: %d",
monthlyCredits, currentCreditsConsumed, creditCost));
}
return new CreditStatus(
true, currentCreditsConsumed, monthlyCredits, remaining, scope, "Success");
}
@Transactional
public CreditStatus checkAndConsumeAnonymousCredits(
String ipAddress, String userAgent, int creditCost) {
if (!anonymousCreditSystemEnabled) {
return new CreditStatus(
true,
0,
Integer.MAX_VALUE,
Integer.MAX_VALUE,
"DISABLED",
"Anonymous credit system disabled");
}
String fingerprint = generateFingerprint(ipAddress, userAgent);
YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
// Try to consume credits atomically
boolean success = anonymousUsageRepository.consumeAnonymousCredits(
fingerprint, currentMonth, creditCost, anonymousMonthlyCredits, ipAddress, userAgent);
// Get current usage state for response
Optional<AnonymousCreditUsage> usageOpt =
anonymousUsageRepository.findByFingerprintAndMonth(fingerprint, currentMonth);
if (usageOpt.isEmpty()) {
// This shouldn't happen but handle gracefully
return new CreditStatus(false, 0, anonymousMonthlyCredits, anonymousMonthlyCredits, "ANONYMOUS", "Unknown error");
}
AnonymousCreditUsage usage = usageOpt.get();
if (Boolean.TRUE.equals(usage.getIsBlocked())) {
return new CreditStatus(
false,
usage.getCreditsConsumed(),
usage.getCreditsAllocated(),
usage.getRemainingCredits(),
"ANONYMOUS",
"IP address is blocked due to abuse");
}
if (!success) {
// Credit consumption failed - handle abuse scoring
usage.setAbuseScore(usage.getAbuseScore() + 1);
if (usage.getAbuseScore() >= abuseThreshold) {
usage.setIsBlocked(true);
log.warn(
"Blocking anonymous user {} due to abuse score: {}",
fingerprint,
usage.getAbuseScore());
}
anonymousUsageRepository.save(usage);
return new CreditStatus(
false,
usage.getCreditsConsumed(),
usage.getCreditsAllocated(),
usage.getRemainingCredits(),
"ANONYMOUS",
String.format(
"Monthly credit limit of %d exceeded. Current consumption: %d",
usage.getCreditsAllocated(), usage.getCreditsConsumed()));
}
// Success - return updated metrics
return new CreditStatus(
true,
usage.getCreditsConsumed(),
usage.getCreditsAllocated(),
usage.getRemainingCredits(),
"ANONYMOUS",
"Success");
}
public Optional<ApiCreditConfig> resolveEffectiveConfig(
User user, Organization org, String roleName) {
// 1. User-specific config (highest priority)
Optional<ApiCreditConfig> userConfig = configRepository.findByUserAndIsActiveTrue(user);
if (userConfig.isPresent()) {
return userConfig;
}
// 2. Organization config (if user belongs to org)
if (org != null) {
Optional<ApiCreditConfig> orgConfig =
configRepository.findByOrganizationAndIsActiveTrue(org);
if (orgConfig.isPresent()) {
return orgConfig;
}
}
// 3. Role default config (lowest priority)
return configRepository.findDefaultForRole(roleName);
}
public void createOrUpdateRoleDefault(String roleName, int monthlyCredits) {
Optional<ApiCreditConfig> existing = configRepository.findDefaultForRole(roleName);
if (existing.isPresent()) {
ApiCreditConfig config = existing.get();
config.setMonthlyCredits(monthlyCredits);
configRepository.save(config);
} else {
ApiCreditConfig newConfig =
ApiCreditConfig.builder()
.scopeType(ApiCreditConfig.ScopeType.ROLE_DEFAULT)
.roleName(roleName)
.monthlyCredits(monthlyCredits)
.isPooled(false)
.isActive(true)
.build();
configRepository.save(newConfig);
}
}
public void createUserCreditConfig(User user, int monthlyCredits, boolean isActive) {
// Check if user already has a config
Optional<ApiCreditConfig> existing = configRepository.findByUserAndIsActiveTrue(user);
if (existing.isPresent()) {
throw new RuntimeException("User already has a credit configuration");
}
ApiCreditConfig newConfig =
ApiCreditConfig.builder()
.scopeType(ApiCreditConfig.ScopeType.USER)
.user(user)
.monthlyCredits(monthlyCredits)
.isPooled(false)
.isActive(isActive)
.build();
configRepository.save(newConfig);
}
public void createOrganizationCreditConfig(Organization org, int monthlyCredits, boolean isPooled, boolean isActive) {
// Check if organization already has a config
Optional<ApiCreditConfig> existing = configRepository.findByOrganizationAndIsActiveTrue(org);
if (existing.isPresent()) {
throw new RuntimeException("Organization already has a credit configuration");
}
ApiCreditConfig newConfig =
ApiCreditConfig.builder()
.scopeType(ApiCreditConfig.ScopeType.ORGANIZATION)
.organization(org)
.monthlyCredits(monthlyCredits)
.isPooled(isPooled)
.isActive(isActive)
.build();
configRepository.save(newConfig);
}
private String determineScope(ApiCreditConfig config) {
return switch (config.getScopeType()) {
case USER ->
"USER:"
+ (config.getUser() != null
? config.getUser().getUsername()
: "unknown");
case ORGANIZATION ->
"ORG:"
+ (config.getOrganization() != null
? config.getOrganization().getName()
: "unknown")
+ (Boolean.TRUE.equals(config.getIsPooled())
? ":POOLED"
: ":INDIVIDUAL");
case ROLE_DEFAULT -> "ROLE:" + config.getRoleName();
};
}
private String generateFingerprint(String ipAddress, String userAgent) {
try {
String input = ipAddress + ":" + (userAgent != null ? userAgent : "");
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(input.getBytes());
return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
} catch (NoSuchAlgorithmException e) {
log.error("SHA-256 not available", e);
return ipAddress; // Fallback to IP address
}
}
public CreditMetrics getUserCreditMetrics(User user) {
YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
Organization org = user.getOrganization();
String roleName = user.getRoleName();
Optional<ApiCreditConfig> configOpt = resolveEffectiveConfig(user, org, roleName);
if (configOpt.isEmpty()) {
return new CreditMetrics(
0, Integer.MAX_VALUE, Integer.MAX_VALUE, "UNLIMITED", currentMonth, false);
}
ApiCreditConfig config = configOpt.get();
if (!config.getIsActive()) {
return new CreditMetrics(
0, Integer.MAX_VALUE, Integer.MAX_VALUE, "DISABLED", currentMonth, false);
}
String scope = determineScope(config);
int monthlyCredits = config.getMonthlyCredits();
int creditsConsumed;
boolean isPooled = false;
switch (config.getScopeType()) {
case USER ->
creditsConsumed = usageRepository.getUserCreditsConsumed(user, currentMonth);
case ORGANIZATION -> {
isPooled = Boolean.TRUE.equals(config.getIsPooled());
if (isPooled && org != null) {
creditsConsumed = usageRepository.getOrgCreditsConsumed(org, currentMonth);
} else {
creditsConsumed = usageRepository.getUserCreditsConsumed(user, currentMonth);
}
}
case ROLE_DEFAULT -> {
// Role defaults are per-user (not pooled)
creditsConsumed = usageRepository.getUserCreditsConsumed(user, currentMonth);
}
default -> creditsConsumed = 0;
}
int remaining = Math.max(0, monthlyCredits - creditsConsumed);
return new CreditMetrics(
creditsConsumed, monthlyCredits, remaining, scope, currentMonth, isPooled);
}
// Methods for handling consecutive failure tracking
private int incrementConsecutiveFailures(User user) {
String userKey = getUserKey(user);
return consecutiveFailures.compute(
userKey, (key, value) -> (value == null) ? 1 : value + 1);
}
private void resetConsecutiveFailures(User user) {
String userKey = getUserKey(user);
consecutiveFailures.remove(userKey);
}
private String getUserKey(User user) {
return "user:" + user.getId();
}
private String getAnonymousKey(String ipAddress, String userAgent) {
return "anon:" + generateFingerprint(ipAddress, userAgent);
}
private int incrementConsecutiveFailures(String ipAddress, String userAgent) {
String anonymousKey = getAnonymousKey(ipAddress, userAgent);
return consecutiveFailures.compute(
anonymousKey, (key, value) -> (value == null) ? 1 : value + 1);
}
private void resetConsecutiveFailures(String ipAddress, String userAgent) {
String anonymousKey = getAnonymousKey(ipAddress, userAgent);
consecutiveFailures.remove(anonymousKey);
}
// Enhanced methods for handling failure-based charging
@Transactional
public CreditStatus preCheckCredits(User user, int creditCost) {
// Only check if credits are available, don't consume yet
return checkCreditsAvailability(user, creditCost);
}
@Transactional
public CreditStatus preCheckAnonymousCredits(
String ipAddress, String userAgent, int creditCost) {
if (!anonymousCreditSystemEnabled) {
return new CreditStatus(
true,
0,
Integer.MAX_VALUE,
Integer.MAX_VALUE,
"DISABLED",
"Anonymous credit system disabled");
}
return checkAnonymousCreditsAvailability(ipAddress, userAgent, creditCost);
}
@Transactional
public CreditStatus recordRequestOutcome(User user, int creditCost, FailureType outcome) {
switch (outcome) {
case SUCCESS -> {
// API succeeded, consume credits and reset failure counter
CreditStatus chargeResult = checkAndConsumeCredits(user, creditCost);
resetConsecutiveFailures(user);
if (chargeResult.allowed()) {
log.debug(
"User {} charged {} credits for successful API call",
user.getUsername(),
creditCost);
} else {
log.warn(
"Failed to charge user {} {} credits on successful API call: {}",
user.getUsername(),
creditCost,
chargeResult.reason());
}
return chargeResult;
}
case CLIENT_ERROR -> {
// Client error: no credit charge, no failure count increment
log.debug("User {} not charged for client error (4xx)", user.getUsername());
return checkCreditsAvailability(user, creditCost);
}
case PROCESSING_ERROR -> {
// Processing error: no immediate charge, but count toward consecutive failures
int consecutiveCount = incrementConsecutiveFailures(user);
if (consecutiveCount >= 3) {
// Charge full credit cost after 3 consecutive processing failures
CreditStatus chargeResult = checkAndConsumeCredits(user, creditCost);
resetConsecutiveFailures(user);
if (chargeResult.allowed()) {
log.warn(
"User {} charged {} credits after {} consecutive processing failures",
user.getUsername(),
creditCost,
consecutiveCount);
} else {
log.error(
"Failed to charge user {} {} credits after {} consecutive failures: {}",
user.getUsername(),
creditCost,
consecutiveCount,
chargeResult.reason());
}
return chargeResult;
} else {
log.debug(
"User {} not charged for processing failure #{}",
user.getUsername(),
consecutiveCount);
return checkCreditsAvailability(user, creditCost);
}
}
}
return null; // Should never reach here
}
@Transactional
public void recordAnonymousRequestOutcome(
String ipAddress, String userAgent, int creditCost, FailureType outcome) {
switch (outcome) {
case SUCCESS -> {
// API succeeded, consume credits and reset failure counter
CreditStatus chargeResult =
checkAndConsumeAnonymousCredits(ipAddress, userAgent, creditCost);
resetConsecutiveFailures(ipAddress, userAgent);
if (chargeResult.allowed()) {
log.debug(
"Anonymous user {} charged {} credits for successful API call",
ipAddress,
creditCost);
} else {
log.warn(
"Failed to charge anonymous user {} {} credits on successful API call: {}",
ipAddress,
creditCost,
chargeResult.reason());
}
}
case CLIENT_ERROR -> {
// Client error: no credit charge, no failure count increment
log.debug("Anonymous user {} not charged for client error (4xx)", ipAddress);
}
case PROCESSING_ERROR -> {
// Processing error: no immediate charge, but count toward consecutive failures
int consecutiveCount = incrementConsecutiveFailures(ipAddress, userAgent);
if (consecutiveCount >= 3) {
// Charge full credit cost after 3 consecutive processing failures
CreditStatus chargeResult =
checkAndConsumeAnonymousCredits(ipAddress, userAgent, creditCost);
resetConsecutiveFailures(ipAddress, userAgent);
if (chargeResult.allowed()) {
log.warn(
"Anonymous user {} charged {} credits after {} consecutive processing failures",
ipAddress,
creditCost,
consecutiveCount);
} else {
log.error(
"Failed to charge anonymous user {} {} credits after {} consecutive failures: {}",
ipAddress,
creditCost,
consecutiveCount,
chargeResult.reason());
}
} else {
log.debug(
"Anonymous user {} not charged for processing failure #{}",
ipAddress,
consecutiveCount);
}
}
}
}
/** Determine failure type based on HTTP status code and exception type */
public static FailureType determineFailureType(int httpStatusCode, Throwable exception) {
// Check exception first - any exception means processing error regardless of status code
if (exception != null) {
String exceptionName = exception.getClass().getSimpleName().toLowerCase();
// Client error indicators in exceptions
if (exceptionName.contains("validation")
|| exceptionName.contains("badrequest")
|| exceptionName.contains("illegalargument")
|| exceptionName.contains("missingparam")
|| exceptionName.contains("unauthorized")
|| exceptionName.contains("forbidden")) {
return FailureType.CLIENT_ERROR;
}
// All other exceptions are processing errors
return FailureType.PROCESSING_ERROR;
}
// No exception - check HTTP status code
if (httpStatusCode >= 200 && httpStatusCode < 300) {
return FailureType.SUCCESS;
}
// Client error cases (4xx) - don't count toward failures
if (httpStatusCode >= 400 && httpStatusCode < 500) {
return FailureType.CLIENT_ERROR;
}
// Server errors (5xx) - count toward consecutive failures
if (httpStatusCode >= 500) {
return FailureType.PROCESSING_ERROR;
}
// Default to processing error for unknown cases to be safe
return FailureType.PROCESSING_ERROR;
}
private CreditStatus checkCreditsAvailability(User user, int creditCost) {
// This is similar to checkAndConsumeCredits but doesn't actually consume
if (user == null) {
return new CreditStatus(false, 0, 0, 0, "NONE", "No user provided");
}
Organization org = user.getOrganization();
String roleName = user.getRoleName();
YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
Optional<ApiCreditConfig> configOpt = resolveEffectiveConfig(user, org, roleName);
if (configOpt.isEmpty()) {
return new CreditStatus(
true,
0,
Integer.MAX_VALUE,
Integer.MAX_VALUE,
"UNLIMITED",
"No credit limit configured");
}
ApiCreditConfig config = configOpt.get();
if (!config.getIsActive()) {
return new CreditStatus(
true,
0,
Integer.MAX_VALUE,
Integer.MAX_VALUE,
"DISABLED",
"Credit system disabled");
}
String scope = determineScope(config);
int monthlyCredits = config.getMonthlyCredits();
int currentCreditsConsumed;
switch (config.getScopeType()) {
case USER -> {
currentCreditsConsumed = usageRepository.getUserCreditsConsumed(user, currentMonth);
}
case ORGANIZATION -> {
if (org == null) {
return new CreditStatus(
false,
0,
0,
0,
scope,
"User has no organization but org-level limit is configured");
}
boolean pooled = Boolean.TRUE.equals(config.getIsPooled());
if (pooled) {
currentCreditsConsumed =
usageRepository.getOrgCreditsConsumed(org, currentMonth);
} else {
currentCreditsConsumed =
usageRepository.getUserCreditsConsumed(user, currentMonth);
}
}
case ROLE_DEFAULT -> {
// Role defaults are per-user (not pooled)
currentCreditsConsumed = usageRepository.getUserCreditsConsumed(user, currentMonth);
}
default -> {
return new CreditStatus(false, 0, 0, 0, scope, "Invalid configuration");
}
}
int remaining = Math.max(0, monthlyCredits - currentCreditsConsumed);
boolean hasEnoughCredits = remaining >= creditCost;
if (!hasEnoughCredits) {
return new CreditStatus(
false,
currentCreditsConsumed,
monthlyCredits,
remaining,
scope,
String.format(
"Insufficient credits. Required: %d, Available: %d",
creditCost, remaining));
}
return new CreditStatus(
true,
currentCreditsConsumed,
monthlyCredits,
remaining,
scope,
"Credits available");
}
private CreditStatus checkAnonymousCreditsAvailability(
String ipAddress, String userAgent, int creditCost) {
String fingerprint = generateFingerprint(ipAddress, userAgent);
YearMonth currentMonth = YearMonth.now(ZoneOffset.UTC);
// Get existing usage record
Optional<AnonymousCreditUsage> existingUsage =
anonymousUsageRepository.findByFingerprintAndMonth(fingerprint, currentMonth);
AnonymousCreditUsage usage =
existingUsage.orElse(
AnonymousCreditUsage.builder()
.fingerprint(fingerprint)
.month(currentMonth)
.creditsConsumed(0)
.creditsAllocated(anonymousMonthlyCredits)
.ipAddress(ipAddress)
.userAgent(userAgent)
.abuseScore(0)
.isBlocked(false)
.build());
if (Boolean.TRUE.equals(usage.getIsBlocked())) {
return new CreditStatus(
false,
usage.getCreditsConsumed(),
usage.getCreditsAllocated(),
usage.getRemainingCredits(),
"ANONYMOUS",
"IP address is blocked due to abuse");
}
if (!usage.hasCreditsRemaining(creditCost)) {
return new CreditStatus(
false,
usage.getCreditsConsumed(),
usage.getCreditsAllocated(),
usage.getRemainingCredits(),
"ANONYMOUS",
String.format(
"Insufficient credits. Required: %d, Available: %d",
creditCost, usage.getRemainingCredits()));
}
return new CreditStatus(
true,
usage.getCreditsConsumed(),
usage.getCreditsAllocated(),
usage.getRemainingCredits(),
"ANONYMOUS",
"Credits available");
}
}
@@ -1,35 +0,0 @@
package stirling.software.proprietary.service;
import org.springframework.stereotype.Component;
import stirling.software.proprietary.model.CreditRequestContext;
/**
* Thread-local storage for credit request context Allows tracking credit information throughout the
* request lifecycle
*/
@Component
public class CreditContextManager {
private static final ThreadLocal<CreditRequestContext> contextHolder = new ThreadLocal<>();
/** Store credit context for the current request thread */
public void setContext(CreditRequestContext context) {
contextHolder.set(context);
}
/** Get credit context for the current request thread */
public CreditRequestContext getContext() {
return contextHolder.get();
}
/** Clear the credit context (should be called at end of request) */
public void clearContext() {
contextHolder.remove();
}
/** Check if there's an active credit context */
public boolean hasContext() {
return contextHolder.get() != null;
}
}
+65 -113
View File
@@ -2,11 +2,11 @@ plugins {
id "java"
id "jacoco"
id "io.spring.dependency-management" version "1.1.7"
id "org.springframework.boot" version "3.5.4"
id "org.springframework.boot" version "3.5.3"
id "org.springdoc.openapi-gradle-plugin" version "1.9.0"
id "io.swagger.swaggerhub" version "1.3.2"
id "edu.sc.seis.launch4j" version "3.0.7"
id "com.diffplug.spotless" version "7.2.1"
id "edu.sc.seis.launch4j" version "3.0.6"
id "com.diffplug.spotless" version "7.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.7.3"
@@ -21,16 +21,15 @@ import java.nio.file.Files
import java.time.Year
ext {
springBootVersion = "3.5.4"
springBootVersion = "3.5.3"
pdfboxVersion = "3.0.5"
imageioVersion = "3.12.0"
lombokVersion = "1.18.38"
bouncycastleVersion = "1.81"
springSecuritySamlVersion = "6.5.2"
springSecuritySamlVersion = "6.5.1"
openSamlVersion = "4.3.2"
commonmarkVersion = "0.25.1"
googleJavaFormatVersion = "1.28.0"
junitPlatformVersion = "1.12.2"
commonmarkVersion = "0.25.0"
googleJavaFormatVersion = "1.27.0"
tempJrePath = null
}
@@ -58,7 +57,7 @@ repositories {
allprojects {
group = 'stirling.software'
version = '1.2.0'
version = '1.0.2'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
@@ -66,11 +65,28 @@ allprojects {
}
}
tasks.register('writeVersion', WriteProperties) {
outputFile = layout.projectDirectory.file('app/common/src/main/resources/version.properties')
println "Writing version.properties to ${outputFile.path}"
comment = "${new Date()}"
property 'version', project.provider { project.version.toString() }
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)
}
}
tasks.named('createExe') {
@@ -83,7 +99,6 @@ subprojects {
apply plugin: 'com.diffplug.spotless'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
apply plugin: 'jacoco'
java {
// 17 is lowest but we support and recommend 21
@@ -127,10 +142,7 @@ subprojects {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.mockito:mockito-inline:5.2.0'
testRuntimeOnly "org.junit.platform:junit-platform-launcher:$junitPlatformVersion"
testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.1.0")
testImplementation "com.squareup.okhttp3:mockwebserver"
testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.12.2'
}
tasks.withType(JavaCompile).configureEach {
@@ -144,90 +156,21 @@ subprojects {
test {
useJUnitPlatform()
finalizedBy jacocoTestReport
}
jacocoTestReport {
dependsOn test
reports {
xml.required.set(true)
csv.required.set(false)
html.required.set(true)
}
}
jacocoTestCoverageVerification {
dependsOn jacocoTestReport
violationRules {
rule {
limit {
minimum = 0.0
}
}
}
}
tasks.named("processResources") {
dependsOn(rootProject.tasks.writeVersion)
}
if (name == 'stirling-pdf') {
apply plugin: 'org.springdoc.openapi-gradle-plugin'
openApi {
apiDocsUrl = "http://localhost:8080/v1/api-docs"
outputDir = file("$projectDir")
outputFileName = "SwaggerDoc.json"
waitTimeInSeconds = 60 // Increase the wait time to 60 seconds
}
tasks.named("forkedSpringBootRun") {
dependsOn(":common:jar")
dependsOn(":proprietary:jar")
}
tasks.register("copySwaggerDoc", Copy) {
doNotTrackState("Writes SwaggerDoc.json to project root")
from(layout.projectDirectory.file("SwaggerDoc.json"))
into(rootProject.projectDir)
dependsOn("generateOpenApiDocs")
}
tasks.register("cleanSwaggerInBuild", Delete) {
doNotTrackState("Cleans up SwaggerDoc.json in build directory")
delete(layout.projectDirectory.file("SwaggerDoc.json"))
dependsOn("copySwaggerDoc")
}
tasks.named("copySwaggerDoc") {
finalizedBy("cleanSwaggerInBuild")
}
tasks.named("generateOpenApiDocs") {
finalizedBy("copySwaggerDoc")
doNotTrackState("OpenAPI plugin writes outside build directory")
}
}
tasks.named("processResources") {
dependsOn(rootProject.tasks.writeVersion)
}
}
tasks.withType(JavaCompile).configureEach {
options.encoding = "UTF-8"
if (!project.hasProperty("noSpotless")) {
dependsOn "spotlessApply"
}
}
gradle.taskGraph.whenReady { graph ->
if (project.hasProperty("noSpotless")) {
tasks.matching { it.name.startsWith("spotless") }.configureEach {
enabled = false
}
}
dependsOn "spotlessApply"
}
licenseReport {
projects = [project]
renderers = [new JsonReportRenderer()]
allowedLicensesFile = project.layout.projectDirectory.file("app/allowed-licenses.json").asFile
outputDir = project.layout.buildDirectory.dir("reports/dependency-license").get().asFile.path
allowedLicensesFile = new File("$projectDir/allowed-licenses.json")
}
sourceSets {
@@ -261,6 +204,13 @@ sourceSets {
}
}
openApi {
apiDocsUrl = "http://localhost:8080/v1/api-docs"
outputDir = file("$projectDir")
outputFileName = "SwaggerDoc.json"
waitTimeInSeconds = 60 // Increase the wait time to 60 seconds
}
// Configure the forked spring boot run task to properly delegate to the stirling-pdf module
tasks.named('forkedSpringBootRun') {
dependsOn ':stirling-pdf:bootRun'
@@ -278,14 +228,14 @@ static def getMacVersion(String version) {
jpackage {
dependsOn(":stirling-pdf:bootJar")
input = layout.projectDirectory.dir("app/core/build/libs")
input = layout.projectDirectory.dir("stirling-pdf/build/libs")
destination = layout.projectDirectory.dir("build/jpackage")
mainJar = "Stirling-PDF-${project.version}.jar"
appName = "Stirling PDF"
appVersion = project.version
vendor = "Stirling PDF Inc"
appDescription = "Stirling PDF - Your Local PDF Editor"
icon = layout.projectDirectory.file("app/core/src/main/resources/static/favicon.ico")
icon = layout.projectDirectory.file("stirling-pdf/src/main/resources/static/favicon.ico")
verbose = true
// mainClass = "org.springframework.boot.loader.launch.JarLauncher"
@@ -327,7 +277,7 @@ jpackage {
// MacOS-specific configuration
mac {
appVersion = getMacVersion(project.version.toString())
icon = layout.projectDirectory.file("app/core/src/main/resources/static/favicon.icns")
icon = layout.projectDirectory.file("stirling-pdf/src/main/resources/static/favicon.icns")
type = ImageType.DMG
macPackageIdentifier = "Stirling PDF"
macPackageName = "Stirling PDF"
@@ -349,7 +299,7 @@ jpackage {
// Linux-specific configuration
linux {
appVersion = project.version
icon = layout.projectDirectory.file("app/core/src/main/resources/static/favicon.png")
icon = layout.projectDirectory.file("stirling-pdf/src/main/resources/static/favicon.png")
type = ImageType.DEB // Can also use "rpm" for Red Hat-based systems
// Debian package configuration
@@ -421,12 +371,12 @@ tasks.register('jpackageMacX64') {
commandLine 'jpackage',
'--type', 'dmg',
'--name', 'Stirling PDF (x86_64)',
'--input', 'app/core/build/libs',
'--input', 'stirling-pdf/build/libs',
'--main-jar', "Stirling-PDF-${project.version}.jar",
'--main-class', 'org.springframework.boot.loader.launch.JarLauncher',
'--runtime-image', file(jrePath + "/zulu-17.jre/Contents/Home"),
'--dest', 'build/jpackage/x86_64',
'--icon', 'app/core/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)',
@@ -512,7 +462,7 @@ tasks.register('cleanTempJre') {
}
launch4j {
icon = "${projectDir}/app/core/src/main/resources/static/favicon.ico"
icon = "${projectDir}/stirling-pdf/src/main/resources/static/favicon.ico"
outfile="Stirling-PDF.exe"
@@ -545,14 +495,16 @@ launch4j {
}
spotless {
yaml {
target '*.yml', '*.yaml'
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
}
format 'gradle', {
target 'build.gradle', 'settings.gradle', 'gradle/*.gradle', 'gradle/**/*.gradle'
java {
target sourceSets.main.allJava
target project(':common').sourceSets.main.allJava
target project(':proprietary').sourceSets.main.allJava
target project(':stirling-pdf').sourceSets.main.allJava
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
toggleOffOn()
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
@@ -590,10 +542,7 @@ dependencies {
}
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly "org.junit.platform:junit-platform-launcher:$junitPlatformVersion"
testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.1.0")
testImplementation "com.squareup.okhttp3:mockwebserver"
testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.12.2'
}
tasks.named("test") {
@@ -616,6 +565,9 @@ tasks.register('printMacVersion') {
}
}
tasks.named('generateOpenApiDocs') {
doNotTrackState("Tracking state is not supported for this task")
}
tasks.named('bootRun') {
group = 'application'
description = 'Delegates to :stirling-pdf:bootRun'

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