mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-06-22 23:45:02 +00:00
Compare commits
76 Commits
d133997372
...
1d174f74fa
Author | SHA1 | Date | |
---|---|---|---|
![]() |
1d174f74fa | ||
![]() |
9b23c0c01d | ||
![]() |
4dc31adc45 | ||
![]() |
a4454fbd3c | ||
![]() |
a598b73a17 | ||
![]() |
38ee18cbad | ||
![]() |
f191207245 | ||
![]() |
38edd9b173 | ||
![]() |
5e20957048 | ||
![]() |
bbaadc1822 | ||
![]() |
9923411ade | ||
![]() |
5a8162ff60 | ||
![]() |
387ae5934d | ||
![]() |
9d5f97c5ad | ||
![]() |
c080158b1c | ||
![]() |
ddad1eddef | ||
![]() |
ec805209a5 | ||
![]() |
50aa5e718d | ||
![]() |
64766a129c | ||
![]() |
cdd1ab704f | ||
![]() |
8632ccb870 | ||
![]() |
a208d55525 | ||
![]() |
552f2ced4d | ||
![]() |
ee41dc11c2 | ||
![]() |
5a272f80b0 | ||
![]() |
2fb13f4f46 | ||
![]() |
45b4588a42 | ||
![]() |
f82662aaaf | ||
![]() |
391bb4545b | ||
![]() |
b3a2bfbe71 | ||
![]() |
03cfad9528 | ||
![]() |
85eb78e707 | ||
![]() |
625900557a | ||
![]() |
d98ebddf49 | ||
![]() |
aaa11fd3e3 | ||
![]() |
ff6353d9ab | ||
![]() |
406695e167 | ||
![]() |
5534f4b64a | ||
![]() |
e74dbf391c | ||
![]() |
3804dd3988 | ||
![]() |
136f16f613 | ||
![]() |
3ddb370f69 | ||
![]() |
fe47cac608 | ||
![]() |
da2473c784 | ||
![]() |
d219198b9b | ||
![]() |
4cb0caaee1 | ||
![]() |
e1fc94929d | ||
![]() |
a2db47d3af | ||
![]() |
0ca23e6835 | ||
![]() |
06db69ed91 | ||
![]() |
c66bf56260 | ||
![]() |
dda3f65f40 | ||
![]() |
9bacebf2e9 | ||
![]() |
f9559151d8 | ||
![]() |
2287d3c08b | ||
![]() |
fbf8f0e419 | ||
![]() |
89580387a2 | ||
![]() |
8fbeeb7161 | ||
![]() |
71ae880a31 | ||
![]() |
23ea86c377 | ||
![]() |
da365c12b4 | ||
![]() |
ffcbf31cca | ||
![]() |
9c83dd270a | ||
![]() |
0b15fa9de0 | ||
![]() |
a49eb3a629 | ||
![]() |
5393ae24cb | ||
![]() |
142dba185c | ||
![]() |
069b71be2c | ||
![]() |
2649b18ab4 | ||
![]() |
3c507eb303 | ||
![]() |
0ee52a4181 | ||
![]() |
d1b677726b | ||
![]() |
0cbe7fe255 | ||
![]() |
493e5daeda | ||
![]() |
bcfe5b7b19 | ||
![]() |
9fc71e851c |
139
.github/labeler-config-srvaroa.yml
vendored
Normal file
139
.github/labeler-config-srvaroa.yml
vendored
Normal file
@ -0,0 +1,139 @@
|
||||
version: 1
|
||||
labels:
|
||||
|
||||
- label: "Bugfix"
|
||||
title: '^fix:.*'
|
||||
|
||||
- label: "enhancement"
|
||||
title: '^feat:.*'
|
||||
|
||||
- label: "build"
|
||||
title: '^build:.*'
|
||||
|
||||
- label: "chore"
|
||||
title: '^chore:.*'
|
||||
|
||||
- label: "ci"
|
||||
title: '^ci:.*'
|
||||
|
||||
- label: "perf"
|
||||
title: '^perf:.*'
|
||||
|
||||
- label: "refactor"
|
||||
title: '^refactor:.*'
|
||||
|
||||
- label: "revert"
|
||||
title: '^revert:.*'
|
||||
|
||||
- label: "style"
|
||||
title: '^style:.*'
|
||||
|
||||
- label: "Documentation"
|
||||
title: '^docs:.*'
|
||||
|
||||
- label: 'API'
|
||||
title: '.*openapi.*'
|
||||
|
||||
- label: 'Translation'
|
||||
files:
|
||||
- 'stirling-pdf/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}.properties'
|
||||
- 'scripts/ignore_translation.toml'
|
||||
- 'stirling-pdf/src/main/resources/templates/fragments/languages.html'
|
||||
- '.github/scripts/check_language_properties.py'
|
||||
|
||||
- label: 'Front End'
|
||||
files:
|
||||
- 'stirling-pdf/src/main/resources/templates/.*'
|
||||
- 'proprietary/src/main/resources/templates/.*'
|
||||
- 'stirling-pdf/src/main/resources/static/.*'
|
||||
- 'proprietary/src/main/resources/static/.*'
|
||||
- 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/web/.*'
|
||||
- 'stirling-pdf/src/main/java/stirling/software/SPDF/UI/.*'
|
||||
|
||||
- label: 'Java'
|
||||
files:
|
||||
- 'common/src/main/java/.*.java'
|
||||
- 'proprietary/src/main/java/.*.java'
|
||||
- 'stirling-pdf/src/main/java/.*.java'
|
||||
|
||||
- label: 'Back End'
|
||||
files:
|
||||
- 'stirling-pdf/src/main/java/stirling/software/SPDF/config/.*'
|
||||
- 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/.*'
|
||||
- 'stirling-pdf/src/main/resources/settings.yml.template'
|
||||
- 'stirling-pdf/src/main/resources/application.properties'
|
||||
- 'stirling-pdf/src/main/resources/banner.txt'
|
||||
- 'scripts/png_to_webp.py'
|
||||
- 'split_photos.py'
|
||||
- 'application.properties'
|
||||
|
||||
- label: 'Security'
|
||||
files:
|
||||
- 'proprietary/src/main/java/stirling/software/proprietary/security/.*'
|
||||
- 'scripts/download-security-jar.sh'
|
||||
- '.github/workflows/dependency-review.yml'
|
||||
- '.github/workflows/scorecards.yml'
|
||||
|
||||
- label: 'API'
|
||||
files:
|
||||
- 'stirling-pdf/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java'
|
||||
- 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java'
|
||||
- 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/.*'
|
||||
- 'stirling-pdf/src/main/java/stirling/software/SPDF/model/api/.*'
|
||||
- 'scripts/png_to_webp.py'
|
||||
- 'split_photos.py'
|
||||
- '.github/workflows/swagger.yml'
|
||||
|
||||
- label: 'Documentation'
|
||||
files:
|
||||
- '.*.md'
|
||||
- 'scripts/counter_translation.py'
|
||||
- 'scripts/ignore_translation.toml'
|
||||
|
||||
- label: 'Docker'
|
||||
files:
|
||||
- '.github/workflows/build.yml'
|
||||
- '.github/workflows/push-docker.yml'
|
||||
- 'Dockerfile'
|
||||
- 'Dockerfile.fat'
|
||||
- 'Dockerfile.ultra-lite'
|
||||
- 'exampleYmlFiles/*.yml'
|
||||
- 'scripts/download-security-jar.sh'
|
||||
- 'scripts/init.sh'
|
||||
- 'scripts/init-without-ocr.sh'
|
||||
- 'scripts/installFonts.sh'
|
||||
- 'test.sh'
|
||||
- 'test2.sh'
|
||||
|
||||
- label: 'Devtools'
|
||||
files:
|
||||
- '.devcontainer/.*'
|
||||
- 'Dockerfile.dev'
|
||||
- '.vscode/.*'
|
||||
- '.editorconfig'
|
||||
- '.pre-commit-config'
|
||||
- '.github/workflows/pre_commit.yml'
|
||||
- 'HowToAddNewLanguage.md'
|
||||
|
||||
- label: 'Test'
|
||||
files:
|
||||
- 'common/src/test/.*'
|
||||
- 'proprietary/src/test/.*'
|
||||
- 'stirling-pdf/src/test/.*'
|
||||
- 'testing/.*'
|
||||
- '.github/workflows/scorecards.yml'
|
||||
|
||||
- label: 'Github'
|
||||
files:
|
||||
- '.github/.*'
|
||||
|
||||
- label: 'Gradle'
|
||||
files:
|
||||
- 'gradle/.*'
|
||||
- 'gradlew'
|
||||
- 'gradlew.bat'
|
||||
- 'settings.gradle'
|
||||
- 'build.gradle'
|
||||
- 'common/build.gradle'
|
||||
- 'proprietary/build.gradle'
|
||||
- 'stirling-pdf/build.gradle'
|
64
.github/labels.yml
vendored
64
.github/labels.yml
vendored
@ -111,3 +111,67 @@
|
||||
- name: "Devtools"
|
||||
color: "FF9E1F"
|
||||
description: "Development tools"
|
||||
- name: "Bugfix"
|
||||
color: "FF9E1F"
|
||||
description: "Pull requests that fix bugs"
|
||||
- name: "Gradle"
|
||||
color: "FF9E1F"
|
||||
description: "Pull requests that update Gradle code"
|
||||
- name: "build"
|
||||
color: "1E90FF"
|
||||
description: "Changes that affect the build system or external dependencies"
|
||||
- name: "chore"
|
||||
color: "FFD700"
|
||||
description: "Routine tasks or maintenance that don't modify src or test files"
|
||||
- name: "ci"
|
||||
color: "4682B4"
|
||||
description: "Changes to CI configuration files and scripts"
|
||||
- name: "perf"
|
||||
color: "FF69B4"
|
||||
description: "Changes that improve performance"
|
||||
- name: "refactor"
|
||||
color: "9932CC"
|
||||
description: "Code changes that neither fix a bug nor add a feature"
|
||||
- name: "revert"
|
||||
color: "DC143C"
|
||||
description: "Reverts a previous commit"
|
||||
- name: "style"
|
||||
color: "FFA500"
|
||||
description: "Changes that do not affect the meaning of the code (formatting, etc.)"
|
||||
- name: "admin"
|
||||
color: "195055"
|
||||
- name: "codex"
|
||||
color: "ededed"
|
||||
description: null
|
||||
- name: "Github"
|
||||
color: "0052CC"
|
||||
- name: "github_actions"
|
||||
color: "000000"
|
||||
description: "Pull requests that update GitHub Actions code"
|
||||
- name: "needs-changes"
|
||||
color: "A65A86"
|
||||
- name: "on-hold"
|
||||
color: "2526F9"
|
||||
- name: "python"
|
||||
color: "2b67c6"
|
||||
description: "Pull requests that update Python code"
|
||||
- name: "size:L"
|
||||
color: "eb9500"
|
||||
description: "This PR changes 100-499 lines ignoring generated files."
|
||||
- name: "size:M"
|
||||
color: "ebb800"
|
||||
description: "This PR changes 30-99 lines ignoring generated files."
|
||||
- name: "size:S"
|
||||
color: "77b800"
|
||||
description: "This PR changes 10-29 lines ignoring generated files."
|
||||
- name: "size:XL"
|
||||
color: "ff823f"
|
||||
description: "This PR changes 500-999 lines ignoring generated files."
|
||||
- name: "size:XS"
|
||||
color: "00ff00"
|
||||
description: "This PR changes 0-9 lines ignoring generated files."
|
||||
- name: "size:XXL"
|
||||
color: "ffb8b8"
|
||||
description: "This PR changes 1000+ lines ignoring generated files."
|
||||
- name: "to research"
|
||||
color: "FBCA04"
|
||||
|
24
.github/scripts/check_language_properties.py
vendored
24
.github/scripts/check_language_properties.py
vendored
@ -196,7 +196,9 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
|
||||
if len(file_list) == 1:
|
||||
file_arr = file_list[0].split()
|
||||
base_dir = os.path.abspath(os.path.join(os.getcwd(), "src", "main", "resources"))
|
||||
base_dir = os.path.abspath(
|
||||
os.path.join(os.getcwd(), "stirling-pdf", "src", "main", "resources")
|
||||
)
|
||||
|
||||
for file_path in file_arr:
|
||||
file_normpath = os.path.normpath(file_path)
|
||||
@ -216,10 +218,19 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
or (
|
||||
# only local windows command
|
||||
not file_normpath.startswith(
|
||||
os.path.join("", "src", "main", "resources", "messages_")
|
||||
os.path.join(
|
||||
"", "stirling-pdf", "src", "main", "resources", "messages_"
|
||||
)
|
||||
)
|
||||
and not file_normpath.startswith(
|
||||
os.path.join(os.getcwd(), "src", "main", "resources", "messages_")
|
||||
os.path.join(
|
||||
os.getcwd(),
|
||||
"stirling-pdf",
|
||||
"src",
|
||||
"main",
|
||||
"resources",
|
||||
"messages_",
|
||||
)
|
||||
)
|
||||
)
|
||||
or not file_normpath.endswith(".properties")
|
||||
@ -377,7 +388,12 @@ if __name__ == "__main__":
|
||||
else:
|
||||
file_list = glob.glob(
|
||||
os.path.join(
|
||||
os.getcwd(), "src", "main", "resources", "messages_*.properties"
|
||||
os.getcwd(),
|
||||
"stirling-pdf",
|
||||
"src",
|
||||
"main",
|
||||
"resources",
|
||||
"messages_*.properties",
|
||||
)
|
||||
)
|
||||
update_missing_keys(args.reference_file, file_list)
|
||||
|
52
.github/workflows/PR-Demo-Comment-with-react.yml
vendored
52
.github/workflows/PR-Demo-Comment-with-react.yml
vendored
@ -38,10 +38,11 @@ jobs:
|
||||
pr_ref: ${{ steps.get-pr-info.outputs.ref }}
|
||||
comment_id: ${{ github.event.comment.id }}
|
||||
disable_security: ${{ steps.check-security-flag.outputs.disable_security }}
|
||||
|
||||
enable_pro: ${{ steps.check-pro-flag.outputs.enable_pro }}
|
||||
enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@ -98,6 +99,25 @@ jobs:
|
||||
echo "disable_security=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Check for pro flag
|
||||
id: check-pro-flag
|
||||
env:
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
if [[ "$COMMENT_BODY" == *"pro"* ]] || [[ "$COMMENT_BODY" == *"premium"* ]]; then
|
||||
echo "pro flags detected in comment"
|
||||
echo "enable_pro=true" >> $GITHUB_OUTPUT
|
||||
echo "enable_enterprise=false" >> $GITHUB_OUTPUT
|
||||
elif [[ "$COMMENT_BODY" == *"enterprise"* ]]; then
|
||||
echo "enterprise flags detected in comment"
|
||||
echo "enable_enterprise=true" >> $GITHUB_OUTPUT
|
||||
echo "enable_pro=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "No pro or enterprise flags detected in comment"
|
||||
echo "enable_pro=false" >> $GITHUB_OUTPUT
|
||||
echo "enable_enterprise=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Add 'in_progress' reaction to comment
|
||||
id: add-eyes-reaction
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
@ -129,7 +149,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@ -165,7 +185,7 @@ jobs:
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
uses: docker/setup-buildx-action@18ce135bb5112fa8ce4ed6c17ab05699d7f3a5e0 # v3.11.0
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
@ -200,15 +220,30 @@ jobs:
|
||||
run: |
|
||||
# Set security settings based on flags
|
||||
if [ "${{ needs.check-comment.outputs.disable_security }}" == "false" ]; then
|
||||
DOCKER_SECURITY="true"
|
||||
DISABLE_ADDITIONAL_FEATURES="false"
|
||||
LOGIN_SECURITY="true"
|
||||
SECURITY_STATUS="🔒 Security Enabled"
|
||||
else
|
||||
DOCKER_SECURITY="false"
|
||||
DISABLE_ADDITIONAL_FEATURES="true"
|
||||
LOGIN_SECURITY="false"
|
||||
SECURITY_STATUS="Security Disabled"
|
||||
fi
|
||||
|
||||
# Set pro/enterprise settings (enterprise implies pro)
|
||||
if [ "${{ needs.check-comment.outputs.enable_enterprise }}" == "true" ]; then
|
||||
PREMIUM_ENABLED="true"
|
||||
PREMIUM_KEY="${{ secrets.ENTERPRISE_KEY }}"
|
||||
PREMIUM_PROFEATURES_AUDIT_ENABLED="true"
|
||||
elif [ "${{ needs.check-comment.outputs.enable_pro }}" == "true" ]; then
|
||||
PREMIUM_ENABLED="true"
|
||||
PREMIUM_KEY="${{ secrets.PREMIUM_KEY }}"
|
||||
PREMIUM_PROFEATURES_AUDIT_ENABLED="true"
|
||||
else
|
||||
PREMIUM_ENABLED="false"
|
||||
PREMIUM_KEY=""
|
||||
PREMIUM_PROFEATURES_AUDIT_ENABLED="false"
|
||||
fi
|
||||
|
||||
# First create the docker-compose content locally
|
||||
cat > docker-compose.yml << EOF
|
||||
version: '3.3'
|
||||
@ -223,7 +258,7 @@ jobs:
|
||||
- /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/config:/configs:rw
|
||||
- /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/logs:/logs:rw
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "${DOCKER_SECURITY}"
|
||||
DISABLE_ADDITIONAL_FEATURES: "${DISABLE_ADDITIONAL_FEATURES}"
|
||||
SECURITY_ENABLELOGIN: "${LOGIN_SECURITY}"
|
||||
SYSTEM_DEFAULTLOCALE: en-GB
|
||||
UI_APPNAME: "Stirling-PDF PR#${{ needs.check-comment.outputs.pr_number }}"
|
||||
@ -232,6 +267,9 @@ jobs:
|
||||
SYSTEM_MAXFILESIZE: "100"
|
||||
METRICS_ENABLED: "true"
|
||||
SYSTEM_GOOGLEVISIBILITY: "false"
|
||||
PREMIUM_KEY: "${PREMIUM_KEY}"
|
||||
PREMIUM_ENABLED: "${PREMIUM_ENABLED}"
|
||||
PREMIUM_PROFEATURES_AUDIT_ENABLED: "${PREMIUM_PROFEATURES_AUDIT_ENABLED}"
|
||||
restart: on-failure:5
|
||||
EOF
|
||||
|
||||
|
2
.github/workflows/PR-Demo-cleanup.yml
vendored
2
.github/workflows/PR-Demo-cleanup.yml
vendored
@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
2
.github/workflows/auto-labeler.yml
vendored
2
.github/workflows/auto-labeler.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
35
.github/workflows/auto-labelerV2.yml
vendored
Normal file
35
.github/workflows/auto-labelerV2.yml
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
name: "Auto Pull Request Labeler V2"
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: srvaroa/labeler@0a20eccb8c94a1ee0bed5f16859aece1c45c3e55 # v1.13.0
|
||||
with:
|
||||
config_path: .github/labeler-config-srvaroa.yml
|
||||
use_local_config: false
|
||||
fail_on_error: true
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ steps.setup-bot.outputs.token }}"
|
59
.github/workflows/build.yml
vendored
59
.github/workflows/build.yml
vendored
@ -21,10 +21,11 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
jdk-version: [17, 21]
|
||||
spring-security: [true, false]
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@ -37,56 +38,41 @@ jobs:
|
||||
java-version: ${{ matrix.jdk-version }}
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Build with Gradle and no spring security
|
||||
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
|
||||
run: ./gradlew clean build
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Build with Gradle and with spring security
|
||||
run: ./gradlew clean build
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: false
|
||||
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.spring-security }}
|
||||
|
||||
- name: Check Test Reports Exist
|
||||
id: check-reports
|
||||
if: always()
|
||||
run: |
|
||||
declare -a dirs=(
|
||||
"stirling-pdf/build/reports/tests/"
|
||||
"stirling-pdf/build/test-results/"
|
||||
"common/build/reports/tests/"
|
||||
"common/build/test-results/"
|
||||
"proprietary/build/reports/tests/"
|
||||
"proprietary/build/test-results/"
|
||||
)
|
||||
missing_reports=()
|
||||
|
||||
# Check for required test report directories
|
||||
if [ ! -d "stirling-pdf/build/reports/tests/" ]; then
|
||||
missing_reports+=("stirling-pdf/build/reports/tests/")
|
||||
fi
|
||||
if [ ! -d "stirling-pdf/build/test-results/" ]; then
|
||||
missing_reports+=("stirling-pdf/build/test-results/")
|
||||
fi
|
||||
if [ ! -d "common/build/reports/tests/" ]; then
|
||||
missing_reports+=("common/build/reports/tests/")
|
||||
fi
|
||||
if [ ! -d "common/build/test-results/" ]; then
|
||||
missing_reports+=("common/build/test-results/")
|
||||
fi
|
||||
if [ ! -d "proprietary/build/reports/tests/" ]; then
|
||||
missing_reports+=("proprietary/build/reports/tests/")
|
||||
fi
|
||||
if [ ! -d "proprietary/build/test-results/" ]; then
|
||||
missing_reports+=("proprietary/build/test-results/")
|
||||
fi
|
||||
|
||||
# Fail if any required reports are missing
|
||||
for dir in "${dirs[@]}"; do
|
||||
if [ ! -d "$dir" ]; then
|
||||
missing_reports+=("$dir")
|
||||
fi
|
||||
done
|
||||
if [ ${#missing_reports[@]} -gt 0 ]; then
|
||||
echo "ERROR: The following required test report directories are missing:"
|
||||
printf '%s\n' "${missing_reports[@]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All required test report directories are present"
|
||||
|
||||
- name: Upload Test Reports
|
||||
if: steps.check-reports.outcome == 'success'
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: test-reports-jdk-${{ matrix.jdk-version }}
|
||||
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
|
||||
path: |
|
||||
stirling-pdf/build/reports/tests/
|
||||
stirling-pdf/build/test-results/
|
||||
@ -98,12 +84,13 @@ jobs:
|
||||
proprietary/build/test-results/
|
||||
proprietary/build/reports/problems/
|
||||
retention-days: 3
|
||||
if-no-files-found: warn
|
||||
|
||||
check-licence:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@ -147,7 +134,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@ -161,7 +148,7 @@ jobs:
|
||||
distribution: "adopt"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
uses: docker/setup-buildx-action@18ce135bb5112fa8ce4ed6c17ab05699d7f3a5e0 # v3.11.0
|
||||
|
||||
- name: Install Docker Compose
|
||||
run: |
|
||||
|
9
.github/workflows/check_properties.yml
vendored
9
.github/workflows/check_properties.yml
vendored
@ -18,7 +18,7 @@ jobs:
|
||||
pull-requests: write # Allow writing to pull requests
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@ -115,8 +115,11 @@ jobs:
|
||||
|
||||
// Filter for relevant files based on the PR changes
|
||||
const changedFiles = files
|
||||
.map(file => file.filename)
|
||||
.filter(file => /^stirling-pdf\src\/main\/resources\/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$/.test(file));
|
||||
.filter(file =>
|
||||
file.status !== "removed" &&
|
||||
/^stirling-pdf\/src\/main\/resources\/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$/.test(file.filename)
|
||||
)
|
||||
.map(file => file.filename);
|
||||
|
||||
console.log("Changed files:", changedFiles);
|
||||
|
||||
|
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
6
.github/workflows/licenses-update.yml
vendored
6
.github/workflows/licenses-update.yml
vendored
@ -19,7 +19,7 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@ -42,7 +42,7 @@ jobs:
|
||||
distribution: "adopt"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
|
||||
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
|
||||
|
||||
- name: Check licenses for compatibility
|
||||
run: ./gradlew clean checkLicense
|
||||
@ -78,7 +78,7 @@ jobs:
|
||||
title: "Update 3rd Party Licenses"
|
||||
body: |
|
||||
Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
|
||||
labels: licenses,github-actions
|
||||
labels: Licenses,github-actions
|
||||
draft: false
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
|
2
.github/workflows/manage-label.yml
vendored
2
.github/workflows/manage-label.yml
vendored
@ -15,7 +15,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
18
.github/workflows/multiOSReleases.yml
vendored
18
.github/workflows/multiOSReleases.yml
vendored
@ -21,7 +21,7 @@ jobs:
|
||||
versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@ -56,7 +56,7 @@ jobs:
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@ -68,7 +68,7 @@ jobs:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
|
||||
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
@ -106,7 +106,7 @@ jobs:
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@ -144,7 +144,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@ -156,7 +156,7 @@ jobs:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
|
||||
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
@ -234,7 +234,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@ -297,7 +297,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@ -306,7 +306,7 @@ jobs:
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
|
||||
uses: softprops/action-gh-release@01570a1f39cb168c169c802c3bceb9e93fb10974 # v2.1.0
|
||||
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
|
||||
with:
|
||||
tag_name: v${{ needs.read_versions.outputs.version }}
|
||||
generate_release_notes: true
|
||||
|
2
.github/workflows/pre_commit.yml
vendored
2
.github/workflows/pre_commit.yml
vendored
@ -16,7 +16,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
6
.github/workflows/push-docker.yml
vendored
6
.github/workflows/push-docker.yml
vendored
@ -18,7 +18,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@ -30,7 +30,7 @@ jobs:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
|
||||
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
uses: docker/setup-buildx-action@18ce135bb5112fa8ce4ed6c17ab05699d7f3a5e0 # v3.11.0
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
|
10
.github/workflows/releaseArtifacts.yml
vendored
10
.github/workflows/releaseArtifacts.yml
vendored
@ -23,7 +23,7 @@ jobs:
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@ -35,7 +35,7 @@ jobs:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
|
||||
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
@ -83,7 +83,7 @@ jobs:
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@ -161,7 +161,7 @@ jobs:
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@ -171,7 +171,7 @@ jobs:
|
||||
name: signed${{ matrix.file_suffix }}
|
||||
|
||||
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
|
||||
uses: softprops/action-gh-release@01570a1f39cb168c169c802c3bceb9e93fb10974 # v2.1.0
|
||||
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
|
||||
with:
|
||||
tag_name: v${{ needs.build.outputs.version }}
|
||||
generate_release_notes: true
|
||||
|
4
.github/workflows/scorecards.yml
vendored
4
.github/workflows/scorecards.yml
vendored
@ -34,7 +34,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@ -74,6 +74,6 @@ jobs:
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
|
||||
uses: github/codeql-action/upload-sarif@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
4
.github/workflows/sonarqube.yml
vendored
4
.github/workflows/sonarqube.yml
vendored
@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@ -27,7 +27,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
|
||||
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
|
||||
|
||||
- name: Build and analyze with Gradle
|
||||
env:
|
||||
|
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@ -16,7 +16,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
4
.github/workflows/swagger.yml
vendored
4
.github/workflows/swagger.yml
vendored
@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@ -26,7 +26,7 @@ jobs:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
|
||||
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
|
||||
|
||||
- name: Generate Swagger documentation
|
||||
run: ./gradlew generateOpenApiDocs
|
||||
|
2
.github/workflows/sync_files.yml
vendored
2
.github/workflows/sync_files.yml
vendored
@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
8
.github/workflows/testdriver.yml
vendored
8
.github/workflows/testdriver.yml
vendored
@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@ -31,7 +31,7 @@ jobs:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
uses: docker/setup-buildx-action@18ce135bb5112fa8ce4ed6c17ab05699d7f3a5e0 # v3.11.0
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
@ -105,7 +105,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@ -134,7 +134,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@ -86,4 +86,9 @@
|
||||
"spring.initializr.defaultLanguage": "Java",
|
||||
"spring.initializr.defaultGroupId": "stirling.software.SPDF",
|
||||
"spring.initializr.defaultArtifactId": "SPDF",
|
||||
"java.project.sourcePaths": [
|
||||
"stirling-pdf/src/main/java",
|
||||
"common/src/main/java",
|
||||
"proprietary/src/main/java"
|
||||
],
|
||||
}
|
||||
|
@ -61,8 +61,16 @@ Make sure to place the entry under the correct language section. This helps main
|
||||
|
||||
#### Windows command
|
||||
|
||||
```ps
|
||||
python .github/scripts/check_language_properties.py --reference-file src\main\resources\messages_en_GB.properties --branch "" --files src\main\resources\messages_pl_PL.properties
|
||||
```powershell
|
||||
python .github/scripts/check_language_properties.py --reference-file stirling-pdf\src\main\resources\messages_en_GB.properties --branch "" --files stirling-pdf\src\main\resources\messages_pl_PL.properties
|
||||
|
||||
python .github/scripts/check_language_properties.py --reference-file src\main\resources\messages_en_GB.properties --branch "" --check-file src\main\resources\messages_pl_PL.properties
|
||||
python .github/scripts/check_language_properties.py --reference-file stirling-pdf\src\main\resources\messages_en_GB.properties --branch "" --check-file stirling-pdf\src\main\resources\messages_pl_PL.properties
|
||||
```
|
||||
|
||||
#### Linux command
|
||||
|
||||
```bash
|
||||
python3 .github/scripts/check_language_properties.py --reference-file stirling-pdf/src/main/resources/messages_en_GB.properties --branch "" --files stirling-pdf/src/main/resources/messages_pl_PL.properties
|
||||
|
||||
python3 .github/scripts/check_language_properties.py --reference-file stirling-pdf/src/main/resources/messages_en_GB.properties --branch "" --check-file stirling-pdf/src/main/resources/messages_pl_PL.properties
|
||||
```
|
||||
|
78
README.md
78
README.md
@ -116,47 +116,47 @@ Stirling-PDF currently supports 40 languages!
|
||||
|
||||
| Language | Progress |
|
||||
| -------------------------------------------- | -------------------------------------- |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| English (English) (en_GB) |  |
|
||||
| English (US) (en_US) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
|
||||
## Stirling PDF Enterprise
|
||||
|
||||
|
@ -124,10 +124,18 @@
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Eclipse Public License 1.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Eclipse Public License - v 1.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Eclipse Public License v2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Eclipse Public License v. 2.0"
|
||||
|
63
build.gradle
63
build.gradle
@ -23,10 +23,11 @@ ext {
|
||||
pdfboxVersion = "3.0.5"
|
||||
imageioVersion = "3.12.0"
|
||||
lombokVersion = "1.18.38"
|
||||
bouncycastleVersion = "1.80"
|
||||
springSecuritySamlVersion = "6.5.0"
|
||||
bouncycastleVersion = "1.81"
|
||||
springSecuritySamlVersion = "6.5.1"
|
||||
openSamlVersion = "4.3.2"
|
||||
commonmarkVersion = "0.24.0"
|
||||
googleJavaFormatVersion = "1.27.0"
|
||||
tempJrePath = null
|
||||
}
|
||||
|
||||
@ -82,6 +83,31 @@ allprojects {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
tasks.register('writeVersion') {
|
||||
def propsFile = file("$projectDir/common/src/main/resources/version.properties")
|
||||
def propsDir = propsFile.parentFile
|
||||
|
||||
doLast {
|
||||
if (propsDir.exists()) {
|
||||
if (propsFile.exists()) {
|
||||
println "File exists: $propsFile"
|
||||
} else {
|
||||
println "$propsFile does not exist. Creating file."
|
||||
propsFile.createNewFile()
|
||||
}
|
||||
} else {
|
||||
println "Creating directory: $propsDir"
|
||||
propsDir.mkdirs()
|
||||
propsFile.createNewFile()
|
||||
}
|
||||
|
||||
def props = new Properties()
|
||||
props.setProperty("version", version)
|
||||
props.store(propsFile.newWriter(), null)
|
||||
}
|
||||
}
|
||||
|
||||
subprojects {
|
||||
apply plugin: 'java'
|
||||
apply plugin: 'java-library'
|
||||
@ -119,7 +145,7 @@ subprojects {
|
||||
|
||||
dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||
implementation 'io.github.pixee:java-security-toolkit:1.2.1'
|
||||
implementation 'io.github.pixee:java-security-toolkit:1.2.2'
|
||||
|
||||
//tmp for security bumps
|
||||
implementation 'ch.qos.logback:logback-core:1.5.18'
|
||||
@ -144,6 +170,10 @@ subprojects {
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
tasks.named("processResources") {
|
||||
dependsOn(rootProject.tasks.writeVersion)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
@ -475,7 +505,7 @@ spotless {
|
||||
target project(':proprietary').sourceSets.main.allJava
|
||||
target project(':stirling-pdf').sourceSets.main.allJava
|
||||
|
||||
googleJavaFormat("1.27.0").aosp().reorderImports(false)
|
||||
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
||||
|
||||
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
||||
toggleOffOn()
|
||||
@ -515,32 +545,9 @@ tasks.named("test") {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure all relevant processes depend on writeVersion
|
||||
processResources.dependsOn(writeVersion)
|
||||
project(':stirling-pdf').tasks.bootJar.dependsOn(writeVersion)
|
||||
|
||||
tasks.register('printVersion') {
|
||||
doLast {
|
||||
|
@ -2,7 +2,18 @@
|
||||
bootRun {
|
||||
enabled = false
|
||||
}
|
||||
spotless {
|
||||
java {
|
||||
target sourceSets.main.allJava
|
||||
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
||||
|
||||
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
||||
toggleOffOn()
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
api 'org.springframework.boot:spring-boot-starter-web'
|
||||
api 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
||||
@ -15,6 +26,7 @@ dependencies {
|
||||
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
|
||||
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
|
||||
api 'org.snakeyaml:snakeyaml-engine:2.9'
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8"
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9"
|
||||
api 'jakarta.mail:jakarta.mail-api:2.1.3'
|
||||
}
|
||||
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.3'
|
||||
}
|
||||
|
@ -8,9 +8,7 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Properties;
|
||||
import java.util.function.Predicate;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
@ -24,6 +22,11 @@ import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@Lazy
|
||||
@ -248,17 +251,16 @@ public class AppConfig {
|
||||
return applicationProperties.getSystem().getDatasource();
|
||||
}
|
||||
|
||||
|
||||
@Bean(name = "runningProOrHigher")
|
||||
@Profile("default")
|
||||
public boolean runningProOrHigher() {
|
||||
return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
@Bean(name = "runningEE")
|
||||
@Profile("default")
|
||||
public boolean runningEnterprise() {
|
||||
return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
@Bean(name = "GoogleDriveEnabled")
|
||||
@ -273,10 +275,9 @@ public class AppConfig {
|
||||
return "NORMAL";
|
||||
}
|
||||
|
||||
|
||||
@Bean(name = "disablePixel")
|
||||
public boolean disablePixel() {
|
||||
return Boolean.parseBoolean(env.getProperty("DISABLE_PIXEL", "false"));
|
||||
return Boolean.parseBoolean(env.getProperty("DISABLE_PIXEL", "false"));
|
||||
}
|
||||
|
||||
@Bean(name = "machineType")
|
||||
|
@ -442,6 +442,7 @@ public class ApplicationProperties {
|
||||
@Data
|
||||
public static class ProFeatures {
|
||||
private boolean ssoAutoLogin;
|
||||
private boolean database;
|
||||
private CustomMetadata customMetadata = new CustomMetadata();
|
||||
private GoogleDrive googleDrive = new GoogleDrive();
|
||||
|
||||
@ -487,6 +488,14 @@ public class ApplicationProperties {
|
||||
@Data
|
||||
public static class EnterpriseFeatures {
|
||||
private PersistentMetrics persistentMetrics = new PersistentMetrics();
|
||||
private Audit audit = new Audit();
|
||||
|
||||
@Data
|
||||
public static class Audit {
|
||||
private boolean enabled = true;
|
||||
private int level = 2; // 0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE
|
||||
private int retentionDays = 90;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class PersistentMetrics {
|
||||
|
@ -1,8 +1,10 @@
|
||||
package stirling.software.common.model.api.converters;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
|
||||
@Data
|
||||
|
@ -0,0 +1,50 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PageMode;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public class AttachmentUtils {
|
||||
|
||||
/**
|
||||
* Sets the PDF catalog viewer preferences to display attachments in the viewer.
|
||||
*
|
||||
* @param document The <code>PDDocument</code> to modify.
|
||||
* @param pageMode The <code>PageMode</code> to set for the PDF viewer. <code>PageMode</code>
|
||||
* values: <code>UseNone</code>, <code>UseOutlines</code>, <code>UseThumbs</code>, <code>
|
||||
* FullScreen</code>, <code>UseOC</code>, <code>UseAttachments</code>.
|
||||
*/
|
||||
public static void setCatalogViewerPreferences(PDDocument document, PageMode pageMode) {
|
||||
try {
|
||||
PDDocumentCatalog catalog = document.getDocumentCatalog();
|
||||
if (catalog != null) {
|
||||
COSDictionary catalogDict = catalog.getCOSObject();
|
||||
|
||||
catalog.setPageMode(pageMode);
|
||||
catalogDict.setName(COSName.PAGE_MODE, pageMode.stringValue());
|
||||
|
||||
COSDictionary viewerPrefs =
|
||||
(COSDictionary) catalogDict.getDictionaryObject(COSName.VIEWER_PREFERENCES);
|
||||
if (viewerPrefs == null) {
|
||||
viewerPrefs = new COSDictionary();
|
||||
catalogDict.setItem(COSName.VIEWER_PREFERENCES, viewerPrefs);
|
||||
}
|
||||
|
||||
viewerPrefs.setName(
|
||||
COSName.getPDFName("NonFullScreenPageMode"), pageMode.stringValue());
|
||||
|
||||
viewerPrefs.setBoolean(COSName.getPDFName("DisplayDocTitle"), true);
|
||||
|
||||
log.info(
|
||||
"Set PDF PageMode to UseAttachments to automatically show attachments pane");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to set catalog viewer preferences for attachments", e);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static stirling.software.common.util.AttachmentUtils.setCatalogViewerPreferences;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
@ -20,13 +22,11 @@ import java.util.Properties;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary;
|
||||
import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PageMode;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
|
||||
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
|
||||
@ -40,7 +40,10 @@ import lombok.Data;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.api.converters.EmlToPdfRequest;
|
||||
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
@ -49,7 +52,7 @@ public class EmlToPdf {
|
||||
private static final class StyleConstants {
|
||||
// Font and layout constants
|
||||
static final int DEFAULT_FONT_SIZE = 12;
|
||||
static final String DEFAULT_FONT_FAMILY = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif";
|
||||
static final String DEFAULT_FONT_FAMILY = "Helvetica, sans-serif";
|
||||
static final float DEFAULT_LINE_HEIGHT = 1.4f;
|
||||
static final String DEFAULT_ZOOM = "1.0";
|
||||
|
||||
@ -70,19 +73,15 @@ public class EmlToPdf {
|
||||
static final int EML_CHECK_LENGTH = 8192;
|
||||
static final int MIN_HEADER_COUNT_FOR_VALID_EML = 2;
|
||||
|
||||
private StyleConstants() {
|
||||
// Utility class - prevent instantiation
|
||||
}
|
||||
private StyleConstants() {}
|
||||
}
|
||||
|
||||
private static final class MimeConstants {
|
||||
static final Pattern MIME_ENCODED_PATTERN = Pattern.compile("=\\?([^?]+)\\?([BbQq])\\?([^?]*)\\?=");
|
||||
static final String PAPERCLIP_EMOJI = "\uD83D\uDCCE"; // 📎
|
||||
static final String ATTACHMENT_ICON_PLACEHOLDER = "icon";
|
||||
static final Pattern MIME_ENCODED_PATTERN =
|
||||
Pattern.compile("=\\?([^?]+)\\?([BbQq])\\?([^?]*)\\?=");
|
||||
static final String ATTACHMENT_MARKER = "@";
|
||||
|
||||
private MimeConstants() {
|
||||
// Utility class - prevent instantiation
|
||||
}
|
||||
private MimeConstants() {}
|
||||
}
|
||||
|
||||
private static final class FileSizeConstants {
|
||||
@ -90,9 +89,7 @@ public class EmlToPdf {
|
||||
static final long BYTES_IN_MB = BYTES_IN_KB * 1024L;
|
||||
static final long BYTES_IN_GB = BYTES_IN_MB * 1024L;
|
||||
|
||||
private FileSizeConstants() {
|
||||
// Utility class - prevent instantiation
|
||||
}
|
||||
private FileSizeConstants() {}
|
||||
}
|
||||
|
||||
// Cached Jakarta Mail availability check
|
||||
@ -101,8 +98,15 @@ public class EmlToPdf {
|
||||
private static boolean isJakartaMailAvailable() {
|
||||
if (jakartaMailAvailable == null) {
|
||||
try {
|
||||
// Check for core Jakarta Mail classes
|
||||
Class.forName("jakarta.mail.internet.MimeMessage");
|
||||
Class.forName("jakarta.mail.Session");
|
||||
Class.forName("jakarta.mail.internet.MimeUtility");
|
||||
Class.forName("jakarta.mail.internet.MimePart");
|
||||
Class.forName("jakarta.mail.internet.MimeMultipart");
|
||||
Class.forName("jakarta.mail.Multipart");
|
||||
Class.forName("jakarta.mail.Part");
|
||||
|
||||
jakartaMailAvailable = true;
|
||||
log.debug("Jakarta Mail libraries are available");
|
||||
} catch (ClassNotFoundException e) {
|
||||
@ -113,7 +117,8 @@ public class EmlToPdf {
|
||||
return jakartaMailAvailable;
|
||||
}
|
||||
|
||||
public static String convertEmlToHtml(byte[] emlBytes, EmlToPdfRequest request) throws IOException {
|
||||
public static String convertEmlToHtml(byte[] emlBytes, EmlToPdfRequest request)
|
||||
throws IOException {
|
||||
validateEmlInput(emlBytes);
|
||||
|
||||
if (isJakartaMailAvailable()) {
|
||||
@ -147,11 +152,14 @@ public class EmlToPdf {
|
||||
}
|
||||
|
||||
// Convert HTML to PDF
|
||||
byte[] pdfBytes = convertHtmlToPdf(weasyprintPath, request, htmlContent, disableSanitize);
|
||||
byte[] pdfBytes =
|
||||
convertHtmlToPdf(weasyprintPath, request, htmlContent, disableSanitize);
|
||||
|
||||
// Attach files if available and requested
|
||||
if (shouldAttachFiles(emailContent, request)) {
|
||||
pdfBytes = attachFilesToPdf(pdfBytes, emailContent.getAttachments(), pdfDocumentFactory);
|
||||
pdfBytes =
|
||||
attachFilesToPdf(
|
||||
pdfBytes, emailContent.getAttachments(), pdfDocumentFactory);
|
||||
}
|
||||
|
||||
return pdfBytes;
|
||||
@ -165,7 +173,7 @@ public class EmlToPdf {
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateEmlInput(byte[] emlBytes) throws IOException {
|
||||
private static void validateEmlInput(byte[] emlBytes) {
|
||||
if (emlBytes == null || emlBytes.length == 0) {
|
||||
throw new IllegalArgumentException("EML file is empty or null");
|
||||
}
|
||||
@ -177,16 +185,19 @@ public class EmlToPdf {
|
||||
|
||||
private static boolean shouldAttachFiles(EmailContent emailContent, EmlToPdfRequest request) {
|
||||
return emailContent != null
|
||||
&& request != null
|
||||
&& request.isIncludeAttachments()
|
||||
&& !emailContent.getAttachments().isEmpty();
|
||||
&& request != null
|
||||
&& request.isIncludeAttachments()
|
||||
&& !emailContent.getAttachments().isEmpty();
|
||||
}
|
||||
|
||||
private static byte[] convertHtmlToPdf(String weasyprintPath, EmlToPdfRequest request,
|
||||
String htmlContent, boolean disableSanitize)
|
||||
private static byte[] convertHtmlToPdf(
|
||||
String weasyprintPath,
|
||||
EmlToPdfRequest request,
|
||||
String htmlContent,
|
||||
boolean disableSanitize)
|
||||
throws IOException, InterruptedException {
|
||||
|
||||
stirling.software.common.model.api.converters.HTMLToPdfRequest htmlRequest = createHtmlRequest(request);
|
||||
HTMLToPdfRequest htmlRequest = createHtmlRequest(request);
|
||||
|
||||
try {
|
||||
return FileToPdf.convertHtmlToPdf(
|
||||
@ -197,7 +208,6 @@ public class EmlToPdf {
|
||||
disableSanitize);
|
||||
} catch (IOException | InterruptedException e) {
|
||||
log.warn("Initial HTML to PDF conversion failed, trying with simplified HTML");
|
||||
// Try with simplified HTML
|
||||
String simplifiedHtml = simplifyHtmlContent(htmlContent);
|
||||
return FileToPdf.convertHtmlToPdf(
|
||||
weasyprintPath,
|
||||
@ -218,8 +228,7 @@ public class EmlToPdf {
|
||||
return "attachment_" + filename.hashCode() + "_" + System.nanoTime();
|
||||
}
|
||||
|
||||
private static String convertEmlToHtmlBasic(
|
||||
byte[] emlBytes, EmlToPdfRequest request) {
|
||||
private static String convertEmlToHtmlBasic(byte[] emlBytes, EmlToPdfRequest request) {
|
||||
if (emlBytes == null || emlBytes.length == 0) {
|
||||
throw new IllegalArgumentException("EML file is empty or null");
|
||||
}
|
||||
@ -249,7 +258,7 @@ public class EmlToPdf {
|
||||
html.append("<html><head><meta charset=\"UTF-8\">\n");
|
||||
html.append("<title>").append(escapeHtml(subject)).append("</title>\n");
|
||||
html.append("<style>\n");
|
||||
appendEnhancedStyles(html, request);
|
||||
appendEnhancedStyles(html);
|
||||
html.append("</style>\n");
|
||||
html.append("</head><body>\n");
|
||||
|
||||
@ -288,7 +297,7 @@ public class EmlToPdf {
|
||||
html.append("<h3>Attachments</h3>\n");
|
||||
html.append(attachmentInfo);
|
||||
|
||||
// Add status message about attachment inclusion
|
||||
// Add a status message about attachment inclusion
|
||||
if (request != null && request.isIncludeAttachments()) {
|
||||
html.append("<div class=\"attachment-inclusion-note\">\n");
|
||||
html.append(
|
||||
@ -306,7 +315,7 @@ public class EmlToPdf {
|
||||
|
||||
// Show advanced features status if requested
|
||||
assert request != null;
|
||||
if (request != null && request.getFileInput().isEmpty()) {
|
||||
if (request.getFileInput().isEmpty()) {
|
||||
html.append("<div class=\"advanced-features-notice\">\n");
|
||||
html.append(
|
||||
"<p><em>Note: Some advanced features require Jakarta Mail dependencies.</em></p>\n");
|
||||
@ -330,12 +339,13 @@ public class EmlToPdf {
|
||||
sessionClass.getMethod("getDefaultInstance", Properties.class);
|
||||
Object session = getDefaultInstance.invoke(null, new Properties());
|
||||
|
||||
// Cast the session object to the proper type for the constructor
|
||||
Class<?>[] constructorArgs = new Class<?>[] {sessionClass, InputStream.class};
|
||||
Constructor<?> mimeMessageConstructor =
|
||||
mimeMessageClass.getConstructor(sessionClass, InputStream.class);
|
||||
mimeMessageClass.getConstructor(constructorArgs);
|
||||
Object message =
|
||||
mimeMessageConstructor.newInstance(session, new ByteArrayInputStream(emlBytes));
|
||||
|
||||
|
||||
return extractEmailContentAdvanced(message, request);
|
||||
|
||||
} catch (ReflectiveOperationException e) {
|
||||
@ -346,8 +356,7 @@ public class EmlToPdf {
|
||||
}
|
||||
}
|
||||
|
||||
private static String convertEmlToHtmlAdvanced(
|
||||
byte[] emlBytes, EmlToPdfRequest request) {
|
||||
private static String convertEmlToHtmlAdvanced(byte[] emlBytes, EmlToPdfRequest request) {
|
||||
EmailContent content = extractEmailContentAdvanced(emlBytes, request);
|
||||
return generateEnhancedEmailHtml(content, request);
|
||||
}
|
||||
@ -479,8 +488,12 @@ public class EmlToPdf {
|
||||
// Create attachment info with paperclip emoji before filename
|
||||
attachmentInfo
|
||||
.append("<div class=\"attachment-item\">")
|
||||
.append("<span class=\"attachment-icon\">").append(MimeConstants.ATTACHMENT_ICON_PLACEHOLDER).append("</span> ")
|
||||
.append("<span class=\"attachment-name\">").append(escapeHtml(filename)).append("</span>");
|
||||
.append("<span class=\"attachment-icon\">")
|
||||
.append(MimeConstants.ATTACHMENT_MARKER)
|
||||
.append("</span> ")
|
||||
.append("<span class=\"attachment-name\">")
|
||||
.append(escapeHtml(filename))
|
||||
.append("</span>");
|
||||
|
||||
// Add content type and encoding info
|
||||
if (!contentType.isEmpty() || !encoding.isEmpty()) {
|
||||
@ -503,17 +516,20 @@ public class EmlToPdf {
|
||||
String content = new String(emlBytes, 0, checkLength, StandardCharsets.UTF_8);
|
||||
String lowerContent = content.toLowerCase();
|
||||
|
||||
boolean hasFrom = lowerContent.contains("from:") || lowerContent.contains("return-path:");
|
||||
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=");
|
||||
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++;
|
||||
@ -636,6 +652,10 @@ public class EmlToPdf {
|
||||
}
|
||||
|
||||
private static String processEmailHtmlBody(String htmlBody) {
|
||||
return processEmailHtmlBody(htmlBody, null);
|
||||
}
|
||||
|
||||
private static String processEmailHtmlBody(String htmlBody, EmailContent emailContent) {
|
||||
if (htmlBody == null) return "";
|
||||
|
||||
String processed = htmlBody;
|
||||
@ -644,10 +664,83 @@ public class EmlToPdf {
|
||||
processed = processed.replaceAll("(?i)\\s*position\\s*:\\s*fixed[^;]*;?", "");
|
||||
processed = processed.replaceAll("(?i)\\s*position\\s*:\\s*absolute[^;]*;?", "");
|
||||
|
||||
// Process inline images (cid: references) if we have email content with attachments
|
||||
if (emailContent != null && !emailContent.getAttachments().isEmpty()) {
|
||||
processed = processInlineImages(processed, emailContent);
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
private static void appendEnhancedStyles(StringBuilder html, EmlToPdfRequest request) {
|
||||
private static String processInlineImages(String htmlContent, EmailContent emailContent) {
|
||||
if (htmlContent == null || emailContent == null) return htmlContent;
|
||||
|
||||
// Create a map of Content-ID to attachment data
|
||||
Map<String, EmailAttachment> contentIdMap = new HashMap<>();
|
||||
for (EmailAttachment attachment : emailContent.getAttachments()) {
|
||||
if (attachment.isEmbedded()
|
||||
&& attachment.getContentId() != null
|
||||
&& attachment.getData() != null) {
|
||||
contentIdMap.put(attachment.getContentId(), attachment);
|
||||
}
|
||||
}
|
||||
|
||||
if (contentIdMap.isEmpty()) return htmlContent;
|
||||
|
||||
// Pattern to match cid: references in img src attributes
|
||||
Pattern cidPattern =
|
||||
Pattern.compile(
|
||||
"(?i)<img[^>]*\\ssrc\\s*=\\s*['\"]cid:([^'\"]+)['\"][^>]*>",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
Matcher matcher = cidPattern.matcher(htmlContent);
|
||||
|
||||
StringBuffer result = new StringBuffer();
|
||||
while (matcher.find()) {
|
||||
String contentId = matcher.group(1);
|
||||
EmailAttachment attachment = contentIdMap.get(contentId);
|
||||
|
||||
if (attachment != null && attachment.getData() != null) {
|
||||
// Convert to data URI
|
||||
String mimeType = attachment.getContentType();
|
||||
if (mimeType == null || mimeType.isEmpty()) {
|
||||
// Try to determine MIME type from filename
|
||||
String filename = attachment.getFilename();
|
||||
if (filename != null) {
|
||||
if (filename.toLowerCase().endsWith(".png")) {
|
||||
mimeType = "image/png";
|
||||
} else if (filename.toLowerCase().endsWith(".jpg")
|
||||
|| filename.toLowerCase().endsWith(".jpeg")) {
|
||||
mimeType = "image/jpeg";
|
||||
} else if (filename.toLowerCase().endsWith(".gif")) {
|
||||
mimeType = "image/gif";
|
||||
} else if (filename.toLowerCase().endsWith(".bmp")) {
|
||||
mimeType = "image/bmp";
|
||||
} else {
|
||||
mimeType = "image/png"; // fallback
|
||||
}
|
||||
} else {
|
||||
mimeType = "image/png"; // fallback
|
||||
}
|
||||
}
|
||||
|
||||
String base64Data = Base64.getEncoder().encodeToString(attachment.getData());
|
||||
String dataUri = "data:" + mimeType + ";base64," + base64Data;
|
||||
|
||||
// Replace the cid: reference with the data URI
|
||||
String replacement =
|
||||
matcher.group(0).replaceFirst("cid:" + Pattern.quote(contentId), dataUri);
|
||||
matcher.appendReplacement(result, Matcher.quoteReplacement(replacement));
|
||||
} else {
|
||||
// Keep original if attachment not found
|
||||
matcher.appendReplacement(result, Matcher.quoteReplacement(matcher.group(0)));
|
||||
}
|
||||
}
|
||||
matcher.appendTail(result);
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private static void appendEnhancedStyles(StringBuilder html) {
|
||||
int fontSize = StyleConstants.DEFAULT_FONT_SIZE;
|
||||
String textColor = StyleConstants.DEFAULT_TEXT_COLOR;
|
||||
String backgroundColor = StyleConstants.DEFAULT_BACKGROUND_COLOR;
|
||||
@ -684,17 +777,19 @@ public class EmlToPdf {
|
||||
html.append(" font-size: ").append(fontSize - 1).append("px;\n");
|
||||
html.append("}\n\n");
|
||||
|
||||
|
||||
html.append(".email-body {\n");
|
||||
html.append(" word-wrap: break-word;\n");
|
||||
html.append("}\n\n");
|
||||
|
||||
|
||||
html.append(".attachment-section {\n");
|
||||
html.append(" margin-top: 15px;\n");
|
||||
html.append(" padding: 10px;\n");
|
||||
html.append(" background-color: ").append(StyleConstants.ATTACHMENT_BACKGROUND_COLOR).append(";\n");
|
||||
html.append(" border: 1px solid ").append(StyleConstants.ATTACHMENT_BORDER_COLOR).append(";\n");
|
||||
html.append(" background-color: ")
|
||||
.append(StyleConstants.ATTACHMENT_BACKGROUND_COLOR)
|
||||
.append(";\n");
|
||||
html.append(" border: 1px solid ")
|
||||
.append(StyleConstants.ATTACHMENT_BORDER_COLOR)
|
||||
.append(";\n");
|
||||
html.append(" border-radius: 3px;\n");
|
||||
html.append("}\n\n");
|
||||
html.append(".attachment-section h3 {\n");
|
||||
@ -746,7 +841,6 @@ public class EmlToPdf {
|
||||
html.append(" margin-left: 8px;\n");
|
||||
html.append("}\n\n");
|
||||
|
||||
|
||||
// Basic image styling: ensure images are responsive but not overly constrained.
|
||||
html.append("img {\n");
|
||||
html.append(" max-width: 100%;\n"); // Make images responsive to container width
|
||||
@ -787,31 +881,33 @@ public class EmlToPdf {
|
||||
Class<?> messageClass = message.getClass();
|
||||
|
||||
// Extract headers via reflection
|
||||
java.lang.reflect.Method getSubject = messageClass.getMethod("getSubject");
|
||||
Method getSubject = messageClass.getMethod("getSubject");
|
||||
String subject = (String) getSubject.invoke(message);
|
||||
content.setSubject(subject != null ? safeMimeDecode(subject) : "No Subject");
|
||||
|
||||
java.lang.reflect.Method getFrom = messageClass.getMethod("getFrom");
|
||||
Method getFrom = messageClass.getMethod("getFrom");
|
||||
Object[] fromAddresses = (Object[]) getFrom.invoke(message);
|
||||
content.setFrom(
|
||||
fromAddresses != null && fromAddresses.length > 0
|
||||
? safeMimeDecode(fromAddresses[0].toString())
|
||||
: "");
|
||||
|
||||
java.lang.reflect.Method getAllRecipients = messageClass.getMethod("getAllRecipients");
|
||||
Method getAllRecipients = messageClass.getMethod("getAllRecipients");
|
||||
Object[] recipients = (Object[]) getAllRecipients.invoke(message);
|
||||
content.setTo(
|
||||
recipients != null && recipients.length > 0 ? safeMimeDecode(recipients[0].toString()) : "");
|
||||
recipients != null && recipients.length > 0
|
||||
? safeMimeDecode(recipients[0].toString())
|
||||
: "");
|
||||
|
||||
java.lang.reflect.Method getSentDate = messageClass.getMethod("getSentDate");
|
||||
Method getSentDate = messageClass.getMethod("getSentDate");
|
||||
content.setDate((Date) getSentDate.invoke(message));
|
||||
|
||||
// Extract content
|
||||
java.lang.reflect.Method getContent = messageClass.getMethod("getContent");
|
||||
Method getContent = messageClass.getMethod("getContent");
|
||||
Object messageContent = getContent.invoke(message);
|
||||
|
||||
if (messageContent instanceof String stringContent) {
|
||||
java.lang.reflect.Method getContentType = messageClass.getMethod("getContentType");
|
||||
Method getContentType = messageClass.getMethod("getContentType");
|
||||
String contentType = (String) getContentType.invoke(message);
|
||||
if (contentType != null && contentType.toLowerCase().contains("text/html")) {
|
||||
content.setHtmlBody(stringContent);
|
||||
@ -826,7 +922,7 @@ public class EmlToPdf {
|
||||
processMultipartAdvanced(messageContent, content, request);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Error processing multipart content: {}", e.getMessage());
|
||||
log.warn("Error processing content: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@ -843,12 +939,17 @@ public class EmlToPdf {
|
||||
private static void processMultipartAdvanced(
|
||||
Object multipart, EmailContent content, EmlToPdfRequest request) {
|
||||
try {
|
||||
// Enhanced multipart type checking
|
||||
if (!isValidJakartaMailMultipart(multipart)) {
|
||||
log.warn("Invalid Jakarta Mail multipart type: {}", multipart.getClass().getName());
|
||||
return;
|
||||
}
|
||||
|
||||
Class<?> multipartClass = multipart.getClass();
|
||||
java.lang.reflect.Method getCount = multipartClass.getMethod("getCount");
|
||||
Method getCount = multipartClass.getMethod("getCount");
|
||||
int count = (Integer) getCount.invoke(multipart);
|
||||
|
||||
java.lang.reflect.Method getBodyPart =
|
||||
multipartClass.getMethod("getBodyPart", int.class);
|
||||
Method getBodyPart = multipartClass.getMethod("getBodyPart", int.class);
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
Object part = getBodyPart.invoke(multipart, i);
|
||||
@ -863,13 +964,18 @@ public class EmlToPdf {
|
||||
private static void processPartAdvanced(
|
||||
Object part, EmailContent content, EmlToPdfRequest request) {
|
||||
try {
|
||||
if (!isValidJakartaMailPart(part)) {
|
||||
log.warn("Invalid Jakarta Mail part type: {}", part.getClass().getName());
|
||||
return;
|
||||
}
|
||||
|
||||
Class<?> partClass = part.getClass();
|
||||
java.lang.reflect.Method isMimeType = partClass.getMethod("isMimeType", String.class);
|
||||
java.lang.reflect.Method getContent = partClass.getMethod("getContent");
|
||||
java.lang.reflect.Method getDisposition = partClass.getMethod("getDisposition");
|
||||
java.lang.reflect.Method getFileName = partClass.getMethod("getFileName");
|
||||
java.lang.reflect.Method getContentType = partClass.getMethod("getContentType");
|
||||
java.lang.reflect.Method getHeader = partClass.getMethod("getHeader", String.class);
|
||||
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);
|
||||
@ -896,10 +1002,18 @@ public class EmlToPdf {
|
||||
String[] contentIdHeaders = (String[]) getHeader.invoke(part, "Content-ID");
|
||||
if (contentIdHeaders != null && contentIdHeaders.length > 0) {
|
||||
attachment.setEmbedded(true);
|
||||
// Store the Content-ID, removing angle brackets if present
|
||||
String contentId = contentIdHeaders[0];
|
||||
if (contentId.startsWith("<") && contentId.endsWith(">")) {
|
||||
contentId = contentId.substring(1, contentId.length() - 1);
|
||||
}
|
||||
attachment.setContentId(contentId);
|
||||
}
|
||||
|
||||
// Extract attachment data only if attachments should be included
|
||||
if (request != null && request.isIncludeAttachments()) {
|
||||
// Extract attachment data if attachments should be included OR if it's an
|
||||
// embedded image (needed for inline display)
|
||||
if ((request != null && request.isIncludeAttachments())
|
||||
|| attachment.isEmbedded()) {
|
||||
try {
|
||||
Object attachmentContent = getContent.invoke(part);
|
||||
byte[] attachmentData = null;
|
||||
@ -908,26 +1022,35 @@ public class EmlToPdf {
|
||||
try {
|
||||
attachmentData = inputStream.readAllBytes();
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to read InputStream attachment: {}", e.getMessage());
|
||||
log.warn(
|
||||
"Failed to read InputStream attachment: {}",
|
||||
e.getMessage());
|
||||
}
|
||||
} else if (attachmentContent instanceof byte[] byteArray) {
|
||||
attachmentData = byteArray;
|
||||
} else if (attachmentContent instanceof String stringContent) {
|
||||
attachmentData =
|
||||
stringContent.getBytes(StandardCharsets.UTF_8);
|
||||
attachmentData = stringContent.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
if (attachmentData != null) {
|
||||
// Check size limit (use default 10MB if request is null)
|
||||
long maxSizeMB = request.getMaxAttachmentSizeMB();
|
||||
long maxSizeMB =
|
||||
request != null ? request.getMaxAttachmentSizeMB() : 10L;
|
||||
long maxSizeBytes = maxSizeMB * 1024 * 1024;
|
||||
|
||||
if (attachmentData.length <= maxSizeBytes) {
|
||||
attachment.setData(attachmentData);
|
||||
attachment.setSizeBytes(attachmentData.length);
|
||||
} else {
|
||||
// Still show attachment info even if too large
|
||||
attachment.setSizeBytes(attachmentData.length);
|
||||
// For embedded images, always include data regardless of size
|
||||
// to ensure inline display works
|
||||
if (attachment.isEmbedded()) {
|
||||
attachment.setData(attachmentData);
|
||||
attachment.setSizeBytes(attachmentData.length);
|
||||
} else {
|
||||
// Still show attachment info even if too large
|
||||
attachment.setSizeBytes(attachmentData.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
@ -963,7 +1086,7 @@ public class EmlToPdf {
|
||||
html.append("<html><head><meta charset=\"UTF-8\">\n");
|
||||
html.append("<title>").append(escapeHtml(content.getSubject())).append("</title>\n");
|
||||
html.append("<style>\n");
|
||||
appendEnhancedStyles(html, request);
|
||||
appendEnhancedStyles(html);
|
||||
html.append("</style>\n");
|
||||
html.append("</head><body>\n");
|
||||
|
||||
@ -974,7 +1097,9 @@ public class EmlToPdf {
|
||||
html.append("<div><strong>From:</strong> ")
|
||||
.append(escapeHtml(content.getFrom()))
|
||||
.append("</div>\n");
|
||||
html.append("<div><strong>To:</strong> ").append(escapeHtml(content.getTo())).append("</div>\n");
|
||||
html.append("<div><strong>To:</strong> ")
|
||||
.append(escapeHtml(content.getTo()))
|
||||
.append("</div>\n");
|
||||
|
||||
if (content.getDate() != null) {
|
||||
html.append("<div><strong>Date:</strong> ")
|
||||
@ -985,7 +1110,7 @@ public class EmlToPdf {
|
||||
|
||||
html.append("<div class=\"email-body\">\n");
|
||||
if (content.getHtmlBody() != null && !content.getHtmlBody().trim().isEmpty()) {
|
||||
html.append(processEmailHtmlBody(content.getHtmlBody()));
|
||||
html.append(processEmailHtmlBody(content.getHtmlBody(), content));
|
||||
} else if (content.getTextBody() != null && !content.getTextBody().trim().isEmpty()) {
|
||||
html.append("<div class=\"text-body\">");
|
||||
html.append(convertTextToHtml(content.getTextBody()));
|
||||
@ -1014,15 +1139,20 @@ public class EmlToPdf {
|
||||
? attachment.getEmbeddedFilename()
|
||||
: attachment.getFilename());
|
||||
|
||||
html.append("<div class=\"attachment-item\" id=\"").append(uniqueId).append("\">")
|
||||
.append("<span class=\"attachment-icon\">").append(MimeConstants.PAPERCLIP_EMOJI).append("</span> ")
|
||||
html.append("<div class=\"attachment-item\" id=\"")
|
||||
.append(uniqueId)
|
||||
.append("\">")
|
||||
.append("<span class=\"attachment-icon\">")
|
||||
.append(MimeConstants.ATTACHMENT_MARKER)
|
||||
.append("</span> ")
|
||||
.append("<span class=\"attachment-name\">")
|
||||
.append(escapeHtml(safeMimeDecode(attachment.getFilename())))
|
||||
.append("</span>");
|
||||
|
||||
String sizeStr = formatFileSize(attachment.getSizeBytes());
|
||||
html.append(" <span class=\"attachment-details\">(").append(sizeStr);
|
||||
if (attachment.getContentType() != null && !attachment.getContentType().isEmpty()) {
|
||||
if (attachment.getContentType() != null
|
||||
&& !attachment.getContentType().isEmpty()) {
|
||||
html.append(", ").append(escapeHtml(attachment.getContentType()));
|
||||
}
|
||||
html.append(")</span></div>\n");
|
||||
@ -1031,8 +1161,7 @@ public class EmlToPdf {
|
||||
|
||||
if (request.isIncludeAttachments()) {
|
||||
html.append("<div class=\"attachment-info-note\">\n");
|
||||
html.append(
|
||||
"<p><em>Attachments are embedded in the file.</em></p>\n");
|
||||
html.append("<p><em>Attachments are embedded in the file.</em></p>\n");
|
||||
html.append("</div>\n");
|
||||
} else {
|
||||
html.append("<div class=\"attachment-info-note\">\n");
|
||||
@ -1050,7 +1179,10 @@ public class EmlToPdf {
|
||||
return html.toString();
|
||||
}
|
||||
|
||||
private static byte[] attachFilesToPdf(byte[] pdfBytes, List<EmailAttachment> attachments, stirling.software.common.service.CustomPDFDocumentFactory pdfDocumentFactory)
|
||||
private static byte[] attachFilesToPdf(
|
||||
byte[] pdfBytes,
|
||||
List<EmailAttachment> attachments,
|
||||
CustomPDFDocumentFactory pdfDocumentFactory)
|
||||
throws IOException {
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfBytes);
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||
@ -1104,18 +1236,17 @@ public class EmlToPdf {
|
||||
|
||||
// Create embedded file
|
||||
PDEmbeddedFile embeddedFile =
|
||||
new PDEmbeddedFile(document, new ByteArrayInputStream(attachment.getData()));
|
||||
new PDEmbeddedFile(
|
||||
document, new ByteArrayInputStream(attachment.getData()));
|
||||
embeddedFile.setSize(attachment.getData().length);
|
||||
embeddedFile.setCreationDate(new GregorianCalendar());
|
||||
if (attachment.getContentType() != null) {
|
||||
embeddedFile.setSubtype(attachment.getContentType());
|
||||
}
|
||||
|
||||
// Create file specification
|
||||
PDComplexFileSpecification fileSpec = new PDComplexFileSpecification();
|
||||
fileSpec.setFile(uniqueFilename);
|
||||
fileSpec.setEmbeddedFile(embeddedFile);
|
||||
if (attachment.getContentType() != null) {
|
||||
embeddedFile.setSubtype(attachment.getContentType());
|
||||
fileSpec.setFileDescription("Email attachment: " + uniqueFilename);
|
||||
}
|
||||
|
||||
@ -1137,7 +1268,7 @@ public class EmlToPdf {
|
||||
efTree.setNames(efMap);
|
||||
|
||||
// Set catalog viewer preferences to automatically show attachments pane
|
||||
setCatalogViewerPreferences(document);
|
||||
setCatalogViewerPreferences(document, PageMode.USE_ATTACHMENTS);
|
||||
}
|
||||
|
||||
// Add attachment annotations to the first page for each embedded file
|
||||
@ -1150,11 +1281,13 @@ public class EmlToPdf {
|
||||
}
|
||||
}
|
||||
|
||||
private static String getUniqueFilename(String filename, List<String> embeddedFiles, Map<String, PDComplexFileSpecification> efMap) {
|
||||
private static String getUniqueFilename(
|
||||
String filename,
|
||||
List<String> embeddedFiles,
|
||||
Map<String, PDComplexFileSpecification> efMap) {
|
||||
String uniqueFilename = filename;
|
||||
int counter = 1;
|
||||
while (embeddedFiles.contains(uniqueFilename)
|
||||
|| efMap.containsKey(uniqueFilename)) {
|
||||
while (embeddedFiles.contains(uniqueFilename) || efMap.containsKey(uniqueFilename)) {
|
||||
String extension = "";
|
||||
String baseName = filename;
|
||||
int lastDot = filename.lastIndexOf('.');
|
||||
@ -1174,24 +1307,24 @@ public class EmlToPdf {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Find the screen position of all emoji anchors
|
||||
EmojiPositionFinder finder = new EmojiPositionFinder();
|
||||
// 1. Find the screen position of all attachment markers
|
||||
AttachmentMarkerPositionFinder finder = new AttachmentMarkerPositionFinder();
|
||||
finder.setSortByPosition(true); // Process pages in order
|
||||
finder.getText(document);
|
||||
List<EmojiPosition> emojiPositions = finder.getPositions();
|
||||
List<MarkerPosition> markerPositions = finder.getPositions();
|
||||
|
||||
// 2. Warn if the number of anchors and attachments don't match
|
||||
if (emojiPositions.size() != attachments.size()) {
|
||||
// 2. Warn if the number of markers and attachments don't match
|
||||
if (markerPositions.size() != attachments.size()) {
|
||||
log.warn(
|
||||
"Found {} emoji anchors, but there are {} attachments. Annotation count may be incorrect.",
|
||||
emojiPositions.size(),
|
||||
"Found {} attachment markers, but there are {} attachments. Annotation count may be incorrect.",
|
||||
markerPositions.size(),
|
||||
attachments.size());
|
||||
}
|
||||
|
||||
// 3. Create an invisible annotation over each found emoji
|
||||
int annotationsToAdd = Math.min(emojiPositions.size(), attachments.size());
|
||||
// 3. Create an invisible annotation over each found marker
|
||||
int annotationsToAdd = Math.min(markerPositions.size(), attachments.size());
|
||||
for (int i = 0; i < annotationsToAdd; i++) {
|
||||
EmojiPosition position = emojiPositions.get(i);
|
||||
MarkerPosition position = markerPositions.get(i);
|
||||
EmailAttachment attachment = attachments.get(i);
|
||||
|
||||
if (attachment.getEmbeddedFilename() != null) {
|
||||
@ -1203,8 +1336,8 @@ public class EmlToPdf {
|
||||
}
|
||||
|
||||
private static void addAttachmentAnnotationToPage(
|
||||
PDDocument document, PDPage page, EmailAttachment attachment, float x, float y)
|
||||
throws IOException {
|
||||
PDDocument document, PDPage page, EmailAttachment attachment, float x, float y)
|
||||
throws IOException {
|
||||
|
||||
PDAnnotationFileAttachment fileAnnotation = new PDAnnotationFileAttachment();
|
||||
|
||||
@ -1226,11 +1359,12 @@ public class EmlToPdf {
|
||||
|
||||
// Set invisibility flags but keep it functional
|
||||
fileAnnotation.setInvisible(true);
|
||||
fileAnnotation.setHidden(false); // Must be false to remain clickable
|
||||
fileAnnotation.setNoView(false); // Must be false to remain clickable
|
||||
fileAnnotation.setHidden(false); // Must be false to remain clickable
|
||||
fileAnnotation.setNoView(false); // Must be false to remain clickable
|
||||
fileAnnotation.setPrinted(false);
|
||||
|
||||
PDEmbeddedFilesNameTreeNode efTree = document.getDocumentCatalog().getNames().getEmbeddedFiles();
|
||||
PDEmbeddedFilesNameTreeNode efTree =
|
||||
document.getDocumentCatalog().getNames().getEmbeddedFiles();
|
||||
if (efTree != null) {
|
||||
Map<String, PDComplexFileSpecification> efMap = efTree.getNames();
|
||||
if (efMap != null) {
|
||||
@ -1246,24 +1380,27 @@ public class EmlToPdf {
|
||||
|
||||
page.getAnnotations().add(fileAnnotation);
|
||||
|
||||
log.info("Added attachment annotation for '{}' on page {}",
|
||||
attachment.getFilename(), document.getPages().indexOf(page) + 1);
|
||||
log.info(
|
||||
"Added attachment annotation for '{}' on page {}",
|
||||
attachment.getFilename(),
|
||||
document.getPages().indexOf(page) + 1);
|
||||
}
|
||||
|
||||
private static @NotNull PDRectangle getPdRectangle(PDPage page, float x, float y) {
|
||||
PDRectangle mediaBox = page.getMediaBox();
|
||||
float pdfY = mediaBox.getHeight() - y;
|
||||
|
||||
float iconWidth = StyleConstants.ATTACHMENT_ICON_WIDTH; // Keep original size for clickability
|
||||
float iconHeight = StyleConstants.ATTACHMENT_ICON_HEIGHT; // Keep original size for clickability
|
||||
float iconWidth =
|
||||
StyleConstants.ATTACHMENT_ICON_WIDTH; // Keep original size for clickability
|
||||
float iconHeight =
|
||||
StyleConstants.ATTACHMENT_ICON_HEIGHT; // Keep original size for clickability
|
||||
|
||||
// Keep the full-size rectangle so it remains clickable
|
||||
return new PDRectangle(
|
||||
x + StyleConstants.ANNOTATION_X_OFFSET,
|
||||
pdfY - iconHeight + StyleConstants.ANNOTATION_Y_OFFSET,
|
||||
iconWidth,
|
||||
iconHeight
|
||||
);
|
||||
x + StyleConstants.ANNOTATION_X_OFFSET,
|
||||
pdfY - iconHeight + StyleConstants.ANNOTATION_Y_OFFSET,
|
||||
iconWidth,
|
||||
iconHeight);
|
||||
}
|
||||
|
||||
private static String formatEmailDate(Date date) {
|
||||
@ -1285,38 +1422,6 @@ public class EmlToPdf {
|
||||
}
|
||||
}
|
||||
|
||||
private static void setCatalogViewerPreferences(PDDocument document) {
|
||||
try {
|
||||
PDDocumentCatalog catalog = document.getDocumentCatalog();
|
||||
if (catalog != null) {
|
||||
// Get the catalog's COS dictionary to work with low-level PDF objects
|
||||
COSDictionary catalogDict = catalog.getCOSObject();
|
||||
|
||||
// Set PageMode to UseAttachments - this is the standard PDF specification approach
|
||||
// PageMode values: UseNone, UseOutlines, UseThumbs, FullScreen, UseOC, UseAttachments
|
||||
catalogDict.setName(COSName.PAGE_MODE, "UseAttachments");
|
||||
|
||||
// Also set viewer preferences for better attachment viewing experience
|
||||
COSDictionary viewerPrefs = (COSDictionary) catalogDict.getDictionaryObject(COSName.VIEWER_PREFERENCES);
|
||||
if (viewerPrefs == null) {
|
||||
viewerPrefs = new COSDictionary();
|
||||
catalogDict.setItem(COSName.VIEWER_PREFERENCES, viewerPrefs);
|
||||
}
|
||||
|
||||
// Set NonFullScreenPageMode to UseAttachments as fallback for viewers that support it
|
||||
viewerPrefs.setName(COSName.getPDFName("NonFullScreenPageMode"), "UseAttachments");
|
||||
|
||||
// Additional viewer preferences that may help with attachment display
|
||||
viewerPrefs.setBoolean(COSName.getPDFName("DisplayDocTitle"), true);
|
||||
|
||||
log.info("Set PDF PageMode to UseAttachments to automatically show attachments pane");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Log warning but don't fail the entire operation for viewer preferences
|
||||
log.warn("Failed to set catalog viewer preferences for attachments", e);
|
||||
}
|
||||
}
|
||||
|
||||
// MIME header decoding functionality for RFC 2047 encoded headers - moved to constants
|
||||
|
||||
private static String decodeMimeHeader(String encodedText) {
|
||||
@ -1391,7 +1496,7 @@ public class EmlToPdf {
|
||||
}
|
||||
}
|
||||
case '_' -> // In RFC 2047, underscore represents space
|
||||
result.append(' ');
|
||||
result.append(' ');
|
||||
default -> result.append(c);
|
||||
}
|
||||
}
|
||||
@ -1407,13 +1512,73 @@ public class EmlToPdf {
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeMimeHeader(headerValue.trim());
|
||||
if (isJakartaMailAvailable()) {
|
||||
// Use Jakarta Mail's MimeUtility for proper MIME decoding
|
||||
Class<?> mimeUtilityClass = Class.forName("jakarta.mail.internet.MimeUtility");
|
||||
Method decodeText = mimeUtilityClass.getMethod("decodeText", String.class);
|
||||
return (String) decodeText.invoke(null, headerValue.trim());
|
||||
} else {
|
||||
// Fallback to basic MIME decoding
|
||||
return decodeMimeHeader(headerValue.trim());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to decode MIME header, using original: {}", headerValue, e);
|
||||
return headerValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isValidJakartaMailPart(Object part) {
|
||||
if (part == null) return false;
|
||||
|
||||
try {
|
||||
// Check if the object implements jakarta.mail.Part interface
|
||||
Class<?> partInterface = Class.forName("jakarta.mail.Part");
|
||||
if (!partInterface.isInstance(part)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional check for MimePart
|
||||
try {
|
||||
Class<?> mimePartInterface = Class.forName("jakarta.mail.internet.MimePart");
|
||||
return mimePartInterface.isInstance(part);
|
||||
} catch (ClassNotFoundException e) {
|
||||
// MimePart not available, but Part is sufficient
|
||||
return true;
|
||||
}
|
||||
} catch (ClassNotFoundException e) {
|
||||
log.debug("Jakarta Mail Part interface not available for validation");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isValidJakartaMailMultipart(Object multipart) {
|
||||
if (multipart == null) return false;
|
||||
|
||||
try {
|
||||
// Check if the object implements jakarta.mail.Multipart interface
|
||||
Class<?> multipartInterface = Class.forName("jakarta.mail.Multipart");
|
||||
if (!multipartInterface.isInstance(multipart)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional check for MimeMultipart
|
||||
try {
|
||||
Class<?> mimeMultipartClass = Class.forName("jakarta.mail.internet.MimeMultipart");
|
||||
if (mimeMultipartClass.isInstance(multipart)) {
|
||||
log.debug("Found MimeMultipart instance for enhanced processing");
|
||||
return true;
|
||||
}
|
||||
} catch (ClassNotFoundException e) {
|
||||
log.debug("MimeMultipart not available, using base Multipart interface");
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (ClassNotFoundException e) {
|
||||
log.debug("Jakarta Mail Multipart interface not available for validation");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class EmailContent {
|
||||
private String subject;
|
||||
@ -1458,16 +1623,13 @@ public class EmlToPdf {
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class EmojiPosition {
|
||||
public static class MarkerPosition {
|
||||
private int pageIndex;
|
||||
private float x;
|
||||
private float y;
|
||||
private String character;
|
||||
|
||||
public EmojiPosition() {
|
||||
}
|
||||
|
||||
public EmojiPosition(int pageIndex, float x, float y, String character) {
|
||||
public MarkerPosition(int pageIndex, float x, float y, String character) {
|
||||
this.pageIndex = pageIndex;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
@ -1475,15 +1637,15 @@ public class EmlToPdf {
|
||||
}
|
||||
}
|
||||
|
||||
public static class EmojiPositionFinder extends org.apache.pdfbox.text.PDFTextStripper {
|
||||
@Getter
|
||||
private final List<EmojiPosition> positions = new ArrayList<>();
|
||||
public static class AttachmentMarkerPositionFinder
|
||||
extends org.apache.pdfbox.text.PDFTextStripper {
|
||||
@Getter private final List<MarkerPosition> positions = new ArrayList<>();
|
||||
private int currentPageIndex;
|
||||
private boolean sortByPosition;
|
||||
protected boolean sortByPosition;
|
||||
private boolean isInAttachmentSection;
|
||||
private boolean attachmentSectionFound;
|
||||
|
||||
public EmojiPositionFinder() throws IOException {
|
||||
public AttachmentMarkerPositionFinder() {
|
||||
super();
|
||||
this.currentPageIndex = 0;
|
||||
this.sortByPosition = false;
|
||||
@ -1503,7 +1665,9 @@ public class EmlToPdf {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeString(String string, List<org.apache.pdfbox.text.TextPosition> textPositions) throws IOException {
|
||||
protected void writeString(
|
||||
String string, List<org.apache.pdfbox.text.TextPosition> textPositions)
|
||||
throws IOException {
|
||||
// Check if we are entering or exiting the attachment section
|
||||
String lowerString = string.toLowerCase();
|
||||
|
||||
@ -1513,31 +1677,29 @@ public class EmlToPdf {
|
||||
attachmentSectionFound = true;
|
||||
}
|
||||
|
||||
// Look for attachment section end markers (common patterns that indicate end of attachments)
|
||||
if (isInAttachmentSection && (lowerString.contains("</body>") ||
|
||||
lowerString.contains("</html>") ||
|
||||
(attachmentSectionFound && lowerString.trim().isEmpty() && string.length() > 50))) {
|
||||
// Look for attachment section end markers (common patterns that indicate end of
|
||||
// attachments)
|
||||
if (isInAttachmentSection
|
||||
&& (lowerString.contains("</body>")
|
||||
|| lowerString.contains("</html>")
|
||||
|| (attachmentSectionFound
|
||||
&& lowerString.trim().isEmpty()
|
||||
&& string.length() > 50))) {
|
||||
isInAttachmentSection = false;
|
||||
}
|
||||
|
||||
// Only look for emojis if we are in the attachment section
|
||||
// Only look for markers if we are in the attachment section
|
||||
if (isInAttachmentSection) {
|
||||
// Look for paperclip emoji characters (U+1F4CE)
|
||||
String paperclipEmoji = "\uD83D\uDCCE"; // 📎 Unicode representation
|
||||
|
||||
for (int i = 0; i < string.length(); i++) {
|
||||
// Check if we have a complete paperclip emoji at this position
|
||||
if (i < string.length() - 1 &&
|
||||
string.substring(i, i + 2).equals(paperclipEmoji) &&
|
||||
i < textPositions.size()) {
|
||||
|
||||
String attachmentMarker = MimeConstants.ATTACHMENT_MARKER;
|
||||
for (int i = 0; (i = string.indexOf(attachmentMarker, i)) != -1; i++) {
|
||||
if (i < textPositions.size()) {
|
||||
org.apache.pdfbox.text.TextPosition textPosition = textPositions.get(i);
|
||||
EmojiPosition position = new EmojiPosition(
|
||||
currentPageIndex,
|
||||
textPosition.getXDirAdj(),
|
||||
textPosition.getYDirAdj(),
|
||||
paperclipEmoji
|
||||
);
|
||||
MarkerPosition position =
|
||||
new MarkerPosition(
|
||||
currentPageIndex,
|
||||
textPosition.getXDirAdj(),
|
||||
textPosition.getYDirAdj(),
|
||||
attachmentMarker);
|
||||
positions.add(position);
|
||||
}
|
||||
}
|
||||
@ -1549,17 +1711,5 @@ public class EmlToPdf {
|
||||
public void setSortByPosition(boolean sortByPosition) {
|
||||
this.sortByPosition = sortByPosition;
|
||||
}
|
||||
|
||||
public boolean isSortByPosition() {
|
||||
return sortByPosition;
|
||||
}
|
||||
|
||||
|
||||
public void reset() {
|
||||
positions.clear();
|
||||
currentPageIndex = 0;
|
||||
isInAttachmentSection = false;
|
||||
attachmentSectionFound = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
@ -199,11 +200,11 @@ public class GeneralUtils {
|
||||
if (bytes < 1024) {
|
||||
return bytes + " B";
|
||||
} else if (bytes < 1024 * 1024) {
|
||||
return String.format("%.2f KB", bytes / 1024.0);
|
||||
return String.format(Locale.US, "%.2f KB", bytes / 1024.0);
|
||||
} else if (bytes < 1024 * 1024 * 1024) {
|
||||
return String.format("%.2f MB", bytes / (1024.0 * 1024.0));
|
||||
return String.format(Locale.US, "%.2f MB", bytes / (1024.0 * 1024.0));
|
||||
} else {
|
||||
return String.format("%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0));
|
||||
return String.format(Locale.US, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -19,6 +19,7 @@ public class RequestUriUtils {
|
||||
|| requestURI.endsWith(".svg")
|
||||
|| requestURI.endsWith(".png")
|
||||
|| requestURI.endsWith(".ico")
|
||||
|| requestURI.endsWith(".txt")
|
||||
|| requestURI.endsWith(".webmanifest")
|
||||
|| requestURI.startsWith(contextPath + "/api/v1/info/status");
|
||||
}
|
||||
@ -35,6 +36,7 @@ public class RequestUriUtils {
|
||||
|| requestURI.endsWith(".png")
|
||||
|| requestURI.endsWith(".ico")
|
||||
|| requestURI.endsWith(".css")
|
||||
|| requestURI.endsWith(".txt")
|
||||
|| requestURI.endsWith(".map")
|
||||
|| requestURI.endsWith(".svg")
|
||||
|| requestURI.endsWith("popularity.txt")
|
||||
|
@ -16,12 +16,12 @@ import io.github.pixee.security.Filenames;
|
||||
|
||||
public class WebResponseUtils {
|
||||
|
||||
public static ResponseEntity<byte[]> boasToWebResponse(
|
||||
public static ResponseEntity<byte[]> baosToWebResponse(
|
||||
ByteArrayOutputStream baos, String docName) throws IOException {
|
||||
return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), docName);
|
||||
}
|
||||
|
||||
public static ResponseEntity<byte[]> boasToWebResponse(
|
||||
public static ResponseEntity<byte[]> baosToWebResponse(
|
||||
ByteArrayOutputStream baos, String docName, MediaType mediaType) throws IOException {
|
||||
return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), docName, mediaType);
|
||||
}
|
||||
@ -44,8 +44,7 @@ public class WebResponseUtils {
|
||||
headers.setContentType(mediaType);
|
||||
headers.setContentLength(bytes.length);
|
||||
String encodedDocName =
|
||||
URLEncoder.encode(docName, StandardCharsets.UTF_8.toString())
|
||||
.replaceAll("\\+", "%20");
|
||||
URLEncoder.encode(docName, StandardCharsets.UTF_8).replaceAll("\\+", "%20");
|
||||
headers.setContentDispositionFormData("attachment", encodedDocName);
|
||||
return new ResponseEntity<>(bytes, headers, HttpStatus.OK);
|
||||
}
|
||||
@ -61,9 +60,8 @@ public class WebResponseUtils {
|
||||
// Open Byte Array and save document to it
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
// Close the document
|
||||
document.close();
|
||||
|
||||
return boasToWebResponse(baos, docName);
|
||||
return baosToWebResponse(baos, docName);
|
||||
}
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ public class WebResponseUtilsTest {
|
||||
String docName = "sample.pdf";
|
||||
|
||||
ResponseEntity<byte[]> responseEntity =
|
||||
WebResponseUtils.boasToWebResponse(baos, docName);
|
||||
WebResponseUtils.baosToWebResponse(baos, docName);
|
||||
|
||||
assertNotNull(responseEntity);
|
||||
assertEquals(HttpStatus.OK, responseEntity.getStatusCode());
|
||||
|
@ -4,6 +4,18 @@ repositories {
|
||||
bootRun {
|
||||
enabled = false
|
||||
}
|
||||
spotless {
|
||||
java {
|
||||
target sourceSets.main.allJava
|
||||
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
||||
|
||||
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
||||
toggleOffOn()
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation project(':common')
|
||||
|
||||
@ -17,17 +29,17 @@ dependencies {
|
||||
api 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
api 'org.springframework.boot:spring-boot-starter-oauth2-client'
|
||||
api 'org.springframework.boot:spring-boot-starter-mail'
|
||||
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.30'
|
||||
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.33'
|
||||
implementation 'com.bucket4j:bucket4j_jdk17-core:8.14.0'
|
||||
|
||||
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
|
||||
implementation 'org.bouncycastle:bcprov-jdk18on:1.80'
|
||||
implementation 'org.bouncycastle:bcprov-jdk18on:1.81'
|
||||
|
||||
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.3.RELEASE'
|
||||
api 'io.micrometer:micrometer-registry-prometheus'
|
||||
implementation 'com.unboundid.product.scim2:scim2-sdk-client:2.3.5'
|
||||
runtimeOnly 'com.h2database:h2:2.3.232' // Don't upgrade h2database
|
||||
runtimeOnly 'org.postgresql:postgresql:42.7.5'
|
||||
runtimeOnly 'org.postgresql:postgresql:42.7.7'
|
||||
constraints {
|
||||
implementation "org.opensaml:opensaml-core:$openSamlVersion"
|
||||
implementation "org.opensaml:opensaml-saml-api:$openSamlVersion"
|
||||
|
@ -0,0 +1,137 @@
|
||||
package stirling.software.proprietary.audit;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
import stirling.software.proprietary.service.AuditService;
|
||||
|
||||
/** Aspect for processing {@link Audited} annotations. */
|
||||
@Aspect
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class AuditAspect {
|
||||
|
||||
private final AuditService auditService;
|
||||
private final AuditConfigurationProperties auditConfig;
|
||||
|
||||
@Around("@annotation(stirling.software.proprietary.audit.Audited)")
|
||||
public Object auditMethod(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
|
||||
Method method = signature.getMethod();
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
|
||||
// Fast path: use unified check to determine if we should audit
|
||||
// This avoids all data collection if auditing is disabled
|
||||
if (!AuditUtils.shouldAudit(method, auditConfig)) {
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
|
||||
// Only create the map once we know we'll use it
|
||||
Map<String, Object> auditData =
|
||||
AuditUtils.createBaseAuditData(joinPoint, auditedAnnotation.level());
|
||||
|
||||
// Add HTTP information if we're in a web context
|
||||
ServletRequestAttributes attrs =
|
||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attrs != null) {
|
||||
HttpServletRequest req = attrs.getRequest();
|
||||
String path = req.getRequestURI();
|
||||
String httpMethod = req.getMethod();
|
||||
AuditUtils.addHttpData(auditData, httpMethod, path, auditedAnnotation.level());
|
||||
AuditUtils.addFileData(auditData, joinPoint, auditedAnnotation.level());
|
||||
}
|
||||
|
||||
// Add arguments if requested and if at VERBOSE level, or if specifically requested
|
||||
boolean includeArgs =
|
||||
auditedAnnotation.includeArgs()
|
||||
&& (auditedAnnotation.level() == AuditLevel.VERBOSE
|
||||
|| auditConfig.getAuditLevel() == AuditLevel.VERBOSE);
|
||||
|
||||
if (includeArgs) {
|
||||
AuditUtils.addMethodArguments(auditData, joinPoint, AuditLevel.VERBOSE);
|
||||
}
|
||||
|
||||
// Record start time for latency calculation
|
||||
long startTime = System.currentTimeMillis();
|
||||
Object result;
|
||||
try {
|
||||
// Execute the method
|
||||
result = joinPoint.proceed();
|
||||
|
||||
// Add success status
|
||||
auditData.put("status", "success");
|
||||
|
||||
// Add result if requested and if at VERBOSE level
|
||||
boolean includeResult =
|
||||
auditedAnnotation.includeResult()
|
||||
&& (auditedAnnotation.level() == AuditLevel.VERBOSE
|
||||
|| auditConfig.getAuditLevel() == AuditLevel.VERBOSE);
|
||||
|
||||
if (includeResult && result != null) {
|
||||
// Use safe string conversion with size limiting
|
||||
auditData.put("result", AuditUtils.safeToString(result, 1000));
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (Throwable ex) {
|
||||
// Always add failure information regardless of level
|
||||
auditData.put("status", "failure");
|
||||
auditData.put("errorType", ex.getClass().getName());
|
||||
auditData.put("errorMessage", ex.getMessage());
|
||||
|
||||
// Re-throw the exception
|
||||
throw ex;
|
||||
} finally {
|
||||
// Add timing information - use isHttpRequest=false to ensure we get timing for non-HTTP
|
||||
// methods
|
||||
HttpServletResponse resp = attrs != null ? attrs.getResponse() : null;
|
||||
boolean isHttpRequest = attrs != null;
|
||||
AuditUtils.addTimingData(
|
||||
auditData, startTime, resp, auditedAnnotation.level(), isHttpRequest);
|
||||
|
||||
// Resolve the event type based on annotation and context
|
||||
String httpMethod = null;
|
||||
String path = null;
|
||||
if (attrs != null) {
|
||||
HttpServletRequest req = attrs.getRequest();
|
||||
httpMethod = req.getMethod();
|
||||
path = req.getRequestURI();
|
||||
}
|
||||
|
||||
AuditEventType eventType =
|
||||
AuditUtils.resolveEventType(
|
||||
method,
|
||||
joinPoint.getTarget().getClass(),
|
||||
path,
|
||||
httpMethod,
|
||||
auditedAnnotation);
|
||||
|
||||
// Check if we should use string type instead
|
||||
String typeString = auditedAnnotation.typeString();
|
||||
if (eventType == AuditEventType.HTTP_REQUEST && StringUtils.isNotEmpty(typeString)) {
|
||||
// Use the string type (for backward compatibility)
|
||||
auditService.audit(typeString, auditData, auditedAnnotation.level());
|
||||
} else {
|
||||
// Use the enum type (preferred)
|
||||
auditService.audit(eventType, auditData, auditedAnnotation.level());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,60 @@
|
||||
package stirling.software.proprietary.audit;
|
||||
|
||||
/** Standardized audit event types for the application. */
|
||||
public enum AuditEventType {
|
||||
// Authentication events - BASIC level
|
||||
USER_LOGIN("User login"),
|
||||
USER_LOGOUT("User logout"),
|
||||
USER_FAILED_LOGIN("Failed login attempt"),
|
||||
|
||||
// User/admin events - BASIC level
|
||||
USER_PROFILE_UPDATE("User or profile operation"),
|
||||
|
||||
// System configuration events - STANDARD level
|
||||
SETTINGS_CHANGED("System or admin settings operation"),
|
||||
|
||||
// File operations - STANDARD level
|
||||
FILE_OPERATION("File operation"),
|
||||
|
||||
// PDF operations - STANDARD level
|
||||
PDF_PROCESS("PDF processing operation"),
|
||||
|
||||
// HTTP requests - STANDARD level
|
||||
HTTP_REQUEST("HTTP request");
|
||||
|
||||
private final String description;
|
||||
|
||||
AuditEventType(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the enum value from a string representation. Useful for backward compatibility with
|
||||
* string-based event types.
|
||||
*
|
||||
* @param type The string representation of the event type
|
||||
* @return The corresponding enum value or null if not found
|
||||
*/
|
||||
public static AuditEventType fromString(String type) {
|
||||
if (type == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return AuditEventType.valueOf(type);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// If the exact enum name doesn't match, try finding a similar one
|
||||
for (AuditEventType eventType : values()) {
|
||||
if (eventType.name().equalsIgnoreCase(type)
|
||||
|| eventType.getDescription().equalsIgnoreCase(type)) {
|
||||
return eventType;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,69 @@
|
||||
package stirling.software.proprietary.audit;
|
||||
|
||||
/** Defines the different levels of audit logging available in the application. */
|
||||
public enum AuditLevel {
|
||||
/**
|
||||
* OFF - No audit logging (level 0) Disables all audit logging except for critical security
|
||||
* events
|
||||
*/
|
||||
OFF(0),
|
||||
|
||||
/**
|
||||
* BASIC - Minimal audit logging (level 1) Includes: - Authentication events (login, logout,
|
||||
* failed logins) - Password changes - User/role changes - System configuration changes
|
||||
*/
|
||||
BASIC(1),
|
||||
|
||||
/**
|
||||
* STANDARD - Standard audit logging (level 2) Includes everything in BASIC plus: - All HTTP
|
||||
* requests (basic info: URL, method, status) - File operations (upload, download, process) -
|
||||
* PDF operations (view, edit, etc.) - User operations
|
||||
*/
|
||||
STANDARD(2),
|
||||
|
||||
/**
|
||||
* VERBOSE - Detailed audit logging (level 3) Includes everything in STANDARD plus: - Request
|
||||
* headers and parameters - Method parameters - Operation results - Detailed timing information
|
||||
*/
|
||||
VERBOSE(3);
|
||||
|
||||
private final int level;
|
||||
|
||||
AuditLevel(int level) {
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
public int getLevel() {
|
||||
return level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this audit level includes the specified level
|
||||
*
|
||||
* @param otherLevel The level to check against
|
||||
* @return true if this level is equal to or greater than the specified level
|
||||
*/
|
||||
public boolean includes(AuditLevel otherLevel) {
|
||||
return this.level >= otherLevel.level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an AuditLevel from an integer value
|
||||
*
|
||||
* @param level The integer level (0-3)
|
||||
* @return The corresponding AuditLevel
|
||||
*/
|
||||
public static AuditLevel fromInt(int level) {
|
||||
// Ensure level is within valid bounds
|
||||
int boundedLevel = Math.min(Math.max(level, 0), 3);
|
||||
|
||||
for (AuditLevel auditLevel : values()) {
|
||||
if (auditLevel.level == boundedLevel) {
|
||||
return auditLevel;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to STANDARD if somehow we didn't match
|
||||
return STANDARD;
|
||||
}
|
||||
}
|
@ -0,0 +1,418 @@
|
||||
package stirling.software.proprietary.audit;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.util.RequestUriUtils;
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Shared utilities for audit aspects to ensure consistent behavior across different audit
|
||||
* mechanisms.
|
||||
*/
|
||||
@Slf4j
|
||||
public class AuditUtils {
|
||||
|
||||
/**
|
||||
* Create a standard audit data map with common attributes based on the current audit level
|
||||
*
|
||||
* @param joinPoint The AspectJ join point
|
||||
* @param auditLevel The current audit level
|
||||
* @return A map with standard audit data
|
||||
*/
|
||||
public static Map<String, Object> createBaseAuditData(
|
||||
ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
|
||||
// Common data for all levels
|
||||
data.put("timestamp", Instant.now().toString());
|
||||
|
||||
// Add principal if available
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.getName() != null) {
|
||||
data.put("principal", auth.getName());
|
||||
} else {
|
||||
data.put("principal", "system");
|
||||
}
|
||||
|
||||
// Add class name and method name only at VERBOSE level
|
||||
if (auditLevel.includes(AuditLevel.VERBOSE)) {
|
||||
data.put("className", joinPoint.getTarget().getClass().getName());
|
||||
data.put(
|
||||
"methodName",
|
||||
((MethodSignature) joinPoint.getSignature()).getMethod().getName());
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add HTTP-specific information to the audit data if available
|
||||
*
|
||||
* @param data The existing audit data map
|
||||
* @param httpMethod The HTTP method (GET, POST, etc.)
|
||||
* @param path The request path
|
||||
* @param auditLevel The current audit level
|
||||
*/
|
||||
public static void addHttpData(
|
||||
Map<String, Object> data, String httpMethod, String path, AuditLevel auditLevel) {
|
||||
if (httpMethod == null || path == null) {
|
||||
return; // Skip if we don't have basic HTTP info
|
||||
}
|
||||
|
||||
// BASIC level HTTP data
|
||||
data.put("httpMethod", httpMethod);
|
||||
data.put("path", path);
|
||||
|
||||
// Get request attributes safely
|
||||
ServletRequestAttributes attrs =
|
||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attrs == null) {
|
||||
return; // No request context available
|
||||
}
|
||||
|
||||
HttpServletRequest req = attrs.getRequest();
|
||||
if (req == null) {
|
||||
return; // No request available
|
||||
}
|
||||
|
||||
// STANDARD level HTTP data
|
||||
if (auditLevel.includes(AuditLevel.STANDARD)) {
|
||||
data.put("clientIp", req.getRemoteAddr());
|
||||
data.put(
|
||||
"sessionId",
|
||||
req.getSession(false) != null ? req.getSession(false).getId() : null);
|
||||
data.put("requestId", MDC.get("requestId"));
|
||||
|
||||
// Form data for POST/PUT/PATCH
|
||||
if (("POST".equalsIgnoreCase(httpMethod)
|
||||
|| "PUT".equalsIgnoreCase(httpMethod)
|
||||
|| "PATCH".equalsIgnoreCase(httpMethod))
|
||||
&& req.getContentType() != null) {
|
||||
|
||||
String contentType = req.getContentType();
|
||||
if (contentType.contains("application/x-www-form-urlencoded")
|
||||
|| contentType.contains("multipart/form-data")) {
|
||||
|
||||
Map<String, String[]> params = new HashMap<>(req.getParameterMap());
|
||||
// Remove CSRF token from logged parameters
|
||||
params.remove("_csrf");
|
||||
|
||||
if (!params.isEmpty()) {
|
||||
data.put("formParams", params);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add file information to the audit data if available
|
||||
*
|
||||
* @param data The existing audit data map
|
||||
* @param joinPoint The AspectJ join point
|
||||
* @param auditLevel The current audit level
|
||||
*/
|
||||
public static void addFileData(
|
||||
Map<String, Object> data, ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
|
||||
if (auditLevel.includes(AuditLevel.STANDARD)) {
|
||||
List<MultipartFile> files =
|
||||
Arrays.stream(joinPoint.getArgs())
|
||||
.filter(a -> a instanceof MultipartFile)
|
||||
.map(a -> (MultipartFile) a)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (!files.isEmpty()) {
|
||||
List<Map<String, Object>> fileInfos =
|
||||
files.stream()
|
||||
.map(
|
||||
f -> {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("name", f.getOriginalFilename());
|
||||
m.put("size", f.getSize());
|
||||
m.put("type", f.getContentType());
|
||||
return m;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
data.put("files", fileInfos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add method arguments to the audit data
|
||||
*
|
||||
* @param data The existing audit data map
|
||||
* @param joinPoint The AspectJ join point
|
||||
* @param auditLevel The current audit level
|
||||
*/
|
||||
public static void addMethodArguments(
|
||||
Map<String, Object> data, ProceedingJoinPoint joinPoint, AuditLevel auditLevel) {
|
||||
if (auditLevel.includes(AuditLevel.VERBOSE)) {
|
||||
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
|
||||
String[] names = sig.getParameterNames();
|
||||
Object[] vals = joinPoint.getArgs();
|
||||
if (names != null && vals != null) {
|
||||
IntStream.range(0, names.length)
|
||||
.forEach(
|
||||
i -> {
|
||||
if (vals[i] != null) {
|
||||
// Convert objects to safe string representation
|
||||
data.put("arg_" + names[i], safeToString(vals[i], 500));
|
||||
} else {
|
||||
data.put("arg_" + names[i], null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely convert an object to string with size limiting
|
||||
*
|
||||
* @param obj The object to convert
|
||||
* @param maxLength Maximum length of the resulting string
|
||||
* @return A safe string representation, truncated if needed
|
||||
*/
|
||||
public static String safeToString(Object obj, int maxLength) {
|
||||
if (obj == null) {
|
||||
return "null";
|
||||
}
|
||||
|
||||
String result;
|
||||
try {
|
||||
// Handle common types directly to avoid toString() overhead
|
||||
if (obj instanceof String) {
|
||||
result = (String) obj;
|
||||
} else if (obj instanceof Number || obj instanceof Boolean) {
|
||||
result = obj.toString();
|
||||
} else if (obj instanceof byte[]) {
|
||||
result = "[binary data length=" + ((byte[]) obj).length + "]";
|
||||
} else {
|
||||
// For complex objects, use toString but handle exceptions
|
||||
result = obj.toString();
|
||||
}
|
||||
|
||||
// Truncate if necessary
|
||||
if (result != null && result.length() > maxLength) {
|
||||
return StringUtils.truncate(result, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
// If toString() fails, return the class name
|
||||
return "[" + obj.getClass().getName() + " - toString() failed]";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a method should be audited based on config and annotation
|
||||
*
|
||||
* @param method The method to check
|
||||
* @param auditConfig The audit configuration
|
||||
* @return true if the method should be audited
|
||||
*/
|
||||
public static boolean shouldAudit(Method method, AuditConfigurationProperties auditConfig) {
|
||||
// First check if audit is globally enabled - fast path
|
||||
if (!auditConfig.isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for annotation override
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
AuditLevel requiredLevel =
|
||||
(auditedAnnotation != null) ? auditedAnnotation.level() : AuditLevel.BASIC;
|
||||
|
||||
// Check if the required level is enabled
|
||||
return auditConfig.getAuditLevel().includes(requiredLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add timing and response status data to the audit record
|
||||
*
|
||||
* @param data The audit data to add to
|
||||
* @param startTime The start time in milliseconds
|
||||
* @param response The HTTP response (may be null for non-HTTP methods)
|
||||
* @param level The current audit level
|
||||
* @param isHttpRequest Whether this is an HTTP request (controller) or a regular method call
|
||||
*/
|
||||
public static void addTimingData(
|
||||
Map<String, Object> data,
|
||||
long startTime,
|
||||
HttpServletResponse response,
|
||||
AuditLevel level,
|
||||
boolean isHttpRequest) {
|
||||
if (level.includes(AuditLevel.STANDARD)) {
|
||||
// For HTTP requests, let ControllerAuditAspect handle timing separately
|
||||
// For non-HTTP methods, add execution time here
|
||||
if (!isHttpRequest) {
|
||||
data.put("latencyMs", System.currentTimeMillis() - startTime);
|
||||
}
|
||||
|
||||
// Add HTTP status code if available
|
||||
if (response != null) {
|
||||
try {
|
||||
data.put("statusCode", response.getStatus());
|
||||
} catch (Exception e) {
|
||||
// Ignore - response might be in an inconsistent state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the event type to use for auditing, considering annotations and context
|
||||
*
|
||||
* @param method The method being audited
|
||||
* @param controller The controller class
|
||||
* @param path The request path (may be null for non-HTTP methods)
|
||||
* @param httpMethod The HTTP method (may be null for non-HTTP methods)
|
||||
* @param annotation The @Audited annotation (may be null)
|
||||
* @return The resolved event type (never null)
|
||||
*/
|
||||
public static AuditEventType resolveEventType(
|
||||
Method method,
|
||||
Class<?> controller,
|
||||
String path,
|
||||
String httpMethod,
|
||||
Audited annotation) {
|
||||
// First check if we have an explicit annotation
|
||||
if (annotation != null && annotation.type() != AuditEventType.HTTP_REQUEST) {
|
||||
return annotation.type();
|
||||
}
|
||||
|
||||
// For HTTP methods, infer based on controller and path
|
||||
if (httpMethod != null && path != null) {
|
||||
String cls = controller.getSimpleName().toLowerCase();
|
||||
String pkg = controller.getPackage().getName().toLowerCase();
|
||||
|
||||
if ("GET".equals(httpMethod)) return AuditEventType.HTTP_REQUEST;
|
||||
|
||||
if (cls.contains("user")
|
||||
|| cls.contains("auth")
|
||||
|| pkg.contains("auth")
|
||||
|| path.startsWith("/user")
|
||||
|| path.startsWith("/login")) {
|
||||
return AuditEventType.USER_PROFILE_UPDATE;
|
||||
} else if (cls.contains("admin")
|
||||
|| path.startsWith("/admin")
|
||||
|| path.startsWith("/settings")) {
|
||||
return AuditEventType.SETTINGS_CHANGED;
|
||||
} else if (cls.contains("file")
|
||||
|| path.startsWith("/file")
|
||||
|| path.matches("(?i).*/(upload|download)/.*")) {
|
||||
return AuditEventType.FILE_OPERATION;
|
||||
}
|
||||
}
|
||||
|
||||
// Default for non-HTTP methods or when no specific match
|
||||
return AuditEventType.PDF_PROCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the appropriate audit level to use
|
||||
*
|
||||
* @param method The method to check
|
||||
* @param defaultLevel The default level to use if no annotation present
|
||||
* @param auditConfig The audit configuration
|
||||
* @return The audit level to use
|
||||
*/
|
||||
public static AuditLevel getEffectiveAuditLevel(
|
||||
Method method, AuditLevel defaultLevel, AuditConfigurationProperties auditConfig) {
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
if (auditedAnnotation != null) {
|
||||
// Method has @Audited - use its level
|
||||
return auditedAnnotation.level();
|
||||
}
|
||||
|
||||
// Use default level (typically from global config)
|
||||
return defaultLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the appropriate audit event type to use
|
||||
*
|
||||
* @param method The method being audited
|
||||
* @param controller The controller class
|
||||
* @param path The request path
|
||||
* @param httpMethod The HTTP method
|
||||
* @return The determined audit event type
|
||||
*/
|
||||
public static AuditEventType determineAuditEventType(
|
||||
Method method, Class<?> controller, String path, String httpMethod) {
|
||||
// First check for explicit annotation
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
if (auditedAnnotation != null) {
|
||||
return auditedAnnotation.type();
|
||||
}
|
||||
|
||||
// Otherwise infer from controller and path
|
||||
String cls = controller.getSimpleName().toLowerCase();
|
||||
String pkg = controller.getPackage().getName().toLowerCase();
|
||||
|
||||
if ("GET".equals(httpMethod)) return AuditEventType.HTTP_REQUEST;
|
||||
|
||||
if (cls.contains("user")
|
||||
|| cls.contains("auth")
|
||||
|| pkg.contains("auth")
|
||||
|| path.startsWith("/user")
|
||||
|| path.startsWith("/login")) {
|
||||
return AuditEventType.USER_PROFILE_UPDATE;
|
||||
} else if (cls.contains("admin")
|
||||
|| path.startsWith("/admin")
|
||||
|| path.startsWith("/settings")) {
|
||||
return AuditEventType.SETTINGS_CHANGED;
|
||||
} else if (cls.contains("file")
|
||||
|| path.startsWith("/file")
|
||||
|| path.matches("(?i).*/(upload|download)/.*")) {
|
||||
return AuditEventType.FILE_OPERATION;
|
||||
} else {
|
||||
return AuditEventType.PDF_PROCESS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current HTTP request if available
|
||||
*
|
||||
* @return The current request or null if not in a request context
|
||||
*/
|
||||
public static HttpServletRequest getCurrentRequest() {
|
||||
ServletRequestAttributes attrs =
|
||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
return attrs != null ? attrs.getRequest() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a GET request is for a static resource
|
||||
*
|
||||
* @param request The HTTP request
|
||||
* @return true if this is a static resource request
|
||||
*/
|
||||
public static boolean isStaticResourceRequest(HttpServletRequest request) {
|
||||
return request != null
|
||||
&& !RequestUriUtils.isTrackableResource(
|
||||
request.getContextPath(), request.getRequestURI());
|
||||
}
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
package stirling.software.proprietary.audit;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Annotation for methods that should be audited.
|
||||
*
|
||||
* <p>Usage:
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Audited(type = AuditEventType.USER_REGISTRATION, level = AuditLevel.BASIC)
|
||||
* public void registerUser(String username) {
|
||||
* // Method implementation
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* For backward compatibility, string-based event types are still supported:
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Audited(typeString = "CUSTOM_EVENT_TYPE", level = AuditLevel.BASIC)
|
||||
* public void customOperation() {
|
||||
* // Method implementation
|
||||
* }
|
||||
* }</pre>
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface Audited {
|
||||
|
||||
/**
|
||||
* The type of audit event using the standardized AuditEventType enum. This is the preferred way
|
||||
* to specify the event type.
|
||||
*
|
||||
* <p>If both type() and typeString() are specified, type() takes precedence.
|
||||
*/
|
||||
AuditEventType type() default AuditEventType.HTTP_REQUEST;
|
||||
|
||||
/**
|
||||
* The type of audit event as a string (e.g., "FILE_UPLOAD", "USER_REGISTRATION"). Provided for
|
||||
* backward compatibility and custom event types not in the enum.
|
||||
*
|
||||
* <p>If both type() and typeString() are specified, type() takes precedence.
|
||||
*/
|
||||
String typeString() default "";
|
||||
|
||||
/** The audit level at which this event should be logged */
|
||||
AuditLevel level() default AuditLevel.STANDARD;
|
||||
|
||||
/** Should method arguments be included in the audit event */
|
||||
boolean includeArgs() default true;
|
||||
|
||||
/** Should the method return value be included in the audit event */
|
||||
boolean includeResult() default false;
|
||||
}
|
@ -0,0 +1,207 @@
|
||||
package stirling.software.proprietary.audit;
|
||||
|
||||
import java.lang.annotation.Annotation;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
import stirling.software.proprietary.service.AuditService;
|
||||
|
||||
/**
|
||||
* Aspect for automatically auditing controller methods with web mappings (GetMapping, PostMapping,
|
||||
* etc.)
|
||||
*/
|
||||
@Aspect
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ControllerAuditAspect {
|
||||
|
||||
private final AuditService auditService;
|
||||
private final AuditConfigurationProperties auditConfig;
|
||||
|
||||
@Around(
|
||||
"execution(* org.springframework.web.servlet.resource.ResourceHttpRequestHandler.handleRequest(..))")
|
||||
public Object auditStaticResource(ProceedingJoinPoint jp) throws Throwable {
|
||||
return auditController(jp, "GET");
|
||||
}
|
||||
|
||||
/** Intercept all methods with GetMapping annotation */
|
||||
@Around("@annotation(org.springframework.web.bind.annotation.GetMapping)")
|
||||
public Object auditGetMethod(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
return auditController(joinPoint, "GET");
|
||||
}
|
||||
|
||||
/** Intercept all methods with PostMapping annotation */
|
||||
@Around("@annotation(org.springframework.web.bind.annotation.PostMapping)")
|
||||
public Object auditPostMethod(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
return auditController(joinPoint, "POST");
|
||||
}
|
||||
|
||||
/** Intercept all methods with PutMapping annotation */
|
||||
@Around("@annotation(org.springframework.web.bind.annotation.PutMapping)")
|
||||
public Object auditPutMethod(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
return auditController(joinPoint, "PUT");
|
||||
}
|
||||
|
||||
/** Intercept all methods with DeleteMapping annotation */
|
||||
@Around("@annotation(org.springframework.web.bind.annotation.DeleteMapping)")
|
||||
public Object auditDeleteMethod(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
return auditController(joinPoint, "DELETE");
|
||||
}
|
||||
|
||||
/** Intercept all methods with PatchMapping annotation */
|
||||
@Around("@annotation(org.springframework.web.bind.annotation.PatchMapping)")
|
||||
public Object auditPatchMethod(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
return auditController(joinPoint, "PATCH");
|
||||
}
|
||||
|
||||
private Object auditController(ProceedingJoinPoint joinPoint, String httpMethod)
|
||||
throws Throwable {
|
||||
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
|
||||
Method method = sig.getMethod();
|
||||
|
||||
// Fast path: check if auditing is enabled before doing any work
|
||||
// This avoids all data collection if auditing is disabled
|
||||
if (!AuditUtils.shouldAudit(method, auditConfig)) {
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
|
||||
// Check if method is explicitly annotated with @Audited
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
AuditLevel level = auditConfig.getAuditLevel();
|
||||
|
||||
// If @Audited annotation is present, respect its level setting
|
||||
if (auditedAnnotation != null) {
|
||||
// Use the level from annotation if it's stricter than global level
|
||||
level = auditedAnnotation.level();
|
||||
}
|
||||
|
||||
String path = getRequestPath(method, httpMethod);
|
||||
|
||||
// Skip static GET resources
|
||||
if ("GET".equals(httpMethod)) {
|
||||
HttpServletRequest maybe = AuditUtils.getCurrentRequest();
|
||||
if (maybe != null && AuditUtils.isStaticResourceRequest(maybe)) {
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
}
|
||||
|
||||
ServletRequestAttributes attrs =
|
||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
HttpServletRequest req = attrs != null ? attrs.getRequest() : null;
|
||||
HttpServletResponse resp = attrs != null ? attrs.getResponse() : null;
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
// Use AuditUtils to create the base audit data
|
||||
Map<String, Object> data = AuditUtils.createBaseAuditData(joinPoint, level);
|
||||
|
||||
// Add HTTP-specific information
|
||||
AuditUtils.addHttpData(data, httpMethod, path, level);
|
||||
|
||||
// Add file information if present
|
||||
AuditUtils.addFileData(data, joinPoint, level);
|
||||
|
||||
// Add method arguments if at VERBOSE level
|
||||
if (level.includes(AuditLevel.VERBOSE)) {
|
||||
AuditUtils.addMethodArguments(data, joinPoint, level);
|
||||
}
|
||||
|
||||
Object result = null;
|
||||
try {
|
||||
result = joinPoint.proceed();
|
||||
data.put("outcome", "success");
|
||||
} catch (Throwable ex) {
|
||||
data.put("outcome", "failure");
|
||||
data.put("errorType", ex.getClass().getSimpleName());
|
||||
data.put("errorMessage", ex.getMessage());
|
||||
throw ex;
|
||||
} finally {
|
||||
// Handle timing directly for HTTP requests
|
||||
if (level.includes(AuditLevel.STANDARD)) {
|
||||
data.put("latencyMs", System.currentTimeMillis() - start);
|
||||
if (resp != null) data.put("statusCode", resp.getStatus());
|
||||
}
|
||||
|
||||
// Call AuditUtils but with isHttpRequest=true to skip additional timing
|
||||
AuditUtils.addTimingData(data, start, resp, level, true);
|
||||
|
||||
// Add result for VERBOSE level
|
||||
if (level.includes(AuditLevel.VERBOSE) && result != null) {
|
||||
// Use safe string conversion with size limiting
|
||||
data.put("result", AuditUtils.safeToString(result, 1000));
|
||||
}
|
||||
|
||||
// Resolve the event type using the unified method
|
||||
AuditEventType eventType =
|
||||
AuditUtils.resolveEventType(
|
||||
method,
|
||||
joinPoint.getTarget().getClass(),
|
||||
path,
|
||||
httpMethod,
|
||||
auditedAnnotation);
|
||||
|
||||
// Check if we should use string type instead (for backward compatibility)
|
||||
if (auditedAnnotation != null) {
|
||||
String typeString = auditedAnnotation.typeString();
|
||||
if (eventType == AuditEventType.HTTP_REQUEST
|
||||
&& StringUtils.isNotEmpty(typeString)) {
|
||||
auditService.audit(typeString, data, level);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Use the enum type
|
||||
auditService.audit(eventType, data, level);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Using AuditUtils.determineAuditEventType instead
|
||||
|
||||
private String getRequestPath(Method method, String httpMethod) {
|
||||
String base = "";
|
||||
RequestMapping cm = method.getDeclaringClass().getAnnotation(RequestMapping.class);
|
||||
if (cm != null && cm.value().length > 0) base = cm.value()[0];
|
||||
String mp = "";
|
||||
Annotation ann =
|
||||
switch (httpMethod) {
|
||||
case "GET" -> method.getAnnotation(GetMapping.class);
|
||||
case "POST" -> method.getAnnotation(PostMapping.class);
|
||||
case "PUT" -> method.getAnnotation(PutMapping.class);
|
||||
case "DELETE" -> method.getAnnotation(DeleteMapping.class);
|
||||
case "PATCH" -> method.getAnnotation(PatchMapping.class);
|
||||
default -> null;
|
||||
};
|
||||
if (ann instanceof GetMapping gm && gm.value().length > 0) mp = gm.value()[0];
|
||||
if (ann instanceof PostMapping pm && pm.value().length > 0) mp = pm.value()[0];
|
||||
if (ann instanceof PutMapping pum && pum.value().length > 0) mp = pum.value()[0];
|
||||
if (ann instanceof DeleteMapping dm && dm.value().length > 0) mp = dm.value()[0];
|
||||
if (ann instanceof PatchMapping pam && pam.value().length > 0) mp = pam.value()[0];
|
||||
return base + mp;
|
||||
}
|
||||
|
||||
// Using AuditUtils.getCurrentRequest instead
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
package stirling.software.proprietary.config;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.task.TaskDecorator;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
public class AsyncConfig {
|
||||
|
||||
/**
|
||||
* MDC context-propagating task decorator Copies MDC context from the caller thread to the async
|
||||
* executor thread
|
||||
*/
|
||||
static class MDCContextTaskDecorator implements TaskDecorator {
|
||||
@Override
|
||||
public Runnable decorate(Runnable runnable) {
|
||||
// Capture the MDC context from the current thread
|
||||
Map<String, String> contextMap = MDC.getCopyOfContextMap();
|
||||
|
||||
return () -> {
|
||||
try {
|
||||
// Set the captured context on the worker thread
|
||||
if (contextMap != null) {
|
||||
MDC.setContextMap(contextMap);
|
||||
}
|
||||
// Execute the task
|
||||
runnable.run();
|
||||
} finally {
|
||||
// Clear the context to prevent memory leaks
|
||||
MDC.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Bean(name = "auditExecutor")
|
||||
public Executor auditExecutor() {
|
||||
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
|
||||
exec.setCorePoolSize(2);
|
||||
exec.setMaxPoolSize(8);
|
||||
exec.setQueueCapacity(1_000);
|
||||
exec.setThreadNamePrefix("audit-");
|
||||
|
||||
// Set the task decorator to propagate MDC context
|
||||
exec.setTaskDecorator(new MDCContextTaskDecorator());
|
||||
|
||||
exec.initialize();
|
||||
return exec;
|
||||
}
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
package stirling.software.proprietary.config;
|
||||
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
|
||||
/**
|
||||
* Configuration properties for the audit system. Reads values from the ApplicationProperties under
|
||||
* premium.enterpriseFeatures.audit
|
||||
*/
|
||||
@Slf4j
|
||||
@Getter
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
|
||||
public class AuditConfigurationProperties {
|
||||
|
||||
private final boolean enabled;
|
||||
private final int level;
|
||||
private final int retentionDays;
|
||||
|
||||
public AuditConfigurationProperties(ApplicationProperties applicationProperties) {
|
||||
ApplicationProperties.Premium.EnterpriseFeatures.Audit auditConfig =
|
||||
applicationProperties.getPremium().getEnterpriseFeatures().getAudit();
|
||||
// Read values directly from configuration
|
||||
this.enabled = auditConfig.isEnabled();
|
||||
|
||||
// Ensure level is within valid bounds (0-3)
|
||||
int configLevel = auditConfig.getLevel();
|
||||
this.level = Math.min(Math.max(configLevel, 0), 3);
|
||||
|
||||
// Retention days (0 means infinite)
|
||||
this.retentionDays = auditConfig.getRetentionDays();
|
||||
|
||||
log.debug(
|
||||
"Initialized audit configuration: enabled={}, level={}, retentionDays={} (0=infinite)",
|
||||
this.enabled,
|
||||
this.level,
|
||||
this.retentionDays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the audit level as an enum
|
||||
*
|
||||
* @return The current AuditLevel
|
||||
*/
|
||||
public AuditLevel getAuditLevel() {
|
||||
return AuditLevel.fromInt(level);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current audit level includes the specified level
|
||||
*
|
||||
* @param requiredLevel The level to check against
|
||||
* @return true if auditing is enabled and the current level includes the required level
|
||||
*/
|
||||
public boolean isLevelEnabled(AuditLevel requiredLevel) {
|
||||
return enabled && getAuditLevel().includes(requiredLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective retention period in days
|
||||
*
|
||||
* @return The number of days to retain audit records, or -1 for infinite retention
|
||||
*/
|
||||
public int getEffectiveRetentionDays() {
|
||||
// 0 means infinite retention
|
||||
return retentionDays <= 0 ? -1 : retentionDays;
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
package stirling.software.proprietary.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||
|
||||
/** Configuration to explicitly enable JPA repositories and scheduling for the audit system. */
|
||||
@Configuration
|
||||
@EnableTransactionManagement
|
||||
@EnableJpaRepositories(basePackages = "stirling.software.proprietary.repository")
|
||||
@EnableScheduling
|
||||
public class AuditJpaConfig {
|
||||
// This configuration enables JPA repositories in the specified package
|
||||
// and enables scheduling for audit cleanup tasks
|
||||
// No additional beans or methods needed
|
||||
}
|
@ -0,0 +1,74 @@
|
||||
package stirling.software.proprietary.config;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.boot.actuate.audit.AuditEvent;
|
||||
import org.springframework.boot.actuate.audit.AuditEventRepository;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.model.security.PersistentAuditEvent;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
import stirling.software.proprietary.util.SecretMasker;
|
||||
|
||||
@Component
|
||||
@Primary
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CustomAuditEventRepository implements AuditEventRepository {
|
||||
|
||||
private final PersistentAuditEventRepository repo;
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
/* ── READ side intentionally inert (endpoint disabled) ── */
|
||||
@Override
|
||||
public List<AuditEvent> find(String p, Instant after, String type) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/* ── WRITE side (async) ───────────────────────────────── */
|
||||
@Async("auditExecutor")
|
||||
@Override
|
||||
public void add(AuditEvent ev) {
|
||||
try {
|
||||
Map<String, Object> clean =
|
||||
CollectionUtils.isEmpty(ev.getData())
|
||||
? Map.of()
|
||||
: SecretMasker.mask(ev.getData());
|
||||
|
||||
if (clean.isEmpty() || (clean.size() == 1 && clean.containsKey("details"))) {
|
||||
return;
|
||||
}
|
||||
String rid = MDC.get("requestId");
|
||||
|
||||
if (rid != null) {
|
||||
clean = new java.util.HashMap<>(clean);
|
||||
clean.put("requestId", rid);
|
||||
}
|
||||
|
||||
String auditEventData = mapper.writeValueAsString(clean);
|
||||
log.debug("AuditEvent data (JSON): {}", auditEventData);
|
||||
|
||||
PersistentAuditEvent ent =
|
||||
PersistentAuditEvent.builder()
|
||||
.principal(ev.getPrincipal())
|
||||
.type(ev.getType())
|
||||
.data(auditEventData)
|
||||
.timestamp(ev.getTimestamp())
|
||||
.build();
|
||||
repo.save(ent);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace(); // fail-open
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
|
@ -0,0 +1,352 @@
|
||||
package stirling.software.proprietary.controller;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
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 org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
import stirling.software.proprietary.model.security.PersistentAuditEvent;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
|
||||
|
||||
/** Controller for the audit dashboard. Admin-only access. */
|
||||
@Slf4j
|
||||
@Controller
|
||||
@RequestMapping("/audit")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequiredArgsConstructor
|
||||
@EnterpriseEndpoint
|
||||
public class AuditDashboardController {
|
||||
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
private final AuditConfigurationProperties auditConfig;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/** Display the audit dashboard. */
|
||||
@GetMapping
|
||||
public String showDashboard(Model model) {
|
||||
model.addAttribute("auditEnabled", auditConfig.isEnabled());
|
||||
model.addAttribute("auditLevel", auditConfig.getAuditLevel());
|
||||
model.addAttribute("auditLevelInt", auditConfig.getLevel());
|
||||
model.addAttribute("retentionDays", auditConfig.getRetentionDays());
|
||||
|
||||
// Add audit level enum values for display
|
||||
model.addAttribute("auditLevels", AuditLevel.values());
|
||||
|
||||
// Add audit event types for the dropdown
|
||||
model.addAttribute("auditEventTypes", AuditEventType.values());
|
||||
|
||||
return "audit/dashboard";
|
||||
}
|
||||
|
||||
/** Get audit events data for the dashboard tables. */
|
||||
@GetMapping("/data")
|
||||
@ResponseBody
|
||||
public Map<String, Object> getAuditData(
|
||||
@RequestParam(value = "page", defaultValue = "0") int page,
|
||||
@RequestParam(value = "size", defaultValue = "30") int size,
|
||||
@RequestParam(value = "type", required = false) String type,
|
||||
@RequestParam(value = "principal", required = false) String principal,
|
||||
@RequestParam(value = "startDate", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate startDate,
|
||||
@RequestParam(value = "endDate", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate endDate,
|
||||
HttpServletRequest request) {
|
||||
|
||||
Pageable pageable = PageRequest.of(page, size, Sort.by("timestamp").descending());
|
||||
Page<PersistentAuditEvent> events;
|
||||
|
||||
String mode;
|
||||
|
||||
if (type != null && principal != null && startDate != null && endDate != null) {
|
||||
mode = "principal + type + startDate + endDate";
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events =
|
||||
auditRepository.findByPrincipalAndTypeAndTimestampBetween(
|
||||
principal, type, start, end, pageable);
|
||||
} else if (type != null && principal != null) {
|
||||
mode = "principal + type";
|
||||
events = auditRepository.findByPrincipalAndType(principal, type, pageable);
|
||||
} else if (type != null && startDate != null && endDate != null) {
|
||||
mode = "type + startDate + endDate";
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events = auditRepository.findByTypeAndTimestampBetween(type, start, end, pageable);
|
||||
} else if (principal != null && startDate != null && endDate != null) {
|
||||
mode = "principal + startDate + endDate";
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events =
|
||||
auditRepository.findByPrincipalAndTimestampBetween(
|
||||
principal, start, end, pageable);
|
||||
} else if (startDate != null && endDate != null) {
|
||||
mode = "startDate + endDate";
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events = auditRepository.findByTimestampBetween(start, end, pageable);
|
||||
} else if (type != null) {
|
||||
mode = "type";
|
||||
events = auditRepository.findByType(type, pageable);
|
||||
} else if (principal != null) {
|
||||
mode = "principal";
|
||||
events = auditRepository.findByPrincipal(principal, pageable);
|
||||
} else {
|
||||
mode = "all";
|
||||
events = auditRepository.findAll(pageable);
|
||||
}
|
||||
|
||||
// Logging
|
||||
List<PersistentAuditEvent> content = events.getContent();
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("content", content);
|
||||
response.put("totalPages", events.getTotalPages());
|
||||
response.put("totalElements", events.getTotalElements());
|
||||
response.put("currentPage", events.getNumber());
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/** Get statistics for charts. */
|
||||
@GetMapping("/stats")
|
||||
@ResponseBody
|
||||
public Map<String, Object> getAuditStats(
|
||||
@RequestParam(value = "days", defaultValue = "7") int days) {
|
||||
|
||||
// Get events from the last X days
|
||||
Instant startDate = Instant.now().minus(java.time.Duration.ofDays(days));
|
||||
List<PersistentAuditEvent> events = auditRepository.findByTimestampAfter(startDate);
|
||||
|
||||
// Count events by type
|
||||
Map<String, Long> eventsByType =
|
||||
events.stream()
|
||||
.collect(
|
||||
Collectors.groupingBy(
|
||||
PersistentAuditEvent::getType, Collectors.counting()));
|
||||
|
||||
// Count events by principal
|
||||
Map<String, Long> eventsByPrincipal =
|
||||
events.stream()
|
||||
.collect(
|
||||
Collectors.groupingBy(
|
||||
PersistentAuditEvent::getPrincipal, Collectors.counting()));
|
||||
|
||||
// Count events by day
|
||||
Map<String, Long> eventsByDay =
|
||||
events.stream()
|
||||
.collect(
|
||||
Collectors.groupingBy(
|
||||
e ->
|
||||
LocalDateTime.ofInstant(
|
||||
e.getTimestamp(),
|
||||
ZoneId.systemDefault())
|
||||
.format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
Collectors.counting()));
|
||||
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
stats.put("eventsByType", eventsByType);
|
||||
stats.put("eventsByPrincipal", eventsByPrincipal);
|
||||
stats.put("eventsByDay", eventsByDay);
|
||||
stats.put("totalEvents", events.size());
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/** Get all unique event types from the database for filtering. */
|
||||
@GetMapping("/types")
|
||||
@ResponseBody
|
||||
public List<String> getAuditTypes() {
|
||||
// Get distinct event types from the database
|
||||
List<String> dbTypes = auditRepository.findDistinctEventTypes();
|
||||
|
||||
// Include standard enum types in case they're not in the database yet
|
||||
List<String> enumTypes =
|
||||
Arrays.stream(AuditEventType.values())
|
||||
.map(AuditEventType::name)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// Combine both sources, remove duplicates, and sort
|
||||
Set<String> combinedTypes = new HashSet<>();
|
||||
combinedTypes.addAll(dbTypes);
|
||||
combinedTypes.addAll(enumTypes);
|
||||
|
||||
return combinedTypes.stream().sorted().collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/** Export audit data as CSV. */
|
||||
@GetMapping("/export")
|
||||
public ResponseEntity<byte[]> exportAuditData(
|
||||
@RequestParam(value = "type", required = false) String type,
|
||||
@RequestParam(value = "principal", required = false) String principal,
|
||||
@RequestParam(value = "startDate", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate startDate,
|
||||
@RequestParam(value = "endDate", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate endDate) {
|
||||
|
||||
// Get data with same filtering as getAuditData
|
||||
List<PersistentAuditEvent> events;
|
||||
|
||||
if (type != null && principal != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events =
|
||||
auditRepository.findAllByPrincipalAndTypeAndTimestampBetweenForExport(
|
||||
principal, type, start, end);
|
||||
} else if (type != null && principal != null) {
|
||||
events = auditRepository.findAllByPrincipalAndTypeForExport(principal, type);
|
||||
} else if (type != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events = auditRepository.findAllByTypeAndTimestampBetweenForExport(type, start, end);
|
||||
} else if (principal != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events =
|
||||
auditRepository.findAllByPrincipalAndTimestampBetweenForExport(
|
||||
principal, start, end);
|
||||
} else if (startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events = auditRepository.findAllByTimestampBetweenForExport(start, end);
|
||||
} else if (type != null) {
|
||||
events = auditRepository.findByTypeForExport(type);
|
||||
} else if (principal != null) {
|
||||
events = auditRepository.findAllByPrincipalForExport(principal);
|
||||
} else {
|
||||
events = auditRepository.findAll();
|
||||
}
|
||||
|
||||
// Convert to CSV
|
||||
StringBuilder csv = new StringBuilder();
|
||||
csv.append("ID,Principal,Type,Timestamp,Data\n");
|
||||
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ISO_INSTANT;
|
||||
|
||||
for (PersistentAuditEvent event : events) {
|
||||
csv.append(event.getId()).append(",");
|
||||
csv.append(escapeCSV(event.getPrincipal())).append(",");
|
||||
csv.append(escapeCSV(event.getType())).append(",");
|
||||
csv.append(formatter.format(event.getTimestamp())).append(",");
|
||||
csv.append(escapeCSV(event.getData())).append("\n");
|
||||
}
|
||||
|
||||
byte[] csvBytes = csv.toString().getBytes();
|
||||
|
||||
// Set up HTTP headers for download
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
|
||||
headers.setContentDispositionFormData("attachment", "audit_export.csv");
|
||||
|
||||
return ResponseEntity.ok().headers(headers).body(csvBytes);
|
||||
}
|
||||
|
||||
/** Export audit data as JSON. */
|
||||
@GetMapping("/export/json")
|
||||
public ResponseEntity<byte[]> exportAuditDataJson(
|
||||
@RequestParam(value = "type", required = false) String type,
|
||||
@RequestParam(value = "principal", required = false) String principal,
|
||||
@RequestParam(value = "startDate", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate startDate,
|
||||
@RequestParam(value = "endDate", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate endDate) {
|
||||
|
||||
// Get data with same filtering as getAuditData
|
||||
List<PersistentAuditEvent> events;
|
||||
|
||||
if (type != null && principal != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events =
|
||||
auditRepository.findAllByPrincipalAndTypeAndTimestampBetweenForExport(
|
||||
principal, type, start, end);
|
||||
} else if (type != null && principal != null) {
|
||||
events = auditRepository.findAllByPrincipalAndTypeForExport(principal, type);
|
||||
} else if (type != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events = auditRepository.findAllByTypeAndTimestampBetweenForExport(type, start, end);
|
||||
} else if (principal != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events =
|
||||
auditRepository.findAllByPrincipalAndTimestampBetweenForExport(
|
||||
principal, start, end);
|
||||
} else if (startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events = auditRepository.findAllByTimestampBetweenForExport(start, end);
|
||||
} else if (type != null) {
|
||||
events = auditRepository.findByTypeForExport(type);
|
||||
} else if (principal != null) {
|
||||
events = auditRepository.findAllByPrincipalForExport(principal);
|
||||
} else {
|
||||
events = auditRepository.findAll();
|
||||
}
|
||||
|
||||
// Convert to JSON
|
||||
try {
|
||||
byte[] jsonBytes = objectMapper.writeValueAsBytes(events);
|
||||
|
||||
// Set up HTTP headers for download
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.setContentDispositionFormData("attachment", "audit_export.json");
|
||||
|
||||
return ResponseEntity.ok().headers(headers).body(jsonBytes);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("Error serializing audit events to JSON", e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
||||
/** Helper method to escape CSV fields. */
|
||||
private String escapeCSV(String field) {
|
||||
if (field == null) {
|
||||
return "";
|
||||
}
|
||||
// Replace double quotes with two double quotes and wrap in quotes
|
||||
return "\"" + field.replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
package stirling.software.proprietary.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@ -10,11 +9,11 @@ public class TeamWithUserCountDTO {
|
||||
private Long id;
|
||||
private String name;
|
||||
private Long userCount;
|
||||
|
||||
|
||||
// Constructor for JPQL projection
|
||||
public TeamWithUserCountDTO(Long id, String name, Long userCount) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.userCount = userCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,39 @@
|
||||
package stirling.software.proprietary.model.security;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "audit_events",
|
||||
indexes = {
|
||||
@jakarta.persistence.Index(name = "idx_audit_timestamp", columnList = "timestamp"),
|
||||
@jakarta.persistence.Index(name = "idx_audit_principal", columnList = "principal"),
|
||||
@jakarta.persistence.Index(name = "idx_audit_type", columnList = "type"),
|
||||
@jakarta.persistence.Index(
|
||||
name = "idx_audit_principal_type",
|
||||
columnList = "principal,type"),
|
||||
@jakarta.persistence.Index(
|
||||
name = "idx_audit_type_timestamp",
|
||||
columnList = "type,timestamp")
|
||||
})
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PersistentAuditEvent {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
private String principal;
|
||||
private String type;
|
||||
|
||||
@Lob private String data; // JSON blob
|
||||
|
||||
private Instant timestamp;
|
||||
}
|
@ -0,0 +1,118 @@
|
||||
package stirling.software.proprietary.repository;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
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 org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import stirling.software.proprietary.model.security.PersistentAuditEvent;
|
||||
|
||||
@Repository
|
||||
public interface PersistentAuditEventRepository extends JpaRepository<PersistentAuditEvent, Long> {
|
||||
|
||||
// Basic queries
|
||||
@Query(
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%'))")
|
||||
Page<PersistentAuditEvent> findByPrincipal(
|
||||
@Param("principal") String principal, Pageable pageable);
|
||||
|
||||
Page<PersistentAuditEvent> findByType(String type, Pageable pageable);
|
||||
|
||||
Page<PersistentAuditEvent> findByTimestampBetween(
|
||||
Instant startDate, Instant endDate, Pageable pageable);
|
||||
|
||||
@Query(
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.type = :type")
|
||||
Page<PersistentAuditEvent> findByPrincipalAndType(
|
||||
@Param("principal") String principal, @Param("type") String type, Pageable pageable);
|
||||
|
||||
@Query(
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.timestamp BETWEEN :startDate AND :endDate")
|
||||
Page<PersistentAuditEvent> findByPrincipalAndTimestampBetween(
|
||||
@Param("principal") String principal,
|
||||
@Param("startDate") Instant startDate,
|
||||
@Param("endDate") Instant endDate,
|
||||
Pageable pageable);
|
||||
|
||||
Page<PersistentAuditEvent> findByTypeAndTimestampBetween(
|
||||
String type, Instant startDate, Instant endDate, Pageable pageable);
|
||||
|
||||
@Query(
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.type = :type AND e.timestamp BETWEEN :startDate AND :endDate")
|
||||
Page<PersistentAuditEvent> findByPrincipalAndTypeAndTimestampBetween(
|
||||
@Param("principal") String principal,
|
||||
@Param("type") String type,
|
||||
@Param("startDate") Instant startDate,
|
||||
@Param("endDate") Instant endDate,
|
||||
Pageable pageable);
|
||||
|
||||
// Non-paged versions for export
|
||||
@Query(
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%'))")
|
||||
List<PersistentAuditEvent> findAllByPrincipalForExport(@Param("principal") String principal);
|
||||
|
||||
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type")
|
||||
List<PersistentAuditEvent> findByTypeForExport(@Param("type") String type);
|
||||
|
||||
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.timestamp BETWEEN :startDate AND :endDate")
|
||||
List<PersistentAuditEvent> findAllByTimestampBetweenForExport(
|
||||
@Param("startDate") Instant startDate, @Param("endDate") Instant endDate);
|
||||
|
||||
@Query("SELECT e FROM PersistentAuditEvent e WHERE e.timestamp > :startDate")
|
||||
List<PersistentAuditEvent> findByTimestampAfter(@Param("startDate") Instant startDate);
|
||||
|
||||
@Query(
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.type = :type")
|
||||
List<PersistentAuditEvent> findAllByPrincipalAndTypeForExport(
|
||||
@Param("principal") String principal, @Param("type") String type);
|
||||
|
||||
@Query(
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.timestamp BETWEEN :startDate AND :endDate")
|
||||
List<PersistentAuditEvent> findAllByPrincipalAndTimestampBetweenForExport(
|
||||
@Param("principal") String principal,
|
||||
@Param("startDate") Instant startDate,
|
||||
@Param("endDate") Instant endDate);
|
||||
|
||||
@Query(
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE e.type = :type AND e.timestamp BETWEEN :startDate AND :endDate")
|
||||
List<PersistentAuditEvent> findAllByTypeAndTimestampBetweenForExport(
|
||||
@Param("type") String type,
|
||||
@Param("startDate") Instant startDate,
|
||||
@Param("endDate") Instant endDate);
|
||||
|
||||
@Query(
|
||||
"SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.type = :type AND e.timestamp BETWEEN :startDate AND :endDate")
|
||||
List<PersistentAuditEvent> findAllByPrincipalAndTypeAndTimestampBetweenForExport(
|
||||
@Param("principal") String principal,
|
||||
@Param("type") String type,
|
||||
@Param("startDate") Instant startDate,
|
||||
@Param("endDate") Instant endDate);
|
||||
|
||||
// Cleanup queries
|
||||
@Query("DELETE FROM PersistentAuditEvent e WHERE e.timestamp < ?1")
|
||||
@Modifying
|
||||
@Transactional
|
||||
int deleteByTimestampBefore(Instant cutoffDate);
|
||||
|
||||
// Find IDs for batch deletion - using JPQL with setMaxResults instead of native query
|
||||
@Query("SELECT e.id FROM PersistentAuditEvent e WHERE e.timestamp < ?1 ORDER BY e.id")
|
||||
List<Long> findIdsForBatchDeletion(Instant cutoffDate, Pageable pageable);
|
||||
|
||||
// Stats queries
|
||||
@Query("SELECT e.type, COUNT(e) FROM PersistentAuditEvent e GROUP BY e.type")
|
||||
List<Object[]> countByType();
|
||||
|
||||
@Query("SELECT e.principal, COUNT(e) FROM PersistentAuditEvent e GROUP BY e.principal")
|
||||
List<Object[]> countByPrincipal();
|
||||
|
||||
// Get distinct event types for filtering
|
||||
@Query("SELECT DISTINCT e.type FROM PersistentAuditEvent e ORDER BY e.type")
|
||||
List<String> findDistinctEventTypes();
|
||||
}
|
@ -17,6 +17,9 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
@ -35,6 +38,7 @@ public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationF
|
||||
}
|
||||
|
||||
@Override
|
||||
@Audited(type = AuditEventType.USER_FAILED_LOGIN, level = AuditLevel.BASIC)
|
||||
public void onAuthenticationFailure(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
|
@ -14,6 +14,9 @@ import jakarta.servlet.http.HttpSession;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.util.RequestUriUtils;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@ -31,6 +34,7 @@ public class CustomAuthenticationSuccessHandler
|
||||
}
|
||||
|
||||
@Override
|
||||
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
|
||||
public void onAuthenticationSuccess(
|
||||
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
|
||||
throws ServletException, IOException {
|
||||
|
@ -28,6 +28,9 @@ import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
|
||||
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
|
||||
import stirling.software.common.model.oauth2.KeycloakProvider;
|
||||
import stirling.software.common.util.UrlUtils;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
import stirling.software.proprietary.security.saml2.CertificateUtils;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
|
||||
@ -42,6 +45,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
private final AppConfig appConfig;
|
||||
|
||||
@Override
|
||||
@Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC)
|
||||
public void onLogoutSuccess(
|
||||
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
|
||||
throws IOException {
|
||||
|
@ -2,6 +2,7 @@ package stirling.software.proprietary.security;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
@ -53,15 +54,23 @@ public class InitialSecuritySetup {
|
||||
|
||||
private void assignUsersToDefaultTeamIfMissing() {
|
||||
Team defaultTeam = teamService.getOrCreateDefaultTeam();
|
||||
Team internalTeam = teamService.getOrCreateInternalTeam();
|
||||
List<User> usersWithoutTeam = userService.getUsersWithoutTeam();
|
||||
|
||||
for (User user : usersWithoutTeam) {
|
||||
user.setTeam(defaultTeam);
|
||||
if (user.getUsername().equalsIgnoreCase(Role.INTERNAL_API_USER.getRoleId())) {
|
||||
user.setTeam(internalTeam);
|
||||
} else {
|
||||
user.setTeam(defaultTeam);
|
||||
}
|
||||
}
|
||||
|
||||
userService.saveAll(usersWithoutTeam); // batch save
|
||||
log.info(
|
||||
"Assigned {} user(s) without a team to the default team.", usersWithoutTeam.size());
|
||||
if (usersWithoutTeam != null && !usersWithoutTeam.isEmpty()) {
|
||||
log.info(
|
||||
"Assigned {} user(s) without a team to the default team.",
|
||||
usersWithoutTeam.size());
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeAdminUser() throws SQLException, UnsupportedProviderException {
|
||||
@ -108,6 +117,20 @@ public class InitialSecuritySetup {
|
||||
false);
|
||||
userService.addApiKeyToUser(Role.INTERNAL_API_USER.getRoleId());
|
||||
log.info("Internal API user created: {}", Role.INTERNAL_API_USER.getRoleId());
|
||||
} else {
|
||||
Optional<User> internalApiUserOpt =
|
||||
userService.findByUsernameIgnoreCase(Role.INTERNAL_API_USER.getRoleId());
|
||||
if (internalApiUserOpt.isPresent()) {
|
||||
User internalApiUser = internalApiUserOpt.get();
|
||||
// move to team internal API user
|
||||
if (!internalApiUser.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
log.info(
|
||||
"Moving internal API user to team: {}", TeamService.INTERNAL_TEAM_NAME);
|
||||
Team internalTeam = teamService.getOrCreateInternalTeam();
|
||||
|
||||
userService.changeUserTeam(internalApiUser, internalTeam);
|
||||
}
|
||||
}
|
||||
}
|
||||
userService.syncCustomApiUser(applicationProperties.getSecurity().getCustomGlobalAPIKey());
|
||||
}
|
||||
|
@ -228,7 +228,7 @@ public class AccountWebController {
|
||||
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())) {
|
||||
@ -237,12 +237,13 @@ public class AccountWebController {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Also check if user is part of the Internal team
|
||||
if (user.getTeam() != null && user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
if (user.getTeam() != null
|
||||
&& user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
shouldRemove = true;
|
||||
}
|
||||
|
||||
|
||||
// Remove the user if either condition is true
|
||||
if (shouldRemove) {
|
||||
iterator.remove();
|
||||
@ -336,6 +337,9 @@ public class AccountWebController {
|
||||
case "userNotFound" -> "userNotFoundMessage";
|
||||
case "downgradeCurrentUser" -> "downgradeCurrentUserMessage";
|
||||
case "disabledCurrentUser" -> "disabledCurrentUserMessage";
|
||||
case "cannotMoveInternalUsers" -> "team.cannotMoveInternalUsers";
|
||||
case "internalTeamNotAccessible" -> "team.internalTeamNotAccessible";
|
||||
case "invalidRole" -> "invalidRoleMessage";
|
||||
default -> messageType;
|
||||
};
|
||||
model.addAttribute("changeMessage", changeMessage);
|
||||
@ -351,10 +355,16 @@ public class AccountWebController {
|
||||
model.addAttribute("disabledUsers", disabledUsers);
|
||||
|
||||
// Get all teams but filter out the Internal team
|
||||
List<Team> allTeams = teamRepository.findAll()
|
||||
.stream()
|
||||
.filter(team -> !team.getName().equals(stirling.software.proprietary.security.service.TeamService.INTERNAL_TEAM_NAME))
|
||||
.toList();
|
||||
List<Team> allTeams =
|
||||
teamRepository.findAll().stream()
|
||||
.filter(
|
||||
team ->
|
||||
!team.getName()
|
||||
.equals(
|
||||
stirling.software.proprietary.security
|
||||
.service.TeamService
|
||||
.INTERNAL_TEAM_NAME))
|
||||
.toList();
|
||||
model.addAttribute("teams", allTeams);
|
||||
|
||||
model.addAttribute("maxPaidUsers", applicationProperties.getPremium().getMaxUsers());
|
||||
|
@ -0,0 +1,11 @@
|
||||
package stirling.software.proprietary.security.config;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/** Annotation to mark endpoints that require an Enterprise license. */
|
||||
@Target({ElementType.METHOD, ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface EnterpriseEndpoint {}
|
@ -0,0 +1,30 @@
|
||||
package stirling.software.proprietary.security.config;
|
||||
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@Aspect
|
||||
@Component
|
||||
public class EnterpriseEndpointAspect {
|
||||
|
||||
private final boolean runningEE;
|
||||
|
||||
public EnterpriseEndpointAspect(@Qualifier("runningEE") boolean runningEE) {
|
||||
this.runningEE = runningEE;
|
||||
}
|
||||
|
||||
@Around(
|
||||
"@annotation(stirling.software.proprietary.security.config.EnterpriseEndpoint) || @within(stirling.software.proprietary.security.config.EnterpriseEndpoint)")
|
||||
public Object checkEnterpriseAccess(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
if (!runningEE) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN, "This endpoint requires an Enterprise license");
|
||||
}
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
}
|
@ -81,9 +81,9 @@ public class EEAppConfig {
|
||||
|
||||
// Copy the license key if it's set in enterprise but not in premium
|
||||
if (premium.getKey() == null
|
||||
|| premium.getKey().equals("00000000-0000-0000-0000-000000000000")) {
|
||||
|| "00000000-0000-0000-0000-000000000000".equals(premium.getKey())) {
|
||||
if (enterpriseEdition.getKey() != null
|
||||
&& !enterpriseEdition.getKey().equals("00000000-0000-0000-0000-000000000000")) {
|
||||
&& !"00000000-0000-0000-0000-000000000000".equals(enterpriseEdition.getKey())) {
|
||||
premium.setKey(enterpriseEdition.getKey());
|
||||
}
|
||||
}
|
||||
|
@ -36,12 +36,12 @@ public class TeamController {
|
||||
@PostMapping("/create")
|
||||
public RedirectView createTeam(@RequestParam("name") String name) {
|
||||
if (teamRepository.existsByNameIgnoreCase(name)) {
|
||||
return new RedirectView("/adminSettings?messageType=teamExists");
|
||||
return new RedirectView("/teams?messageType=teamExists");
|
||||
}
|
||||
Team team = new Team();
|
||||
team.setName(name);
|
||||
teamRepository.save(team);
|
||||
return new RedirectView("/adminSettings?messageType=teamCreated");
|
||||
return new RedirectView("/teams?messageType=teamCreated");
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@ -50,21 +50,21 @@ public class TeamController {
|
||||
@RequestParam("teamId") Long teamId, @RequestParam("newName") String newName) {
|
||||
Optional<Team> existing = teamRepository.findById(teamId);
|
||||
if (existing.isEmpty()) {
|
||||
return new RedirectView("/adminSettings?messageType=teamNotFound");
|
||||
return new RedirectView("/teams?messageType=teamNotFound");
|
||||
}
|
||||
if (teamRepository.existsByNameIgnoreCase(newName)) {
|
||||
return new RedirectView("/adminSettings?messageType=teamNameExists");
|
||||
return new RedirectView("/teams?messageType=teamNameExists");
|
||||
}
|
||||
Team team = existing.get();
|
||||
|
||||
|
||||
// Prevent renaming the Internal team
|
||||
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
return new RedirectView("/adminSettings?messageType=internalTeamNotAccessible");
|
||||
return new RedirectView("/teams?messageType=internalTeamNotAccessible");
|
||||
}
|
||||
|
||||
|
||||
team.setName(newName);
|
||||
teamRepository.save(team);
|
||||
return new RedirectView("/adminSettings?messageType=teamRenamed");
|
||||
return new RedirectView("/teams?messageType=teamRenamed");
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@ -73,54 +73,58 @@ public class TeamController {
|
||||
public RedirectView deleteTeam(@RequestParam("teamId") Long teamId) {
|
||||
Optional<Team> teamOpt = teamRepository.findById(teamId);
|
||||
if (teamOpt.isEmpty()) {
|
||||
return new RedirectView("/adminSettings?messageType=teamNotFound");
|
||||
return new RedirectView("/teams?messageType=teamNotFound");
|
||||
}
|
||||
|
||||
Team team = teamOpt.get();
|
||||
|
||||
|
||||
// Prevent deleting the Internal team
|
||||
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
return new RedirectView("/adminSettings?messageType=internalTeamNotAccessible");
|
||||
return new RedirectView("/teams?messageType=internalTeamNotAccessible");
|
||||
}
|
||||
|
||||
|
||||
long memberCount = userRepository.countByTeam(team);
|
||||
if (memberCount > 0) {
|
||||
return new RedirectView("/adminSettings?messageType=teamHasUsers");
|
||||
return new RedirectView("/teams?messageType=teamHasUsers");
|
||||
}
|
||||
|
||||
teamRepository.delete(team);
|
||||
return new RedirectView("/adminSettings?messageType=teamDeleted");
|
||||
return new RedirectView("/teams?messageType=teamDeleted");
|
||||
}
|
||||
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/addUser")
|
||||
@Transactional
|
||||
public RedirectView addUserToTeam(
|
||||
@RequestParam("teamId") Long teamId,
|
||||
@RequestParam("userId") Long userId) {
|
||||
|
||||
@RequestParam("teamId") Long teamId, @RequestParam("userId") Long userId) {
|
||||
|
||||
// Find the team
|
||||
Team team = teamRepository.findById(teamId)
|
||||
.orElseThrow(() -> new RuntimeException("Team not found"));
|
||||
|
||||
Team team =
|
||||
teamRepository
|
||||
.findById(teamId)
|
||||
.orElseThrow(() -> new RuntimeException("Team not found"));
|
||||
|
||||
// Prevent adding users to the Internal team
|
||||
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
return new RedirectView("/teams?error=internalTeamNotAccessible");
|
||||
}
|
||||
|
||||
|
||||
// Find the user
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||
|
||||
User user =
|
||||
userRepository
|
||||
.findById(userId)
|
||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||
|
||||
// Check if user is in the Internal team - prevent moving them
|
||||
if (user.getTeam() != null && user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
if (user.getTeam() != null
|
||||
&& user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
return new RedirectView("/teams/" + teamId + "?error=cannotMoveInternalUsers");
|
||||
}
|
||||
|
||||
|
||||
// Assign user to team
|
||||
user.setTeam(team);
|
||||
userRepository.save(user);
|
||||
|
||||
|
||||
// Redirect back to team details page
|
||||
return new RedirectView("/teams/" + teamId + "?messageType=userAdded");
|
||||
}
|
||||
|
@ -57,6 +57,7 @@ public class UserController {
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final TeamRepository teamRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/register")
|
||||
public String register(@ModelAttribute UsernameAndPass requestModel, Model model)
|
||||
@ -246,22 +247,25 @@ public class UserController {
|
||||
// If the role ID is not valid, redirect with an error message
|
||||
return new RedirectView("/adminSettings?messageType=invalidRole", true);
|
||||
}
|
||||
|
||||
|
||||
// Use teamId if provided, otherwise use default team
|
||||
Long effectiveTeamId = teamId;
|
||||
if (effectiveTeamId == null) {
|
||||
Team defaultTeam = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
|
||||
Team defaultTeam =
|
||||
teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
|
||||
if (defaultTeam != null) {
|
||||
effectiveTeamId = defaultTeam.getId();
|
||||
}
|
||||
} else {
|
||||
// Check if the selected team is Internal - prevent assigning to it
|
||||
Team selectedTeam = teamRepository.findById(effectiveTeamId).orElse(null);
|
||||
if (selectedTeam != null && TeamService.INTERNAL_TEAM_NAME.equals(selectedTeam.getName())) {
|
||||
return new RedirectView("/adminSettings?messageType=internalTeamNotAccessible", true);
|
||||
if (selectedTeam != null
|
||||
&& TeamService.INTERNAL_TEAM_NAME.equals(selectedTeam.getName())) {
|
||||
return new RedirectView(
|
||||
"/adminSettings?messageType=internalTeamNotAccessible", true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) {
|
||||
userService.saveUser(username, AuthenticationType.SSO, effectiveTeamId, role);
|
||||
} else {
|
||||
@ -309,26 +313,29 @@ public class UserController {
|
||||
return new RedirectView("/adminSettings?messageType=invalidRole", true);
|
||||
}
|
||||
User user = userOpt.get();
|
||||
|
||||
|
||||
// Update the team if a teamId is provided
|
||||
if (teamId != null) {
|
||||
Team team = teamRepository.findById(teamId).orElse(null);
|
||||
if (team != null) {
|
||||
// Prevent assigning to Internal team
|
||||
if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) {
|
||||
return new RedirectView("/adminSettings?messageType=internalTeamNotAccessible", true);
|
||||
return new RedirectView(
|
||||
"/adminSettings?messageType=internalTeamNotAccessible", true);
|
||||
}
|
||||
|
||||
|
||||
// Prevent moving users from Internal team
|
||||
if (user.getTeam() != null && TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) {
|
||||
return new RedirectView("/adminSettings?messageType=cannotMoveInternalUsers", true);
|
||||
if (user.getTeam() != null
|
||||
&& TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) {
|
||||
return new RedirectView(
|
||||
"/adminSettings?messageType=cannotMoveInternalUsers", true);
|
||||
}
|
||||
|
||||
|
||||
user.setTeam(team);
|
||||
userRepository.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
userService.changeRole(user, role);
|
||||
return new RedirectView(
|
||||
"/adminSettings", // Redirect to account page after adding the user
|
||||
|
@ -12,6 +12,8 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@ -35,14 +37,15 @@ public class TeamWebController {
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
public String listTeams(Model model) {
|
||||
public String listTeams(HttpServletRequest request, Model model) {
|
||||
// Get teams with user counts using a DTO projection
|
||||
List<TeamWithUserCountDTO> allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount();
|
||||
|
||||
|
||||
// Filter out the Internal team
|
||||
List<TeamWithUserCountDTO> teamsWithCounts = allTeamsWithCounts.stream()
|
||||
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME))
|
||||
.toList();
|
||||
List<TeamWithUserCountDTO> teamsWithCounts =
|
||||
allTeamsWithCounts.stream()
|
||||
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME))
|
||||
.toList();
|
||||
|
||||
// Get the latest activity for each team
|
||||
List<Object[]> teamActivities = sessionRepository.findLatestActivityByTeam();
|
||||
@ -55,36 +58,69 @@ public class TeamWebController {
|
||||
teamLastRequest.put(teamId, lastActivity);
|
||||
}
|
||||
|
||||
String messageType = request.getParameter("messageType");
|
||||
if (messageType != null) {
|
||||
if ("teamCreated".equals(messageType)) {
|
||||
model.addAttribute("addMessage", "teamCreated");
|
||||
} else if ("teamExists".equals(messageType)) {
|
||||
model.addAttribute("errorMessage", "teamExists");
|
||||
} else if ("teamNotFound".equals(messageType)) {
|
||||
model.addAttribute("errorMessage", "teamNotFound");
|
||||
} else if ("teamNameExists".equals(messageType)) {
|
||||
model.addAttribute("errorMessage", "teamNameExists");
|
||||
} else if ("internalTeamNotAccessible".equals(messageType)) {
|
||||
model.addAttribute("errorMessage", "team.internalTeamNotAccessible");
|
||||
} else if ("teamRenamed".equals(messageType)) {
|
||||
model.addAttribute("changeMessage", "teamRenamed");
|
||||
} else if ("teamHasUsers".equals(messageType)) {
|
||||
model.addAttribute("errorMessage", "teamHasUsers");
|
||||
} else if ("teamDeleted".equals(messageType)) {
|
||||
model.addAttribute("deleteMessage", "teamDeleted");
|
||||
}
|
||||
}
|
||||
|
||||
// Add data to the model
|
||||
model.addAttribute("teamsWithCounts", teamsWithCounts);
|
||||
model.addAttribute("teamLastRequest", teamLastRequest);
|
||||
|
||||
|
||||
return "accounts/teams";
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
public String viewTeamDetails(@PathVariable("id") Long id, Model model) {
|
||||
public String viewTeamDetails(
|
||||
HttpServletRequest request, @PathVariable("id") Long id, Model model) {
|
||||
// Get the team
|
||||
Team team = teamRepository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("Team not found"));
|
||||
|
||||
Team team =
|
||||
teamRepository
|
||||
.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("Team not found"));
|
||||
|
||||
// Prevent access to Internal team
|
||||
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
return "redirect:/teams?error=internalTeamNotAccessible";
|
||||
}
|
||||
|
||||
|
||||
// Get users for this team directly using the direct query
|
||||
List<User> teamUsers = userRepository.findAllByTeamId(id);
|
||||
|
||||
|
||||
// Get all users not in this team for the Add User to Team dropdown
|
||||
// Exclude users that are in the Internal team
|
||||
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<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();
|
||||
|
||||
// Get the latest session for each user in the team
|
||||
List<Object[]> userSessions = sessionRepository.findLatestSessionByTeamId(id);
|
||||
|
||||
@ -96,6 +132,13 @@ public class TeamWebController {
|
||||
userLastRequest.put(username, lastRequest);
|
||||
}
|
||||
|
||||
String errorMessage = request.getParameter("error");
|
||||
if (errorMessage != null) {
|
||||
if ("cannotMoveInternalUsers".equals(errorMessage)) {
|
||||
model.addAttribute("errorMessage", "team.cannotMoveInternalUsers");
|
||||
}
|
||||
}
|
||||
|
||||
model.addAttribute("team", team);
|
||||
model.addAttribute("teamUsers", teamUsers);
|
||||
model.addAttribute("availableUsers", availableUsers);
|
||||
|
@ -29,8 +29,9 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
@Query(value = "SELECT u FROM User u LEFT JOIN FETCH u.team")
|
||||
List<User> findAllWithTeam();
|
||||
|
||||
@Query("SELECT u FROM User u JOIN FETCH u.authorities JOIN FETCH u.team WHERE u.team.id = :teamId")
|
||||
|
||||
@Query(
|
||||
"SELECT u FROM User u JOIN FETCH u.authorities JOIN FETCH u.team WHERE u.team.id = :teamId")
|
||||
List<User> findAllByTeamId(@Param("teamId") Long teamId);
|
||||
|
||||
long countByTeam(Team team);
|
||||
|
@ -58,7 +58,7 @@ public class User implements Serializable {
|
||||
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
|
||||
private Set<Authority> authorities = new HashSet<>();
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "team_id")
|
||||
private Team team;
|
||||
|
||||
|
@ -5,7 +5,6 @@ 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.Team;
|
||||
@ -15,8 +14,9 @@ import stirling.software.proprietary.model.dto.TeamWithUserCountDTO;
|
||||
public interface TeamRepository extends JpaRepository<Team, Long> {
|
||||
Optional<Team> findByName(String name);
|
||||
|
||||
@Query("SELECT new stirling.software.proprietary.model.dto.TeamWithUserCountDTO(t.id, t.name, COUNT(u)) " +
|
||||
"FROM Team t LEFT JOIN t.users u GROUP BY t.id, t.name")
|
||||
@Query(
|
||||
"SELECT new stirling.software.proprietary.model.dto.TeamWithUserCountDTO(t.id, t.name, COUNT(u)) "
|
||||
+ "FROM Team t LEFT JOIN t.users u GROUP BY t.id, t.name")
|
||||
List<TeamWithUserCountDTO> findAllTeamsWithUserCount();
|
||||
|
||||
boolean existsByNameIgnoreCase(String name);
|
||||
|
@ -371,6 +371,16 @@ public class UserService implements UserServiceInterface {
|
||||
databaseService.exportDatabase();
|
||||
}
|
||||
|
||||
public void changeUserTeam(User user, Team team)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
if (team == null) {
|
||||
team = getDefaultTeam();
|
||||
}
|
||||
user.setTeam(team);
|
||||
userRepository.save(user);
|
||||
databaseService.exportDatabase();
|
||||
}
|
||||
|
||||
public boolean isPasswordCorrect(User user, String currentPassword) {
|
||||
return passwordEncoder.matches(currentPassword, user.getPassword());
|
||||
}
|
||||
|
@ -0,0 +1,109 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
|
||||
/** Service to periodically clean up old audit events based on retention policy. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuditCleanupService {
|
||||
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
private final AuditConfigurationProperties auditConfig;
|
||||
|
||||
// Default batch size for deletions
|
||||
private static final int BATCH_SIZE = 10000;
|
||||
|
||||
/**
|
||||
* Scheduled task that runs daily to clean up old audit events. The retention period is
|
||||
* configurable in settings.yml.
|
||||
*/
|
||||
@Scheduled(fixedDelay = 1, initialDelay = 1, timeUnit = TimeUnit.DAYS)
|
||||
public void cleanupOldAuditEvents() {
|
||||
if (!auditConfig.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
int retentionDays = auditConfig.getRetentionDays();
|
||||
if (retentionDays <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Starting audit cleanup for events older than {} days", retentionDays);
|
||||
|
||||
try {
|
||||
Instant cutoffDate = Instant.now().minus(retentionDays, ChronoUnit.DAYS);
|
||||
int totalDeleted = batchDeleteEvents(cutoffDate);
|
||||
log.info(
|
||||
"Successfully cleaned up {} audit events older than {}",
|
||||
totalDeleted,
|
||||
cutoffDate);
|
||||
} catch (Exception e) {
|
||||
log.error("Error cleaning up old audit events", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs batch deletion of events to prevent long-running transactions and potential database
|
||||
* locks.
|
||||
*/
|
||||
private int batchDeleteEvents(Instant cutoffDate) {
|
||||
int totalDeleted = 0;
|
||||
boolean hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
// Start a new transaction for each batch
|
||||
List<Long> batchIds = findBatchOfIdsToDelete(cutoffDate);
|
||||
|
||||
if (batchIds.isEmpty()) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
int deleted = deleteBatch(batchIds);
|
||||
totalDeleted += deleted;
|
||||
|
||||
// If we got fewer records than the batch size, we're done
|
||||
if (batchIds.size() < BATCH_SIZE) {
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return totalDeleted;
|
||||
}
|
||||
|
||||
/** Finds a batch of IDs to delete. */
|
||||
@Transactional(readOnly = true)
|
||||
private List<Long> findBatchOfIdsToDelete(Instant cutoffDate) {
|
||||
PageRequest pageRequest = PageRequest.of(0, BATCH_SIZE, Sort.by("id"));
|
||||
return auditRepository.findIdsForBatchDeletion(cutoffDate, pageRequest);
|
||||
}
|
||||
|
||||
/** Deletes a batch of events by ID. Each batch is in its own transaction. */
|
||||
@Transactional
|
||||
private int deleteBatch(List<Long> batchIds) {
|
||||
if (batchIds.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int batchSize = batchIds.size();
|
||||
auditRepository.deleteAllByIdInBatch(batchIds);
|
||||
log.debug("Deleted batch of {} audit events", batchSize);
|
||||
|
||||
return batchSize;
|
||||
}
|
||||
}
|
@ -0,0 +1,169 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.actuate.audit.AuditEvent;
|
||||
import org.springframework.boot.actuate.audit.AuditEventRepository;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Service for creating manual audit events throughout the application. This provides easy access to
|
||||
* audit functionality in any component.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class AuditService {
|
||||
|
||||
private final AuditEventRepository repository;
|
||||
private final AuditConfigurationProperties auditConfig;
|
||||
private final boolean runningEE;
|
||||
|
||||
public AuditService(
|
||||
AuditEventRepository repository,
|
||||
AuditConfigurationProperties auditConfig,
|
||||
@org.springframework.beans.factory.annotation.Qualifier("runningEE")
|
||||
boolean runningEE) {
|
||||
this.repository = repository;
|
||||
this.auditConfig = auditConfig;
|
||||
this.runningEE = runningEE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an audit event for the current authenticated user with a specific audit level using
|
||||
* the standardized AuditEventType enum
|
||||
*
|
||||
* @param type The event type from AuditEventType enum
|
||||
* @param data Additional event data (will be automatically sanitized)
|
||||
* @param level The minimum audit level required for this event to be logged
|
||||
*/
|
||||
public void audit(AuditEventType type, Map<String, Object> data, AuditLevel level) {
|
||||
// Skip auditing if this level is not enabled or if not Enterprise edition
|
||||
if (!auditConfig.isEnabled()
|
||||
|| !auditConfig.getAuditLevel().includes(level)
|
||||
|| !runningEE) {
|
||||
return;
|
||||
}
|
||||
|
||||
String principal = getCurrentUsername();
|
||||
repository.add(new AuditEvent(principal, type.name(), data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an audit event for the current authenticated user with standard level using the
|
||||
* standardized AuditEventType enum
|
||||
*
|
||||
* @param type The event type from AuditEventType enum
|
||||
* @param data Additional event data (will be automatically sanitized)
|
||||
*/
|
||||
public void audit(AuditEventType type, Map<String, Object> data) {
|
||||
// Default to STANDARD level
|
||||
audit(type, data, AuditLevel.STANDARD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an audit event for a specific user with a specific audit level using the standardized
|
||||
* AuditEventType enum
|
||||
*
|
||||
* @param principal The username or system identifier
|
||||
* @param type The event type from AuditEventType enum
|
||||
* @param data Additional event data (will be automatically sanitized)
|
||||
* @param level The minimum audit level required for this event to be logged
|
||||
*/
|
||||
public void audit(
|
||||
String principal, AuditEventType type, Map<String, Object> data, AuditLevel level) {
|
||||
// Skip auditing if this level is not enabled or if not Enterprise edition
|
||||
if (!auditConfig.isLevelEnabled(level) || !runningEE) {
|
||||
return;
|
||||
}
|
||||
|
||||
repository.add(new AuditEvent(principal, type.name(), data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an audit event for a specific user with standard level using the standardized
|
||||
* AuditEventType enum
|
||||
*
|
||||
* @param principal The username or system identifier
|
||||
* @param type The event type from AuditEventType enum
|
||||
* @param data Additional event data (will be automatically sanitized)
|
||||
*/
|
||||
public void audit(String principal, AuditEventType type, Map<String, Object> data) {
|
||||
// Default to STANDARD level
|
||||
audit(principal, type, data, AuditLevel.STANDARD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an audit event for the current authenticated user with a specific audit level using a
|
||||
* string-based event type (for backward compatibility)
|
||||
*
|
||||
* @param type The event type (e.g., "FILE_UPLOAD", "PASSWORD_CHANGE")
|
||||
* @param data Additional event data (will be automatically sanitized)
|
||||
* @param level The minimum audit level required for this event to be logged
|
||||
*/
|
||||
public void audit(String type, Map<String, Object> data, AuditLevel level) {
|
||||
// Skip auditing if this level is not enabled or if not Enterprise edition
|
||||
if (!auditConfig.isLevelEnabled(level) || !runningEE) {
|
||||
return;
|
||||
}
|
||||
|
||||
String principal = getCurrentUsername();
|
||||
repository.add(new AuditEvent(principal, type, data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an audit event for the current authenticated user with standard level using a
|
||||
* string-based event type (for backward compatibility)
|
||||
*
|
||||
* @param type The event type (e.g., "FILE_UPLOAD", "PASSWORD_CHANGE")
|
||||
* @param data Additional event data (will be automatically sanitized)
|
||||
*/
|
||||
public void audit(String type, Map<String, Object> data) {
|
||||
// Default to STANDARD level
|
||||
audit(type, data, AuditLevel.STANDARD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an audit event for a specific user with a specific audit level using a string-based
|
||||
* event type (for backward compatibility)
|
||||
*
|
||||
* @param principal The username or system identifier
|
||||
* @param type The event type (e.g., "FILE_UPLOAD", "PASSWORD_CHANGE")
|
||||
* @param data Additional event data (will be automatically sanitized)
|
||||
* @param level The minimum audit level required for this event to be logged
|
||||
*/
|
||||
public void audit(String principal, String type, Map<String, Object> data, AuditLevel level) {
|
||||
// Skip auditing if this level is not enabled or if not Enterprise edition
|
||||
if (!auditConfig.isLevelEnabled(level) || !runningEE) {
|
||||
return;
|
||||
}
|
||||
|
||||
repository.add(new AuditEvent(principal, type, data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an audit event for a specific user with standard level using a string-based event type
|
||||
* (for backward compatibility)
|
||||
*
|
||||
* @param principal The username or system identifier
|
||||
* @param type The event type (e.g., "FILE_UPLOAD", "PASSWORD_CHANGE")
|
||||
* @param data Additional event data (will be automatically sanitized)
|
||||
*/
|
||||
public void audit(String principal, String type, Map<String, Object> data) {
|
||||
// Default to STANDARD level
|
||||
audit(principal, type, data, AuditLevel.STANDARD);
|
||||
}
|
||||
|
||||
/** Get the current authenticated username or "system" if none */
|
||||
private String getCurrentUsername() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
return (auth != null && auth.getName() != null) ? auth.getName() : "system";
|
||||
}
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
package stirling.software.proprietary.util;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/** Redacts any map values whose keys match common secret/token patterns. */
|
||||
@Slf4j
|
||||
public final class SecretMasker {
|
||||
|
||||
private static final Pattern SENSITIVE =
|
||||
Pattern.compile(
|
||||
"(?i)(password|token|secret|api[_-]?key|authorization|auth|jwt|cred|cert)");
|
||||
|
||||
private SecretMasker() {}
|
||||
|
||||
public static Map<String, Object> mask(Map<String, Object> in) {
|
||||
if (in == null) return null;
|
||||
|
||||
return in.entrySet().stream()
|
||||
.filter(e -> e.getValue() != null)
|
||||
.collect(
|
||||
Collectors.toMap(
|
||||
Map.Entry::getKey, e -> deepMaskValue(e.getKey(), e.getValue())));
|
||||
}
|
||||
|
||||
private static Object deepMask(Object value) {
|
||||
if (value instanceof Map<?, ?> m) {
|
||||
return m.entrySet().stream()
|
||||
.filter(e -> e.getValue() != null)
|
||||
.collect(
|
||||
Collectors.toMap(
|
||||
Map.Entry::getKey,
|
||||
e -> deepMaskValue((String) e.getKey(), e.getValue())));
|
||||
} else if (value instanceof List<?> list) {
|
||||
return list.stream().map(SecretMasker::deepMask).toList();
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private static Object deepMaskValue(String key, Object value) {
|
||||
if (key != null && SENSITIVE.matcher(key).find()) {
|
||||
return "***REDACTED***";
|
||||
}
|
||||
return deepMask(value);
|
||||
}
|
||||
}
|
@ -0,0 +1,93 @@
|
||||
package stirling.software.proprietary.web;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
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 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;
|
||||
|
||||
/** Filter that stores additional request information for audit purposes */
|
||||
@Slf4j
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
|
||||
@RequiredArgsConstructor
|
||||
public class AuditWebFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final String USER_AGENT_HEADER = "User-Agent";
|
||||
private static final String REFERER_HEADER = "Referer";
|
||||
private static final String ACCEPT_LANGUAGE_HEADER = "Accept-Language";
|
||||
private static final String CONTENT_TYPE_HEADER = "Content-Type";
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
// Store key request info in MDC for logging and later audit use
|
||||
try {
|
||||
// Store request headers
|
||||
String userAgent = request.getHeader(USER_AGENT_HEADER);
|
||||
if (userAgent != null) {
|
||||
MDC.put("userAgent", userAgent);
|
||||
}
|
||||
|
||||
String referer = request.getHeader(REFERER_HEADER);
|
||||
if (referer != null) {
|
||||
MDC.put("referer", referer);
|
||||
}
|
||||
|
||||
String acceptLanguage = request.getHeader(ACCEPT_LANGUAGE_HEADER);
|
||||
if (acceptLanguage != null) {
|
||||
MDC.put("acceptLanguage", acceptLanguage);
|
||||
}
|
||||
|
||||
String contentType = request.getHeader(CONTENT_TYPE_HEADER);
|
||||
if (contentType != null) {
|
||||
MDC.put("contentType", contentType);
|
||||
}
|
||||
|
||||
// Store authenticated user roles if available
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.getAuthorities() != null) {
|
||||
String roles =
|
||||
auth.getAuthorities().stream()
|
||||
.map(a -> a.getAuthority())
|
||||
.reduce((a, b) -> a + "," + b)
|
||||
.orElse("");
|
||||
MDC.put("userRoles", roles);
|
||||
}
|
||||
|
||||
// Store query parameters (without values for privacy)
|
||||
Map<String, String[]> parameterMap = request.getParameterMap();
|
||||
if (parameterMap != null && !parameterMap.isEmpty()) {
|
||||
String params = String.join(",", parameterMap.keySet());
|
||||
MDC.put("queryParams", params);
|
||||
}
|
||||
|
||||
// Continue with the filter chain
|
||||
filterChain.doFilter(request, response);
|
||||
|
||||
} finally {
|
||||
// Clear MDC after request is processed
|
||||
MDC.remove("userAgent");
|
||||
MDC.remove("referer");
|
||||
MDC.remove("acceptLanguage");
|
||||
MDC.remove("contentType");
|
||||
MDC.remove("userRoles");
|
||||
MDC.remove("queryParams");
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
package stirling.software.proprietary.web;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import io.github.pixee.security.Newlines;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/** Guarantees every request carries a stable X-Request-Id; propagates to MDC. */
|
||||
@Slf4j
|
||||
@Component
|
||||
public class CorrelationIdFilter extends OncePerRequestFilter {
|
||||
|
||||
public static final String HEADER = "X-Request-Id";
|
||||
public static final String MDC_KEY = "requestId";
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest req, HttpServletResponse res, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
try {
|
||||
String id = req.getHeader(HEADER);
|
||||
if (!StringUtils.hasText(id)) {
|
||||
id = UUID.randomUUID().toString();
|
||||
}
|
||||
req.setAttribute(MDC_KEY, id);
|
||||
MDC.put(MDC_KEY, id);
|
||||
res.setHeader(HEADER, Newlines.stripAll(id));
|
||||
|
||||
chain.doFilter(req, res);
|
||||
} finally {
|
||||
MDC.remove(MDC_KEY);
|
||||
}
|
||||
}
|
||||
}
|
239
proprietary/src/main/resources/static/css/audit-dashboard.css
Normal file
239
proprietary/src/main/resources/static/css/audit-dashboard.css
Normal file
@ -0,0 +1,239 @@
|
||||
.dashboard-card {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
background-color: var(--md-sys-color-surface-container);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
border: 1px solid var(--md-sys-color-outline-variant);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: var(--md-sys-color-surface-container-high);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
background-color: var(--md-sys-color-surface-container);
|
||||
}
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 1rem;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
.filter-card {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background-color: var(--md-sys-color-surface-container-low);
|
||||
border: 1px solid var(--md-sys-color-outline-variant);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--md-sys-color-surface-container-high, rgba(229, 232, 241, 0.8));
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.level-indicator {
|
||||
display: inline-block;
|
||||
padding: 5px 10px;
|
||||
border-radius: 15px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
.level-0 {
|
||||
background-color: var(--md-sys-color-error, #dc3545); /* Red */
|
||||
}
|
||||
.level-1 {
|
||||
background-color: var(--md-sys-color-secondary, #fd7e14); /* Orange */
|
||||
}
|
||||
.level-2 {
|
||||
background-color: var(--md-nav-section-color-other, #28a745); /* Green */
|
||||
}
|
||||
.level-3 {
|
||||
background-color: var(--md-sys-color-tertiary, #17a2b8); /* Teal */
|
||||
}
|
||||
/* Custom data table styling */
|
||||
.audit-table {
|
||||
font-size: 0.9rem;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
border-color: var(--md-sys-color-outline-variant);
|
||||
}
|
||||
|
||||
.audit-table tbody tr {
|
||||
background-color: var(--md-sys-color-surface-container-low);
|
||||
}
|
||||
|
||||
.audit-table tbody tr:nth-child(even) {
|
||||
background-color: var(--md-sys-color-surface-container);
|
||||
}
|
||||
|
||||
.audit-table tbody tr:hover {
|
||||
background-color: var(--md-sys-color-surface-container-high);
|
||||
}
|
||||
.audit-table th {
|
||||
background-color: var(--md-sys-color-surface-container-high);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
font-weight: bold;
|
||||
}
|
||||
.table-responsive {
|
||||
max-height: 600px;
|
||||
}
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 15px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid var(--md-sys-color-outline-variant);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
.pagination .page-item.active .page-link {
|
||||
background-color: var(--bs-primary);
|
||||
border-color: var(--bs-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pagination .page-link {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.pagination .page-link.disabled {
|
||||
pointer-events: none;
|
||||
color: var(--bs-secondary);
|
||||
background-color: var(--bs-light);
|
||||
}
|
||||
.json-viewer {
|
||||
background-color: var(--md-sys-color-surface-container-low);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
max-height: 350px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
white-space: pre-wrap;
|
||||
border: 1px solid var(--md-sys-color-outline-variant);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Simple, minimal radio styling - no extras */
|
||||
.form-check {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
#debug-console {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 400px;
|
||||
height: 200px;
|
||||
background: var(--md-sys-color-surface-container-highest, rgba(0,0,0,0.8));
|
||||
color: var(--md-sys-color-tertiary, #0f0);
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
z-index: 9999;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
display: none; /* Changed to none by default, enable with key command */
|
||||
}
|
||||
|
||||
/* Enhanced styling for radio buttons as buttons */
|
||||
label.btn-outline-primary {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border-color: var(--md-sys-color-primary);
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
label.btn-outline-primary.active {
|
||||
background-color: var(--md-sys-color-primary);
|
||||
color: var(--md-sys-color-on-primary);
|
||||
border-color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
label.btn-outline-primary input[type="radio"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Modal overrides for dark mode */
|
||||
.modal-content {
|
||||
background-color: var(--md-sys-color-surface-container);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
border-color: var(--md-sys-color-outline);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-bottom-color: var(--md-sys-color-outline-variant);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
border-top-color: var(--md-sys-color-outline-variant);
|
||||
}
|
||||
|
||||
/* Improved modal positioning */
|
||||
.modal-dialog-centered {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: calc(100% - 3.5rem);
|
||||
}
|
||||
|
||||
.modal {
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
/* Button overrides for theme consistency */
|
||||
.btn-outline-primary {
|
||||
color: var(--md-sys-color-primary);
|
||||
border-color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover {
|
||||
background-color: var(--md-sys-color-primary);
|
||||
color: var(--md-sys-color-on-primary);
|
||||
}
|
||||
|
||||
.btn-outline-secondary {
|
||||
color: var(--md-sys-color-secondary);
|
||||
border-color: var(--md-sys-color-secondary);
|
||||
}
|
||||
|
||||
.btn-outline-secondary:hover {
|
||||
background-color: var(--md-sys-color-secondary);
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--md-sys-color-primary);
|
||||
color: var(--md-sys-color-on-primary);
|
||||
border-color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--md-sys-color-secondary);
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
border-color: var(--md-sys-color-secondary);
|
||||
}
|
@ -384,4 +384,11 @@
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid
|
||||
}
|
||||
}
|
||||
|
||||
.text-overflow {
|
||||
max-width: 100px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
999
proprietary/src/main/resources/static/js/audit/dashboard.js
Normal file
999
proprietary/src/main/resources/static/js/audit/dashboard.js
Normal file
@ -0,0 +1,999 @@
|
||||
// Initialize variables
|
||||
let currentPage = 0;
|
||||
let pageSize = 20;
|
||||
let totalPages = 0;
|
||||
let typeFilter = '';
|
||||
let principalFilter = '';
|
||||
let startDateFilter = '';
|
||||
let endDateFilter = '';
|
||||
|
||||
// Charts
|
||||
let typeChart;
|
||||
let userChart;
|
||||
let timeChart;
|
||||
|
||||
// DOM elements - will properly initialize these during page load
|
||||
let auditTableBody;
|
||||
let pageSizeSelect;
|
||||
let typeFilterInput;
|
||||
let exportTypeFilterInput;
|
||||
let principalFilterInput;
|
||||
let startDateFilterInput;
|
||||
let endDateFilterInput;
|
||||
let applyFiltersButton;
|
||||
let resetFiltersButton;
|
||||
|
||||
|
||||
// Initialize page
|
||||
// Theme change listener to redraw charts when theme changes
|
||||
function setupThemeChangeListener() {
|
||||
// Watch for theme changes (usually by a class on body or html element)
|
||||
const observer = new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
if (mutation.attributeName === 'data-bs-theme' || mutation.attributeName === 'class') {
|
||||
// Redraw charts with new theme colors if they exist
|
||||
if (typeChart && userChart && timeChart) {
|
||||
// If we have stats data cached, use it
|
||||
if (window.cachedStatsData) {
|
||||
renderCharts(window.cachedStatsData);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Observe the document element for theme changes
|
||||
observer.observe(document.documentElement, { attributes: true });
|
||||
|
||||
// Also observe body for class changes
|
||||
observer.observe(document.body, { attributes: true });
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize DOM references
|
||||
auditTableBody = document.getElementById('auditTableBody');
|
||||
pageSizeSelect = document.getElementById('pageSizeSelect');
|
||||
typeFilterInput = document.getElementById('typeFilter');
|
||||
exportTypeFilterInput = document.getElementById('exportTypeFilter');
|
||||
principalFilterInput = document.getElementById('principalFilter');
|
||||
startDateFilterInput = document.getElementById('startDateFilter');
|
||||
endDateFilterInput = document.getElementById('endDateFilter');
|
||||
applyFiltersButton = document.getElementById('applyFilters');
|
||||
resetFiltersButton = document.getElementById('resetFilters');
|
||||
|
||||
// Load event types for dropdowns
|
||||
loadEventTypes();
|
||||
|
||||
// Show a loading message immediately
|
||||
if (auditTableBody) {
|
||||
auditTableBody.innerHTML =
|
||||
'<tr><td colspan="5" class="text-center"><div class="spinner-border spinner-border-sm" role="status"></div> ' + window.i18n.loading + '</td></tr>';
|
||||
}
|
||||
|
||||
// Make a direct API call first to avoid validation issues
|
||||
loadAuditData(0, pageSize);
|
||||
|
||||
// Load statistics for dashboard
|
||||
loadStats(7);
|
||||
|
||||
// Setup theme change listener
|
||||
setupThemeChangeListener();
|
||||
|
||||
// Set up event listeners
|
||||
pageSizeSelect.addEventListener('change', function() {
|
||||
pageSize = parseInt(this.value);
|
||||
window.originalPageSize = pageSize;
|
||||
currentPage = 0;
|
||||
window.requestedPage = 0;
|
||||
loadAuditData(0, pageSize);
|
||||
});
|
||||
|
||||
applyFiltersButton.addEventListener('click', function() {
|
||||
typeFilter = typeFilterInput.value.trim();
|
||||
principalFilter = principalFilterInput.value.trim();
|
||||
startDateFilter = startDateFilterInput.value;
|
||||
endDateFilter = endDateFilterInput.value;
|
||||
currentPage = 0;
|
||||
window.requestedPage = 0;
|
||||
loadAuditData(0, pageSize);
|
||||
});
|
||||
|
||||
resetFiltersButton.addEventListener('click', function() {
|
||||
// Reset input fields
|
||||
typeFilterInput.value = '';
|
||||
principalFilterInput.value = '';
|
||||
startDateFilterInput.value = '';
|
||||
endDateFilterInput.value = '';
|
||||
|
||||
// Reset filter variables
|
||||
typeFilter = '';
|
||||
principalFilter = '';
|
||||
startDateFilter = '';
|
||||
endDateFilter = '';
|
||||
|
||||
// Reset page
|
||||
currentPage = 0;
|
||||
window.requestedPage = 0;
|
||||
|
||||
// Update UI
|
||||
document.getElementById('currentPage').textContent = '1';
|
||||
|
||||
// Load data with reset filters
|
||||
loadAuditData(0, pageSize);
|
||||
});
|
||||
|
||||
// Reset export filters button
|
||||
document.getElementById('resetExportFilters').addEventListener('click', function() {
|
||||
exportTypeFilter.value = '';
|
||||
exportPrincipalFilter.value = '';
|
||||
exportStartDateFilter.value = '';
|
||||
exportEndDateFilter.value = '';
|
||||
});
|
||||
|
||||
// Make radio buttons behave like toggle buttons
|
||||
const radioLabels = document.querySelectorAll('label.btn-outline-primary');
|
||||
radioLabels.forEach(label => {
|
||||
const radio = label.querySelector('input[type="radio"]');
|
||||
|
||||
if (radio) {
|
||||
// Highlight the checked radio button's label
|
||||
if (radio.checked) {
|
||||
label.classList.add('active');
|
||||
}
|
||||
|
||||
// Handle clicking on the label
|
||||
label.addEventListener('click', function() {
|
||||
// Remove active class from all labels
|
||||
radioLabels.forEach(l => l.classList.remove('active'));
|
||||
|
||||
// Add active class to this label
|
||||
this.classList.add('active');
|
||||
|
||||
// Check this radio button
|
||||
radio.checked = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle export button
|
||||
exportButton.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Get selected format with fallback
|
||||
const selectedRadio = document.querySelector('input[name="exportFormat"]:checked');
|
||||
const exportFormat = selectedRadio ? selectedRadio.value : 'csv';
|
||||
exportAuditData(exportFormat);
|
||||
return false;
|
||||
};
|
||||
|
||||
// Set up pagination buttons
|
||||
document.getElementById('page-first').onclick = function() {
|
||||
if (currentPage > 0) {
|
||||
goToPage(0);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
document.getElementById('page-prev').onclick = function() {
|
||||
if (currentPage > 0) {
|
||||
goToPage(currentPage - 1);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
document.getElementById('page-next').onclick = function() {
|
||||
if (currentPage < totalPages - 1) {
|
||||
goToPage(currentPage + 1);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
document.getElementById('page-last').onclick = function() {
|
||||
if (totalPages > 0 && currentPage < totalPages - 1) {
|
||||
goToPage(totalPages - 1);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Set up tab change events
|
||||
const tabEls = document.querySelectorAll('button[data-bs-toggle="tab"]');
|
||||
tabEls.forEach(tabEl => {
|
||||
tabEl.addEventListener('shown.bs.tab', function (event) {
|
||||
const targetId = event.target.getAttribute('data-bs-target');
|
||||
if (targetId === '#dashboard') {
|
||||
// Redraw charts when dashboard tab is shown
|
||||
if (typeChart) typeChart.update();
|
||||
if (userChart) userChart.update();
|
||||
if (timeChart) timeChart.update();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Load audit data from server
|
||||
function loadAuditData(targetPage, realPageSize) {
|
||||
const requestedPage = targetPage !== undefined ? targetPage : window.requestedPage || 0;
|
||||
realPageSize = realPageSize || pageSize;
|
||||
|
||||
showLoading('table-loading');
|
||||
|
||||
// Always request page 0 from server, but with increased page size if needed
|
||||
let url = `/audit/data?page=${requestedPage}&size=${realPageSize}`;
|
||||
|
||||
if (typeFilter) url += `&type=${encodeURIComponent(typeFilter)}`;
|
||||
if (principalFilter) url += `&principal=${encodeURIComponent(principalFilter)}`;
|
||||
if (startDateFilter) url += `&startDate=${startDateFilter}`;
|
||||
if (endDateFilter) url += `&endDate=${endDateFilter}`;
|
||||
|
||||
// Update page indicator
|
||||
if (document.getElementById('page-indicator')) {
|
||||
document.getElementById('page-indicator').textContent = `Page ${requestedPage + 1} of ?`;
|
||||
}
|
||||
|
||||
fetch(url)
|
||||
.then(response => {
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
|
||||
|
||||
// Calculate the correct slice of data to show for the requested page
|
||||
let displayContent = data.content;
|
||||
|
||||
// Render the correct slice of data
|
||||
renderTable(displayContent);
|
||||
|
||||
// Calculate total pages based on the actual total elements
|
||||
const calculatedTotalPages = Math.ceil(data.totalElements / realPageSize);
|
||||
totalPages = calculatedTotalPages;
|
||||
currentPage = requestedPage; // Use our tracked page, not server's
|
||||
|
||||
|
||||
// Update UI
|
||||
document.getElementById('currentPage').textContent = currentPage + 1;
|
||||
document.getElementById('totalPages').textContent = totalPages;
|
||||
document.getElementById('totalRecords').textContent = data.totalElements;
|
||||
if (document.getElementById('page-indicator')) {
|
||||
document.getElementById('page-indicator').textContent = `Page ${currentPage + 1} of ${totalPages}`;
|
||||
}
|
||||
|
||||
// Re-enable buttons with correct state
|
||||
document.getElementById('page-first').disabled = currentPage === 0;
|
||||
document.getElementById('page-prev').disabled = currentPage === 0;
|
||||
document.getElementById('page-next').disabled = currentPage >= totalPages - 1;
|
||||
document.getElementById('page-last').disabled = currentPage >= totalPages - 1;
|
||||
|
||||
hideLoading('table-loading');
|
||||
|
||||
// Restore original page size for next operations
|
||||
if (window.originalPageSize && realPageSize !== window.originalPageSize) {
|
||||
pageSize = window.originalPageSize;
|
||||
|
||||
}
|
||||
|
||||
// Store original page size for recovery
|
||||
window.originalPageSize = realPageSize;
|
||||
|
||||
// Clear busy flag
|
||||
window.paginationBusy = false;
|
||||
|
||||
})
|
||||
.catch(error => {
|
||||
|
||||
if (auditTableBody) {
|
||||
auditTableBody.innerHTML = `<tr><td colspan="5" class="text-center">${window.i18n.errorLoading} ${error.message}</td></tr>`;
|
||||
}
|
||||
hideLoading('table-loading');
|
||||
|
||||
// Re-enable buttons
|
||||
document.getElementById('page-first').disabled = false;
|
||||
document.getElementById('page-prev').disabled = false;
|
||||
document.getElementById('page-next').disabled = false;
|
||||
document.getElementById('page-last').disabled = false;
|
||||
|
||||
// Clear busy flag
|
||||
window.paginationBusy = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Load statistics for charts
|
||||
function loadStats(days) {
|
||||
showLoading('type-chart-loading');
|
||||
showLoading('user-chart-loading');
|
||||
showLoading('time-chart-loading');
|
||||
|
||||
fetch(`/audit/stats?days=${days}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
document.getElementById('total-events').textContent = data.totalEvents;
|
||||
// Cache stats data for theme changes
|
||||
window.cachedStatsData = data;
|
||||
renderCharts(data);
|
||||
hideLoading('type-chart-loading');
|
||||
hideLoading('user-chart-loading');
|
||||
hideLoading('time-chart-loading');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading stats:', error);
|
||||
hideLoading('type-chart-loading');
|
||||
hideLoading('user-chart-loading');
|
||||
hideLoading('time-chart-loading');
|
||||
});
|
||||
}
|
||||
|
||||
// Export audit data
|
||||
function exportAuditData(format) {
|
||||
const type = exportTypeFilter.value.trim();
|
||||
const principal = exportPrincipalFilter.value.trim();
|
||||
const startDate = exportStartDateFilter.value;
|
||||
const endDate = exportEndDateFilter.value;
|
||||
|
||||
let url = format === 'json' ? '/audit/export/json?' : '/audit/export?';
|
||||
|
||||
if (type) url += `&type=${encodeURIComponent(type)}`;
|
||||
if (principal) url += `&principal=${encodeURIComponent(principal)}`;
|
||||
if (startDate) url += `&startDate=${startDate}`;
|
||||
if (endDate) url += `&endDate=${endDate}`;
|
||||
|
||||
// Trigger download
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
// Render table with audit data
|
||||
function renderTable(events) {
|
||||
|
||||
if (!events || events.length === 0) {
|
||||
auditTableBody.innerHTML = '<tr><td colspan="5" class="text-center">' + window.i18n.noEventsFound + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
auditTableBody.innerHTML = '';
|
||||
|
||||
events.forEach((event, index) => {
|
||||
try {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>${event.id || 'N/A'}</td>
|
||||
<td>${formatDate(event.timestamp)}</td>
|
||||
<td>${escapeHtml(event.principal || 'N/A')}</td>
|
||||
<td>${escapeHtml(event.type || 'N/A')}</td>
|
||||
<td><button class="btn btn-sm btn-outline-primary view-details">${window.i18n.viewDetails || 'View Details'}</button></td>
|
||||
`;
|
||||
|
||||
// Store event data for modal
|
||||
row.dataset.event = JSON.stringify(event);
|
||||
|
||||
// Add click handler for details button
|
||||
const detailsButton = row.querySelector('.view-details');
|
||||
if (detailsButton) {
|
||||
detailsButton.addEventListener('click', function() {
|
||||
showEventDetails(event);
|
||||
});
|
||||
}
|
||||
|
||||
auditTableBody.appendChild(row);
|
||||
} catch (rowError) {
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
auditTableBody.innerHTML = '<tr><td colspan="5" class="text-center">' + window.i18n.errorRendering + ' ' + e.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// Show event details in modal
|
||||
function showEventDetails(event) {
|
||||
// Get modal elements by ID with correct hyphenated IDs from HTML
|
||||
const modalId = document.getElementById('modal-id');
|
||||
const modalPrincipal = document.getElementById('modal-principal');
|
||||
const modalType = document.getElementById('modal-type');
|
||||
const modalTimestamp = document.getElementById('modal-timestamp');
|
||||
const modalData = document.getElementById('modal-data');
|
||||
const eventDetailsModal = document.getElementById('eventDetailsModal');
|
||||
|
||||
// Set modal content
|
||||
if (modalId) modalId.textContent = event.id;
|
||||
if (modalPrincipal) modalPrincipal.textContent = event.principal;
|
||||
if (modalType) modalType.textContent = event.type;
|
||||
if (modalTimestamp) modalTimestamp.textContent = formatDate(event.timestamp);
|
||||
|
||||
// Format JSON data
|
||||
if (modalData) {
|
||||
try {
|
||||
const dataObj = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
||||
modalData.textContent = JSON.stringify(dataObj, null, 2);
|
||||
} catch (e) {
|
||||
modalData.textContent = event.data || 'No data available';
|
||||
}
|
||||
}
|
||||
|
||||
// Show the modal
|
||||
if (eventDetailsModal) {
|
||||
const modal = new bootstrap.Modal(eventDetailsModal);
|
||||
modal.show();
|
||||
}
|
||||
}
|
||||
|
||||
// No need for a dynamic pagination renderer anymore as we're using static buttons
|
||||
|
||||
// Direct pagination approach - server seems to be hard-limited to returning 20 items
|
||||
function goToPage(page) {
|
||||
|
||||
// Basic validation - totalPages may not be initialized on first load
|
||||
if (page < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip validation against totalPages on first load
|
||||
if (totalPages > 0 && page >= totalPages) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Simple guard flag
|
||||
if (window.paginationBusy) {
|
||||
return;
|
||||
}
|
||||
window.paginationBusy = true;
|
||||
|
||||
try {
|
||||
|
||||
// Store the requested page for later
|
||||
window.requestedPage = page;
|
||||
currentPage = page;
|
||||
|
||||
// Update UI immediately for user feedback
|
||||
document.getElementById('currentPage').textContent = page + 1;
|
||||
|
||||
// Load data with this page
|
||||
loadAuditData(page, pageSize);
|
||||
} catch (e) {
|
||||
window.paginationBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Render charts
|
||||
function renderCharts(data) {
|
||||
// Get theme colors
|
||||
const colors = getThemeColors();
|
||||
|
||||
// Prepare data for charts
|
||||
const typeLabels = Object.keys(data.eventsByType);
|
||||
const typeValues = Object.values(data.eventsByType);
|
||||
|
||||
const userLabels = Object.keys(data.eventsByPrincipal);
|
||||
const userValues = Object.values(data.eventsByPrincipal);
|
||||
|
||||
// Sort days for time chart
|
||||
const timeLabels = Object.keys(data.eventsByDay).sort();
|
||||
const timeValues = timeLabels.map(day => data.eventsByDay[day] || 0);
|
||||
|
||||
// Chart.js global defaults for dark mode compatibility
|
||||
Chart.defaults.color = colors.text;
|
||||
Chart.defaults.borderColor = colors.grid;
|
||||
|
||||
// Type chart
|
||||
if (typeChart) {
|
||||
typeChart.destroy();
|
||||
}
|
||||
|
||||
const typeCtx = document.getElementById('typeChart').getContext('2d');
|
||||
typeChart = new Chart(typeCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: typeLabels,
|
||||
datasets: [{
|
||||
label: window.i18n.eventsByType,
|
||||
data: typeValues,
|
||||
backgroundColor: colors.chartColors.slice(0, typeLabels.length).map(color => {
|
||||
// Add transparency to the colors
|
||||
if (color.startsWith('rgb(')) {
|
||||
return color.replace('rgb(', 'rgba(').replace(')', ', 0.8)');
|
||||
}
|
||||
return color;
|
||||
}),
|
||||
borderColor: colors.chartColors.slice(0, typeLabels.length),
|
||||
borderWidth: 2,
|
||||
borderRadius: 4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: colors.text,
|
||||
font: {
|
||||
weight: colors.isDarkMode ? 'bold' : 'normal',
|
||||
size: 14
|
||||
},
|
||||
usePointStyle: true,
|
||||
pointStyle: 'rectRounded',
|
||||
boxWidth: 12,
|
||||
boxHeight: 12,
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
titleFont: {
|
||||
weight: 'bold',
|
||||
size: 14
|
||||
},
|
||||
bodyFont: {
|
||||
size: 13
|
||||
},
|
||||
backgroundColor: colors.isDarkMode ? 'rgba(40, 44, 52, 0.9)' : 'rgba(255, 255, 255, 0.9)',
|
||||
titleColor: colors.isDarkMode ? '#ffffff' : '#000000',
|
||||
bodyColor: colors.isDarkMode ? '#ffffff' : '#000000',
|
||||
borderColor: colors.isDarkMode ? 'rgba(255, 255, 255, 0.5)' : colors.grid,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
cornerRadius: 6,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return `${context.dataset.label}: ${context.raw}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: colors.text,
|
||||
font: {
|
||||
weight: colors.isDarkMode ? 'bold' : 'normal',
|
||||
size: 12
|
||||
},
|
||||
precision: 0 // Only show whole numbers
|
||||
},
|
||||
grid: {
|
||||
color: colors.isDarkMode ? 'rgba(255, 255, 255, 0.1)' : colors.grid
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Count',
|
||||
color: colors.text,
|
||||
font: {
|
||||
weight: colors.isDarkMode ? 'bold' : 'normal',
|
||||
size: 14
|
||||
}
|
||||
}
|
||||
},
|
||||
x: {
|
||||
ticks: {
|
||||
color: colors.text,
|
||||
font: {
|
||||
weight: colors.isDarkMode ? 'bold' : 'normal',
|
||||
size: 11
|
||||
},
|
||||
callback: function(value, index) {
|
||||
// Get the original label
|
||||
const label = this.getLabelForValue(value);
|
||||
// If the label is too long, truncate it
|
||||
const maxLength = 10;
|
||||
if (label.length > maxLength) {
|
||||
return label.substring(0, maxLength) + '...';
|
||||
}
|
||||
return label;
|
||||
},
|
||||
autoSkip: true,
|
||||
maxRotation: 0,
|
||||
minRotation: 0
|
||||
},
|
||||
grid: {
|
||||
color: colors.isDarkMode ? 'rgba(255, 255, 255, 0.1)' : colors.grid,
|
||||
display: false // Hide vertical gridlines for cleaner look
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Event Type',
|
||||
color: colors.text,
|
||||
font: {
|
||||
weight: colors.isDarkMode ? 'bold' : 'normal',
|
||||
size: 14
|
||||
},
|
||||
padding: {top: 10, bottom: 0}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// User chart
|
||||
if (userChart) {
|
||||
userChart.destroy();
|
||||
}
|
||||
|
||||
const userCtx = document.getElementById('userChart').getContext('2d');
|
||||
userChart = new Chart(userCtx, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: userLabels,
|
||||
datasets: [{
|
||||
label: window.i18n.eventsByUser,
|
||||
data: userValues,
|
||||
backgroundColor: colors.chartColors.slice(0, userLabels.length),
|
||||
borderWidth: 2,
|
||||
borderColor: colors.isDarkMode ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.5)'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
labels: {
|
||||
color: colors.text,
|
||||
font: {
|
||||
size: colors.isDarkMode ? 14 : 12,
|
||||
weight: colors.isDarkMode ? 'bold' : 'normal'
|
||||
},
|
||||
padding: 15,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
boxWidth: 10,
|
||||
boxHeight: 10,
|
||||
// Add a box around each label for better contrast in dark mode
|
||||
generateLabels: function(chart) {
|
||||
const original = Chart.overrides.pie.plugins.legend.labels.generateLabels;
|
||||
const labels = original.call(this, chart);
|
||||
|
||||
if (colors.isDarkMode) {
|
||||
labels.forEach(label => {
|
||||
// Enhance contrast for dark mode
|
||||
label.fillStyle = label.fillStyle; // Keep original fill
|
||||
label.strokeStyle = 'rgba(255, 255, 255, 0.8)'; // White border
|
||||
label.lineWidth = 2; // Thicker border
|
||||
});
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
titleFont: {
|
||||
weight: 'bold',
|
||||
size: 14
|
||||
},
|
||||
bodyFont: {
|
||||
size: 13
|
||||
},
|
||||
backgroundColor: colors.isDarkMode ? 'rgba(40, 44, 52, 0.9)' : 'rgba(255, 255, 255, 0.9)',
|
||||
titleColor: colors.isDarkMode ? '#ffffff' : '#000000',
|
||||
bodyColor: colors.isDarkMode ? '#ffffff' : '#000000',
|
||||
borderColor: colors.isDarkMode ? 'rgba(255, 255, 255, 0.5)' : colors.grid,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
cornerRadius: 6
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Time chart
|
||||
if (timeChart) {
|
||||
timeChart.destroy();
|
||||
}
|
||||
|
||||
const timeCtx = document.getElementById('timeChart').getContext('2d');
|
||||
|
||||
// Get first color for line chart with appropriate transparency
|
||||
let bgColor, borderColor;
|
||||
if (colors.isDarkMode) {
|
||||
bgColor = 'rgba(162, 201, 255, 0.3)'; // Light blue with transparency
|
||||
borderColor = 'rgb(162, 201, 255)'; // Light blue solid
|
||||
} else {
|
||||
bgColor = 'rgba(0, 96, 170, 0.2)'; // Dark blue with transparency
|
||||
borderColor = 'rgb(0, 96, 170)'; // Dark blue solid
|
||||
}
|
||||
|
||||
timeChart = new Chart(timeCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: timeLabels,
|
||||
datasets: [{
|
||||
label: window.i18n.eventsOverTime,
|
||||
data: timeValues,
|
||||
backgroundColor: bgColor,
|
||||
borderColor: borderColor,
|
||||
borderWidth: 3,
|
||||
tension: 0.2,
|
||||
fill: true,
|
||||
pointBackgroundColor: borderColor,
|
||||
pointBorderColor: colors.isDarkMode ? '#fff' : '#000',
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 7
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: colors.text,
|
||||
font: {
|
||||
weight: colors.isDarkMode ? 'bold' : 'normal',
|
||||
size: 14
|
||||
},
|
||||
usePointStyle: true,
|
||||
pointStyle: 'line',
|
||||
boxWidth: 50,
|
||||
boxHeight: 3
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
titleFont: {
|
||||
weight: 'bold',
|
||||
size: 14
|
||||
},
|
||||
bodyFont: {
|
||||
size: 13
|
||||
},
|
||||
backgroundColor: colors.isDarkMode ? 'rgba(40, 44, 52, 0.9)' : 'rgba(255, 255, 255, 0.9)',
|
||||
titleColor: colors.isDarkMode ? '#ffffff' : '#000000',
|
||||
bodyColor: colors.isDarkMode ? '#ffffff' : '#000000',
|
||||
borderColor: colors.isDarkMode ? 'rgba(255, 255, 255, 0.5)' : colors.grid,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
cornerRadius: 6,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return `Events: ${context.raw}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: colors.text,
|
||||
font: {
|
||||
weight: colors.isDarkMode ? 'bold' : 'normal',
|
||||
size: 12
|
||||
},
|
||||
precision: 0 // Only show whole numbers
|
||||
},
|
||||
grid: {
|
||||
color: colors.isDarkMode ? 'rgba(255, 255, 255, 0.1)' : colors.grid
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Number of Events',
|
||||
color: colors.text,
|
||||
font: {
|
||||
weight: colors.isDarkMode ? 'bold' : 'normal',
|
||||
size: 14
|
||||
}
|
||||
}
|
||||
},
|
||||
x: {
|
||||
ticks: {
|
||||
color: colors.text,
|
||||
font: {
|
||||
weight: colors.isDarkMode ? 'bold' : 'normal',
|
||||
size: 12
|
||||
},
|
||||
maxRotation: 45,
|
||||
minRotation: 45
|
||||
},
|
||||
grid: {
|
||||
color: colors.isDarkMode ? 'rgba(255, 255, 255, 0.1)' : colors.grid
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Date',
|
||||
color: colors.text,
|
||||
font: {
|
||||
weight: colors.isDarkMode ? 'bold' : 'normal',
|
||||
size: 14
|
||||
},
|
||||
padding: {top: 20}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function formatDate(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.toString()
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function showLoading(id) {
|
||||
const loading = document.getElementById(id);
|
||||
if (loading) loading.style.display = 'flex';
|
||||
}
|
||||
|
||||
function hideLoading(id) {
|
||||
const loading = document.getElementById(id);
|
||||
if (loading) loading.style.display = 'none';
|
||||
}
|
||||
|
||||
// Load event types from the server for filter dropdowns
|
||||
function loadEventTypes() {
|
||||
fetch('/audit/types')
|
||||
.then(response => response.json())
|
||||
.then(types => {
|
||||
if (!types || types.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate the type filter dropdowns
|
||||
const typeFilter = document.getElementById('typeFilter');
|
||||
const exportTypeFilter = document.getElementById('exportTypeFilter');
|
||||
|
||||
// Clear existing options except the first one (All event types)
|
||||
while (typeFilter.options.length > 1) {
|
||||
typeFilter.remove(1);
|
||||
}
|
||||
|
||||
while (exportTypeFilter.options.length > 1) {
|
||||
exportTypeFilter.remove(1);
|
||||
}
|
||||
|
||||
// Add new options
|
||||
types.forEach(type => {
|
||||
// Main filter dropdown
|
||||
const option = document.createElement('option');
|
||||
option.value = type;
|
||||
option.textContent = type;
|
||||
typeFilter.appendChild(option);
|
||||
|
||||
// Export filter dropdown
|
||||
const exportOption = document.createElement('option');
|
||||
exportOption.value = type;
|
||||
exportOption.textContent = type;
|
||||
exportTypeFilter.appendChild(exportOption);
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading event types:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Get theme colors for charts
|
||||
function getThemeColors() {
|
||||
const isDarkMode = document.documentElement.getAttribute('data-bs-theme') === 'dark';
|
||||
|
||||
// In dark mode, use higher contrast colors for text
|
||||
const textColor = isDarkMode ?
|
||||
'rgb(255, 255, 255)' : // White for dark mode for maximum contrast
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-on-surface').trim();
|
||||
|
||||
// Use a more visible grid color in dark mode
|
||||
const gridColor = isDarkMode ?
|
||||
'rgba(255, 255, 255, 0.2)' : // Semi-transparent white for dark mode
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-outline-variant').trim();
|
||||
|
||||
// Define bright, high-contrast colors for both dark and light modes
|
||||
const chartColorsDark = [
|
||||
'rgb(162, 201, 255)', // Light blue - primary
|
||||
'rgb(193, 194, 248)', // Light purple - tertiary
|
||||
'rgb(255, 180, 171)', // Light red - error
|
||||
'rgb(72, 189, 84)', // Green - other
|
||||
'rgb(25, 177, 212)', // Cyan - convert
|
||||
'rgb(25, 101, 212)', // Blue - sign
|
||||
'rgb(255, 120, 146)', // Pink - security
|
||||
'rgb(104, 220, 149)', // Light green - convertto
|
||||
'rgb(212, 172, 25)', // Yellow - image
|
||||
'rgb(245, 84, 84)', // Red - advance
|
||||
];
|
||||
|
||||
const chartColorsLight = [
|
||||
'rgb(0, 96, 170)', // Blue - primary
|
||||
'rgb(88, 90, 138)', // Purple - tertiary
|
||||
'rgb(186, 26, 26)', // Red - error
|
||||
'rgb(72, 189, 84)', // Green - other
|
||||
'rgb(25, 177, 212)', // Cyan - convert
|
||||
'rgb(25, 101, 212)', // Blue - sign
|
||||
'rgb(255, 120, 146)', // Pink - security
|
||||
'rgb(104, 220, 149)', // Light green - convertto
|
||||
'rgb(212, 172, 25)', // Yellow - image
|
||||
'rgb(245, 84, 84)', // Red - advance
|
||||
];
|
||||
|
||||
return {
|
||||
text: textColor,
|
||||
grid: gridColor,
|
||||
backgroundColor: getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-surface-container').trim(),
|
||||
chartColors: isDarkMode ? chartColorsDark : chartColorsLight,
|
||||
isDarkMode: isDarkMode
|
||||
};
|
||||
}
|
||||
|
||||
// Function to generate a palette of colors for charts
|
||||
function getChartColors(count, opacity = 0.6) {
|
||||
try {
|
||||
// Use theme colors first
|
||||
const themeColors = getThemeColors();
|
||||
if (themeColors && themeColors.chartColors && themeColors.chartColors.length > 0) {
|
||||
const result = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Get the raw color and add opacity
|
||||
let color = themeColors.chartColors[i % themeColors.chartColors.length];
|
||||
// If it's rgb() format, convert to rgba()
|
||||
if (color.startsWith('rgb(')) {
|
||||
color = color.replace('rgb(', '').replace(')', '');
|
||||
result.push(`rgba(${color}, ${opacity})`);
|
||||
} else {
|
||||
// Just use the color directly
|
||||
result.push(color);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error using theme colors, falling back to default colors', e);
|
||||
}
|
||||
|
||||
// Base colors - a larger palette than the default
|
||||
const colors = [
|
||||
[54, 162, 235], // blue
|
||||
[255, 99, 132], // red
|
||||
[75, 192, 192], // teal
|
||||
[255, 206, 86], // yellow
|
||||
[153, 102, 255], // purple
|
||||
[255, 159, 64], // orange
|
||||
[46, 204, 113], // green
|
||||
[231, 76, 60], // dark red
|
||||
[52, 152, 219], // light blue
|
||||
[155, 89, 182], // violet
|
||||
[241, 196, 15], // dark yellow
|
||||
[26, 188, 156], // turquoise
|
||||
[230, 126, 34], // dark orange
|
||||
[149, 165, 166], // light gray
|
||||
[243, 156, 18], // amber
|
||||
[39, 174, 96], // emerald
|
||||
[211, 84, 0], // dark orange red
|
||||
[22, 160, 133], // green sea
|
||||
[41, 128, 185], // belize hole
|
||||
[142, 68, 173] // wisteria
|
||||
];
|
||||
|
||||
const result = [];
|
||||
|
||||
// Always use the same format regardless of color source
|
||||
if (count > colors.length) {
|
||||
// Generate colors algorithmically for large sets
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Generate a color based on position in the hue circle (0-360)
|
||||
const hue = (i * 360 / count) % 360;
|
||||
const sat = 70 + Math.random() * 10; // 70-80%
|
||||
const light = 50 + Math.random() * 10; // 50-60%
|
||||
|
||||
result.push(`hsla(${hue}, ${sat}%, ${light}%, ${opacity})`);
|
||||
}
|
||||
} else {
|
||||
// Use colors from our palette but also return in hsla format for consistency
|
||||
for (let i = 0; i < count; i++) {
|
||||
const color = colors[i % colors.length];
|
||||
result.push(`rgba(${color[0]}, ${color[1]}, ${color[2]}, ${opacity})`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
42
proprietary/src/main/resources/templates/AUDIT_HELP.md
Normal file
42
proprietary/src/main/resources/templates/AUDIT_HELP.md
Normal file
@ -0,0 +1,42 @@
|
||||
# Audit System Help
|
||||
|
||||
## About the Audit System
|
||||
The Stirling PDF audit system records user actions and system events for security monitoring, compliance, and troubleshooting purposes.
|
||||
|
||||
## Audit Levels
|
||||
|
||||
| Level | Name | Description | Use Case |
|
||||
|-------|------|-------------|----------|
|
||||
| 0 | OFF | Minimal auditing, only critical security events | Development environments |
|
||||
| 1 | BASIC | Authentication events, security events, and errors | Production environments with minimal storage |
|
||||
| 2 | STANDARD | All HTTP requests and operations (default) | Normal production use |
|
||||
| 3 | VERBOSE | Detailed information including headers, parameters, and results | Troubleshooting and detailed analysis |
|
||||
|
||||
## Configuration
|
||||
Audit settings are configured in the `settings.yml` file under the `premium.proFeatures.audit` section:
|
||||
|
||||
```yaml
|
||||
premium:
|
||||
proFeatures:
|
||||
audit:
|
||||
enabled: true # Enable/disable audit logging
|
||||
level: 2 # Audit level (0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE)
|
||||
retentionDays: 90 # Number of days to retain audit logs
|
||||
```
|
||||
|
||||
## Common Event Types
|
||||
|
||||
### BASIC Events:
|
||||
- USER_LOGIN - User login
|
||||
- USER_LOGOUT - User logout
|
||||
- USER_FAILED_LOGIN - Failed login attempt
|
||||
- USER_PROFILE_UPDATE - User or profile operations
|
||||
|
||||
### STANDARD Events:
|
||||
- HTTP_REQUEST - GET requests for viewing
|
||||
- PDF_PROCESS - PDF processing operations
|
||||
- FILE_OPERATION - File-related operations
|
||||
- SETTINGS_CHANGED - System or admin settings operations
|
||||
|
||||
### VERBOSE Events:
|
||||
- Detailed versions of STANDARD events with parameters and results
|
250
proprietary/src/main/resources/templates/AUDIT_USAGE.md
Normal file
250
proprietary/src/main/resources/templates/AUDIT_USAGE.md
Normal file
@ -0,0 +1,250 @@
|
||||
# Stirling PDF Audit System
|
||||
|
||||
This document provides guidance on how to use the audit system in Stirling PDF.
|
||||
|
||||
## Overview
|
||||
|
||||
The audit system provides comprehensive logging of user actions and system events, storing them in a database for later review. This is useful for:
|
||||
|
||||
- Security monitoring
|
||||
- Compliance requirements
|
||||
- User activity tracking
|
||||
- Troubleshooting
|
||||
|
||||
## Audit Levels
|
||||
|
||||
The audit system supports different levels of detail that can be configured in the settings.yml file:
|
||||
|
||||
### Level 0: OFF
|
||||
- Disables all audit logging except for critical security events
|
||||
- Minimal database usage and performance impact
|
||||
- Only recommended for development environments
|
||||
|
||||
### Level 1: BASIC
|
||||
- Authentication events (login, logout, failed logins)
|
||||
- Password changes
|
||||
- User/role changes
|
||||
- System configuration changes
|
||||
- HTTP request errors (status codes >= 400)
|
||||
|
||||
### Level 2: STANDARD (Default)
|
||||
- Everything in BASIC plus:
|
||||
- All HTTP requests (basic info: URL, method, status)
|
||||
- File operations (upload, download, process)
|
||||
- PDF operations (view, edit, etc.)
|
||||
- User operations
|
||||
|
||||
### Level 3: VERBOSE
|
||||
- Everything in STANDARD plus:
|
||||
- Request headers and parameters
|
||||
- Method parameters
|
||||
- Operation results
|
||||
- Detailed timing information
|
||||
|
||||
## Configuration
|
||||
|
||||
Audit levels are configured in the settings.yml file under the premium section:
|
||||
|
||||
```yaml
|
||||
premium:
|
||||
proFeatures:
|
||||
audit:
|
||||
enabled: true # Enable/disable audit logging
|
||||
level: 2 # Audit level (0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE)
|
||||
retentionDays: 90 # Number of days to retain audit logs
|
||||
```
|
||||
|
||||
## Automatic Auditing
|
||||
|
||||
The following events are automatically audited (based on configured level):
|
||||
|
||||
### HTTP Request Auditing
|
||||
All HTTP requests are automatically audited with details based on the configured level:
|
||||
|
||||
- **BASIC level**: Only errors (status code >= 400)
|
||||
- **STANDARD level**: All requests with basic information (URL, method, status code, latency, IP)
|
||||
- **VERBOSE level**: All of the above plus headers, parameters, and detailed timing
|
||||
|
||||
### Controller Method Auditing
|
||||
All controller methods with web mapping annotations are automatically audited:
|
||||
|
||||
- `@GetMapping`
|
||||
- `@PostMapping`
|
||||
- `@PutMapping`
|
||||
- `@DeleteMapping`
|
||||
- `@PatchMapping`
|
||||
|
||||
Methods with these annotations are audited at the **STANDARD** level by default.
|
||||
|
||||
### Security Events
|
||||
The following security events are always audited at the **BASIC** level:
|
||||
|
||||
- Authentication events (login, logout, failed login attempts)
|
||||
- Password changes
|
||||
- User/role changes
|
||||
|
||||
## Manual Auditing
|
||||
|
||||
There are two ways to add audit events from your code:
|
||||
|
||||
### 1. Using AuditService Directly
|
||||
|
||||
Inject the `AuditService` and use it directly:
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MyService {
|
||||
|
||||
private final AuditService auditService;
|
||||
|
||||
public void processPdf(MultipartFile file) {
|
||||
// Process the file...
|
||||
|
||||
// Add an audit event with default level (STANDARD)
|
||||
auditService.audit("PDF_PROCESSED", Map.of(
|
||||
"filename", file.getOriginalFilename(),
|
||||
"size", file.getSize(),
|
||||
"operation", "process"
|
||||
));
|
||||
|
||||
// Or specify an audit level
|
||||
auditService.audit("PDF_PROCESSED_DETAILED", Map.of(
|
||||
"filename", file.getOriginalFilename(),
|
||||
"size", file.getSize(),
|
||||
"operation", "process",
|
||||
"metadata", file.getContentType(),
|
||||
"user", "johndoe"
|
||||
), AuditLevel.VERBOSE);
|
||||
|
||||
// Critical security events should use BASIC level to ensure they're always logged
|
||||
auditService.audit("SECURITY_EVENT", Map.of(
|
||||
"action", "file_access",
|
||||
"resource", file.getOriginalFilename()
|
||||
), AuditLevel.BASIC);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Using the @Audited Annotation
|
||||
|
||||
For simpler auditing, use the `@Audited` annotation on your methods:
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class UserService {
|
||||
|
||||
// Basic audit level for important security events
|
||||
@Audited(type = "USER_REGISTRATION", level = AuditLevel.BASIC)
|
||||
public User registerUser(String username, String email) {
|
||||
// Method implementation
|
||||
User user = new User(username, email);
|
||||
// Save user...
|
||||
return user;
|
||||
}
|
||||
|
||||
// Sensitive operations should use BASIC but disable argument logging
|
||||
@Audited(type = "USER_PASSWORD_CHANGE", level = AuditLevel.BASIC, includeArgs = false)
|
||||
public void changePassword(String username, String newPassword) {
|
||||
// Change password implementation
|
||||
// includeArgs=false prevents the password from being included in the audit
|
||||
}
|
||||
|
||||
// Standard level for normal operations (default)
|
||||
@Audited(type = "USER_LOGIN")
|
||||
public boolean login(String username, String password) {
|
||||
// Login implementation
|
||||
return true;
|
||||
}
|
||||
|
||||
// Verbose level for detailed information
|
||||
@Audited(type = "USER_SEARCH", level = AuditLevel.VERBOSE, includeResult = true)
|
||||
public List<User> searchUsers(String query) {
|
||||
// Search implementation
|
||||
// At VERBOSE level, this will include both the query and results
|
||||
return userList;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With the `@Audited` annotation:
|
||||
- You can specify the audit level using the `level` parameter
|
||||
- Method arguments are automatically included in the audit event (unless `includeArgs = false`)
|
||||
- Return values can be included with `includeResult = true`
|
||||
- Exceptions are automatically captured and included in the audit
|
||||
- The aspect handles all the boilerplate code for you
|
||||
- The annotation respects the configured global audit level
|
||||
|
||||
### 3. Controller Automatic Auditing
|
||||
|
||||
In addition to the manual methods above, all controller methods with web mapping annotations are automatically audited, even without the `@Audited` annotation:
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
public class UserController {
|
||||
|
||||
// This method will be automatically audited
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<User> getUser(@PathVariable String id) {
|
||||
// Method implementation
|
||||
return ResponseEntity.ok(user);
|
||||
}
|
||||
|
||||
// This method will be automatically audited
|
||||
@PostMapping
|
||||
public ResponseEntity<User> createUser(@RequestBody User user) {
|
||||
// Method implementation
|
||||
return ResponseEntity.ok(savedUser);
|
||||
}
|
||||
|
||||
// This method uses @Audited and takes precedence over automatic auditing
|
||||
@Audited(type = "USER_DELETE", level = AuditLevel.BASIC)
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteUser(@PathVariable String id) {
|
||||
// Method implementation
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Important notes about automatic controller auditing:
|
||||
- All controller methods with web mapping annotations are audited at the STANDARD level
|
||||
- If a method already has an @Audited annotation, that takes precedence
|
||||
- The audit event includes controller name, method name, path, and HTTP method
|
||||
- At VERBOSE level, request parameters are also included
|
||||
- Exceptions are automatically captured
|
||||
|
||||
## Common Audit Event Types
|
||||
|
||||
Use consistent event types throughout the application:
|
||||
|
||||
- `FILE_UPLOAD` - When a file is uploaded
|
||||
- `FILE_DOWNLOAD` - When a file is downloaded
|
||||
- `PDF_PROCESS` - When a PDF is processed (split, merged, etc.)
|
||||
- `USER_CREATE` - When a user is created
|
||||
- `USER_UPDATE` - When a user details are updated
|
||||
- `PASSWORD_CHANGE` - When a password is changed
|
||||
- `PERMISSION_CHANGE` - When permissions are modified
|
||||
- `SETTINGS_CHANGE` - When system settings are changed
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Sensitive data is automatically masked in audit logs (passwords, API keys, tokens)
|
||||
- Each audit event includes a unique request ID for correlation
|
||||
- Audit events are stored asynchronously to avoid performance impact
|
||||
- The `/auditevents` endpoint is disabled to prevent unauthorized access to audit data
|
||||
|
||||
## Database Storage
|
||||
|
||||
Audit events are stored in the `audit_events` table with the following schema:
|
||||
|
||||
- `id` - Unique identifier
|
||||
- `principal` - The username or system identifier
|
||||
- `type` - The event type
|
||||
- `data` - JSON blob containing event details
|
||||
- `timestamp` - When the event occurred
|
||||
|
||||
## Metrics
|
||||
|
||||
Prometheus metrics are available at `/actuator/prometheus` for monitoring system performance and audit event volume.
|
@ -1,196 +1,205 @@
|
||||
<!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=#{team.details.title}, header=#{team.details.header})}"></th:block>
|
||||
<link rel="stylesheet" th:href="@{/css/modern-tables.css}">
|
||||
</head>
|
||||
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}"
|
||||
xmlns:th="https://www.thymeleaf.org">
|
||||
|
||||
<body>
|
||||
<th:block th:insert="~{fragments/common :: game}"></th:block>
|
||||
<div id="page-container">
|
||||
<div id="content-wrap">
|
||||
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
||||
|
||||
<div class="data-container">
|
||||
<div class="data-panel">
|
||||
<div class="data-header">
|
||||
<h1 class="data-title">
|
||||
<span class="data-icon">
|
||||
<span class="material-symbols-rounded">group</span>
|
||||
</span>
|
||||
<span th:text="'Team: ' + ${team.name}">Team Name</span>
|
||||
</h1>
|
||||
<head>
|
||||
<th:block th:insert="~{fragments/common :: head(title=#{team.details.title}, header=#{team.details.header})}"></th:block>
|
||||
<link rel="stylesheet" th:href="@{/css/modern-tables.css}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<th:block th:insert="~{fragments/common :: game}"></th:block>
|
||||
<div id="page-container">
|
||||
<div id="content-wrap">
|
||||
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
||||
|
||||
<div class="data-container">
|
||||
<div class="data-panel">
|
||||
<div class="data-header">
|
||||
<h1 class="data-title">
|
||||
<span class="data-icon">
|
||||
<span class="material-symbols-rounded">group</span>
|
||||
</span>
|
||||
<span th:text="'Team: ' + ${team.name}">Team Name</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="data-body">
|
||||
<div class="data-stats">
|
||||
<div class="data-stat-card">
|
||||
<div class="data-stat-label" th:text="#{team.totalMembers}">Total Members:</div>
|
||||
<div class="data-stat-value" th:text="${teamUsers.size()}">1</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="data-body">
|
||||
<div class="data-stats">
|
||||
<div class="data-stat-card">
|
||||
<div class="data-stat-label">Total Members:</div>
|
||||
<div class="data-stat-value" th:text="${teamUsers.size()}">1</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="data-actions data-actions-start">
|
||||
<a th:href="@{'/teams'}" class="data-btn data-btn-secondary">
|
||||
<span class="material-symbols-rounded">arrow_back</span>
|
||||
<span th:text="#{team.back}">Back to Teams</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="data-section-title">Members</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th scope="col" th:title="${@runningProOrHigher} ? #{adminUserSettings.lastRequest} : 'Pro feature'" class="text-overflow" th:text="#{adminUserSettings.lastRequest}">Last Request</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="user : ${teamUsers}">
|
||||
<td th:text="${user.id}">1</td>
|
||||
<td th:text="${user.username}">username</td>
|
||||
<td th:text="#{${user.roleName}}">Role</td>
|
||||
<td th:text="${@runningProOrHigher} ? (${userLastRequest[user.username] != null ? #dates.format(userLastRequest[user.username], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}) : 'hidden'">2023-01-01 12:00:00</td>
|
||||
<td>
|
||||
<span th:if="${user.enabled}" class="data-status data-status-success">
|
||||
<span class="material-symbols-rounded">person</span>
|
||||
Enabled
|
||||
</span>
|
||||
<span th:unless="${user.enabled}" class="data-status data-status-danger">
|
||||
<span class="material-symbols-rounded">person_off</span>
|
||||
Disabled
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Empty state for when there are no team members -->
|
||||
<div th:if="${teamUsers.empty}" class="data-empty">
|
||||
<span class="material-symbols-rounded data-empty-icon">person_off</span>
|
||||
<p class="data-empty-text">This team has no members yet.</p>
|
||||
<button data-bs-toggle="modal" data-bs-target="#addUserToTeamModal" class="data-btn data-btn-primary">
|
||||
<span class="material-symbols-rounded">person_add</span>
|
||||
<span th:text="#{team.addUser}">Add User to Team</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add button for non-empty teams too -->
|
||||
<div th:if="${!teamUsers.empty}" class="data-actions data-mt-3">
|
||||
<button data-bs-toggle="modal" data-bs-target="#addUserToTeamModal" class="data-btn data-btn-primary">
|
||||
<span class="material-symbols-rounded">person_add</span>
|
||||
<span th:text="#{team.addUser}">Add User to Team</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Alert Messages -->
|
||||
<div th:if="${errorMessage}" class="alert alert-danger data-mb-3">
|
||||
<span th:text="#{${errorMessage}}">Default message if not found</span>
|
||||
</div>
|
||||
|
||||
<div class="data-actions data-actions-start">
|
||||
<a th:href="@{'/teams'}" class="data-btn data-btn-secondary">
|
||||
<span class="material-symbols-rounded">arrow_back</span>
|
||||
<span th:text="#{team.back}">Back to Teams</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="data-section-title" th:text="#{team.members}">Members</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th th:text="#{team.username}">Username</th>
|
||||
<th th:text="#{team.role}">Role</th>
|
||||
<th scope="col" th:title="${@runningProOrHigher} ? #{adminUserSettings.lastRequest} : #{proFeatures}"
|
||||
class="text-overflow" th:text="#{adminUserSettings.lastRequest}">Last Request</th>
|
||||
<th th:text="#{team.status}">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="user : ${teamUsers}">
|
||||
<td th:text="${user.id}">1</td>
|
||||
<td th:text="${user.username}">username</td>
|
||||
<td th:text="#{${user.roleName}}">Role</td>
|
||||
<td
|
||||
th:text="${@runningProOrHigher} ? (${userLastRequest[user.username] != null ? #dates.format(userLastRequest[user.username], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}) : #{team.hidden}">
|
||||
2023-01-01 12:00:00</td>
|
||||
<td>
|
||||
<span th:if="${user.enabled}" class="data-status data-status-success">
|
||||
<span class="material-symbols-rounded">person</span>
|
||||
<span th:text="#{team.enabled}">Enabled</span>
|
||||
</span>
|
||||
<span th:unless="${user.enabled}" class="data-status data-status-danger">
|
||||
<span class="material-symbols-rounded">person_off</span>
|
||||
<span th:text="#{team.disabled}">Disabled</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Empty state for when there are no team members -->
|
||||
<div th:if="${teamUsers.empty}" class="data-empty">
|
||||
<span class="material-symbols-rounded data-empty-icon">person_off</span>
|
||||
<p class="data-empty-text" th:text="#{team.noMembers}">This team has no members yet.</p>
|
||||
<button data-bs-toggle="modal" data-bs-target="#addUserToTeamModal" class="data-btn data-btn-primary">
|
||||
<span class="material-symbols-rounded">person_add</span>
|
||||
<span th:text="#{team.addUser}">Add User to Team</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add button for non-empty teams too -->
|
||||
<div th:if="${!teamUsers.empty}" class="data-actions data-mt-3">
|
||||
<button data-bs-toggle="modal" data-bs-target="#addUserToTeamModal" class="data-btn data-btn-primary">
|
||||
<span class="material-symbols-rounded">person_add</span>
|
||||
<span th:text="#{team.addUser}">Add User to Team</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript for team warning -->
|
||||
<script th:inline="javascript">
|
||||
function checkUserTeam(userId) {
|
||||
// Clear any existing warning
|
||||
const warningDiv = document.getElementById('teamChangeWarning');
|
||||
const warningMessage = document.getElementById('warningMessage');
|
||||
const submitButton = document.getElementById('addUserSubmitBtn');
|
||||
|
||||
// Reset
|
||||
warningDiv.style.display = 'none';
|
||||
submitButton.onclick = null;
|
||||
|
||||
// Get the selected option
|
||||
const selectedOption = document.querySelector('#userId option[value="' + userId + '"]');
|
||||
if (!selectedOption) return;
|
||||
|
||||
// Get team data
|
||||
const currentTeam = selectedOption.getAttribute('data-team');
|
||||
const currentTeamId = selectedOption.getAttribute('data-team-id');
|
||||
const newTeamName = /*[[${team.name}]]*/ 'Current Team';
|
||||
|
||||
// If user is already in a team, show warning
|
||||
if (currentTeam && currentTeam.length > 0) {
|
||||
</div>
|
||||
|
||||
<!-- JavaScript for team warning -->
|
||||
<script th:inline="javascript">
|
||||
function checkUserTeam(userId) {
|
||||
// Clear any existing warning
|
||||
const warningDiv = document.getElementById('teamChangeWarning');
|
||||
const warningMessage = document.getElementById('warningMessage');
|
||||
const submitButton = document.getElementById('addUserSubmitBtn');
|
||||
|
||||
// Reset
|
||||
warningDiv.style.display = 'none';
|
||||
submitButton.onclick = null;
|
||||
|
||||
// Get the selected option
|
||||
const selectedOption = document.querySelector('#userId option[value="' + userId + '"]');
|
||||
if (!selectedOption) return;
|
||||
|
||||
// Get team data
|
||||
const currentTeam = selectedOption.getAttribute('data-team');
|
||||
const currentTeamId = selectedOption.getAttribute('data-team-id');
|
||||
const newTeamName = /*[[${team.name}]]*/ 'Current Team';
|
||||
|
||||
// If user is already in a team, show warning
|
||||
if (currentTeam && currentTeam.length > 0) {
|
||||
// Use internationalized message
|
||||
const warningTemplate = /*[[#{team.warning.moveUser}]]*/ 'Warning: This will move the user from "{0}" team to "{1}" team. Are you sure?';
|
||||
const formattedWarning = warningTemplate.replace('{0}', currentTeam).replace('{1}', newTeamName);
|
||||
warningMessage.textContent = formattedWarning;
|
||||
warningDiv.style.display = 'block';
|
||||
|
||||
// Add confirmation to submit button
|
||||
submitButton.onclick = function (e) {
|
||||
// Use internationalized message
|
||||
const warningTemplate = /*[[#{team.warning.moveUser}]]*/ 'Warning: This will move the user from "{0}" team to "{1}" team. Are you sure?';
|
||||
const formattedWarning = warningTemplate.replace('{0}', currentTeam).replace('{1}', newTeamName);
|
||||
warningMessage.textContent = formattedWarning;
|
||||
warningDiv.style.display = 'block';
|
||||
|
||||
// Add confirmation to submit button
|
||||
submitButton.onclick = function(e) {
|
||||
// Use internationalized message
|
||||
const confirmTemplate = /*[[#{team.confirm.moveUser}]]*/ 'Are you sure you want to move this user from "{0}" team to "{1}" team?';
|
||||
const formattedConfirm = confirmTemplate.replace('{0}', currentTeam).replace('{1}', newTeamName);
|
||||
if (!confirm(formattedConfirm)) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
const confirmTemplate = /*[[#{team.confirm.moveUser}]]*/ 'Are you sure you want to move this user from "{0}" team to "{1}" team?';
|
||||
const formattedConfirm = confirmTemplate.replace('{0}', currentTeam).replace('{1}', newTeamName);
|
||||
if (!confirm(formattedConfirm)) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add User to Team Modal -->
|
||||
<div class="modal fade" id="addUserToTeamModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<form th:action="@{'/api/v1/team/addUser'}" method="post" class="modal-content data-modal">
|
||||
<div class="data-modal-header">
|
||||
<h5 class="data-modal-title">
|
||||
<span class="data-icon">
|
||||
<span class="material-symbols-rounded">person_add</span>
|
||||
</span>
|
||||
<span th:text="#{team.addUser}">Add User to Team</span>
|
||||
</h5>
|
||||
<button type="button" class="data-btn-close" data-bs-dismiss="modal" aria-label="Close">
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add User to Team Modal -->
|
||||
<div class="modal fade" id="addUserToTeamModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<form th:action="@{'/api/v1/team/addUser'}" method="post" class="modal-content data-modal">
|
||||
<div class="data-modal-header">
|
||||
<h5 class="data-modal-title">
|
||||
<span class="data-icon">
|
||||
<span class="material-symbols-rounded">person_add</span>
|
||||
</span>
|
||||
<span th:text="#{team.addUser}">Add User to Team</span>
|
||||
</h5>
|
||||
<button type="button" class="data-btn-close" data-bs-dismiss="modal" aria-label="Close">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="data-modal-body">
|
||||
<input type="hidden" name="teamId" th:value="${team.id}" />
|
||||
|
||||
<div class="data-form-group">
|
||||
<label for="userId" class="data-form-label" th:text="#{team.selectUser}">Select User</label>
|
||||
<select name="userId" id="userId" class="data-form-control" required onchange="checkUserTeam(this.value)">
|
||||
<option value="" disabled selected th:text="#{selectFillter}">-- Select User --</option>
|
||||
<option th:each="user : ${availableUsers}" th:value="${user.id}" th:text="${user.username}"
|
||||
th:data-team="${user.team != null ? user.team.name : ''}"
|
||||
th:data-team-id="${user.team != null ? user.team.id : ''}">
|
||||
Username
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Warning message for users being moved between teams -->
|
||||
<div id="teamChangeWarning" class="alert alert-warning mt-3" style="display: none;">
|
||||
<span class="material-symbols-rounded">warning</span>
|
||||
<span id="warningMessage">Warning: This will move the user from their current team to this team.</span>
|
||||
</div>
|
||||
|
||||
<div class="data-form-actions">
|
||||
<button type="button" class="data-btn data-btn-secondary" data-bs-dismiss="modal">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
<span th:text="#{cancel}">Cancel</span>
|
||||
</button>
|
||||
<button type="submit" id="addUserSubmitBtn" class="data-btn data-btn-primary">
|
||||
<span class="material-symbols-rounded">check</span>
|
||||
<span th:text="#{team.addUser}">Add User</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="data-modal-body">
|
||||
<input type="hidden" name="teamId" th:value="${team.id}" />
|
||||
|
||||
<div class="data-form-group">
|
||||
<label for="userId" class="data-form-label" th:text="#{team.selectUser}">Select User</label>
|
||||
<select name="userId" id="userId" class="data-form-control" required onchange="checkUserTeam(this.value)">
|
||||
<option value="" disabled selected th:text="#{selectFillter}">-- Select User --</option>
|
||||
<option th:each="user : ${availableUsers}"
|
||||
th:value="${user.id}"
|
||||
th:text="${user.username}"
|
||||
th:data-team="${user.team != null ? user.team.name : ''}"
|
||||
th:data-team-id="${user.team != null ? user.team.id : ''}">
|
||||
Username
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Warning message for users being moved between teams -->
|
||||
<div id="teamChangeWarning" class="alert alert-warning mt-3" style="display: none;">
|
||||
<span class="material-symbols-rounded">warning</span>
|
||||
<span id="warningMessage">Warning: This will move the user from their current team to this team.</span>
|
||||
</div>
|
||||
|
||||
<div class="data-form-actions">
|
||||
<button type="button" class="data-btn data-btn-secondary" data-bs-dismiss="modal">
|
||||
<span class="material-symbols-rounded">close</span>
|
||||
<span th:text="#{cancel}">Cancel</span>
|
||||
</button>
|
||||
<button type="submit" id="addUserSubmitBtn" class="data-btn data-btn-primary">
|
||||
<span class="material-symbols-rounded">check</span>
|
||||
<span th:text="#{team.addUser}">Add User</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
@ -1,15 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
|
||||
<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=#{adminUserSettings.manageTeams}, header=#{adminUserSettings.manageTeams})}"></th:block>
|
||||
<th:block
|
||||
th:insert="~{fragments/common :: head(title=#{adminUserSettings.manageTeams}, header=#{adminUserSettings.manageTeams})}">
|
||||
</th:block>
|
||||
<link rel="stylesheet" th:href="@{/css/modern-tables.css}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<th:block th:insert="~{fragments/common :: game}"></th:block>
|
||||
<div id="page-container">
|
||||
<div id="content-wrap">
|
||||
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
||||
|
||||
|
||||
<div class="data-container">
|
||||
<div class="data-panel">
|
||||
<div class="data-header">
|
||||
@ -20,28 +25,44 @@
|
||||
<span th:text="#{adminUserSettings.manageTeams}">Team Management</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="data-body">
|
||||
<!-- Back Button -->
|
||||
<div class="data-actions data-actions-start">
|
||||
<a href="/adminSettings" class="data-btn data-btn-secondary">
|
||||
<a th:href="@{'/adminSettings'}" class="data-btn data-btn-secondary">
|
||||
<span class="material-symbols-rounded">arrow_back</span>
|
||||
<span th:text="#{back.toSettings}">Back to Settings</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Alert Messages -->
|
||||
<div th:if="${addMessage}" class="alert alert-success data-mb-3">
|
||||
<span th:text="#{${addMessage}}">Default message if not found</span>
|
||||
</div>
|
||||
|
||||
<div th:if="${changeMessage}" class="alert alert-success data-mb-3">
|
||||
<span th:text="#{${changeMessage}}">Default message if not found</span>
|
||||
</div>
|
||||
|
||||
<div th:if="${deleteMessage}" class="alert alert-danger data-mb-3">
|
||||
<span th:text="#{${deleteMessage}}">Default message if not found</span>
|
||||
</div>
|
||||
|
||||
<div th:if="${errorMessage}" class="alert alert-danger data-mb-3">
|
||||
<span th:text="#{${errorMessage}}">Default message if not found</span>
|
||||
</div>
|
||||
|
||||
<!-- Create New Team Button -->
|
||||
<div class="data-actions">
|
||||
<a href="#"
|
||||
th:data-bs-toggle="${@runningProOrHigher} ? 'modal' : null"
|
||||
th:data-bs-target="${@runningProOrHigher} ? '#addTeamModal' : null"
|
||||
th:class="${@runningProOrHigher} ? 'data-btn data-btn-primary' : 'data-btn data-btn-danger'"
|
||||
th:title="${@runningProOrHigher} ? #{adminUserSettings.createTeam} : #{enterpriseEdition.proTeamFeatureDisabled}">
|
||||
<a href="#" th:data-bs-toggle="${@runningProOrHigher} ? 'modal' : null"
|
||||
th:data-bs-target="${@runningProOrHigher} ? '#addTeamModal' : null"
|
||||
th:class="${@runningProOrHigher} ? 'data-btn data-btn-primary' : 'data-btn data-btn-danger'"
|
||||
th:title="${@runningProOrHigher} ? #{adminUserSettings.createTeam} : #{enterpriseEdition.proTeamFeatureDisabled}">
|
||||
<span class="material-symbols-rounded">group_add</span>
|
||||
<span th:text="#{adminUserSettings.createTeam}">Create New Team</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Team Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
@ -49,7 +70,8 @@
|
||||
<tr>
|
||||
<th scope="col" th:text="#{adminUserSettings.teamName}">Team Name</th>
|
||||
<th scope="col" th:text="#{adminUserSettings.totalMembers}">Total Members</th>
|
||||
<th scope="col" th:title="${@runningProOrHigher} ? #{adminUserSettings.lastRequest} : 'Pro feature'" class="text-overflow" th:text="#{adminUserSettings.lastRequest}">Last Request</th>
|
||||
<th scope="col" th:title="${@runningProOrHigher} ? #{adminUserSettings.lastRequest} : #{proFeatures}"
|
||||
class="text-overflow" th:text="#{adminUserSettings.lastRequest}">Last Request</th>
|
||||
<th scope="col" th:text="#{adminUserSettings.actions}">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -58,18 +80,20 @@
|
||||
<tr th:each="teamDto : ${teamsWithCounts}">
|
||||
<td th:text="${teamDto.name}"></td>
|
||||
<td th:text="${teamDto.userCount}"></td>
|
||||
<td th:text="${@runningProOrHigher} ? (${teamLastRequest[teamDto.id] != null ? #dates.format(teamLastRequest[teamDto.id], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}) : 'hidden'"></td>
|
||||
<td
|
||||
th:text="${@runningProOrHigher} ? (${teamLastRequest[teamDto.id] != null ? #dates.format(teamLastRequest[teamDto.id], 'yyyy-MM-dd HH:mm:ss') : 'N/A'}) : #{adminUserSettings.teamHidden}">
|
||||
</td>
|
||||
<td>
|
||||
<div class="data-action-cell">
|
||||
<a th:href="@{'/teams/' + ${teamDto.id}}" class="data-btn data-btn-secondary data-btn-sm" th:title="#{adminUserSettings.viewTeam}">
|
||||
<a th:href="@{'/teams/' + ${teamDto.id}}" class="data-btn data-btn-secondary data-btn-sm"
|
||||
th:title="#{adminUserSettings.viewTeam}">
|
||||
<span class="material-symbols-rounded">search</span> <span th:text="#{view}">View</span>
|
||||
</a>
|
||||
<form th:action="@{'/api/v1/team/delete'}" method="post" style="display:inline-block"
|
||||
onsubmit="return confirmDeleteTeam()">
|
||||
onsubmit="return confirmDeleteTeam()">
|
||||
<input type="hidden" name="teamId" th:value="${teamDto.id}" />
|
||||
<button type="submit" class="data-btn data-btn-danger data-btn-sm"
|
||||
th:disabled="${!@runningProOrHigher}"
|
||||
th:classappend="${!@runningProOrHigher} ? 'disabled' : ''"
|
||||
<button type="submit" class="data-btn data-btn-danger data-btn-sm"
|
||||
th:disabled="${!@runningProOrHigher}" th:classappend="${!@runningProOrHigher} ? 'disabled' : ''"
|
||||
th:title="${@runningProOrHigher} ? #{adminUserSettings.deleteTeam} : #{enterpriseEdition.proTeamFeatureDisabled}">
|
||||
<span class="material-symbols-rounded">delete</span> <span th:text="#{delete}">Delete</span>
|
||||
</button>
|
||||
@ -80,7 +104,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Delete Confirmation Script -->
|
||||
<script th:inline="javascript">
|
||||
const confirmDeleteText = /*[[#{adminUserSettings.confirmDeleteTeam}]]*/ 'Are you sure you want to delete this team?';
|
||||
@ -91,7 +115,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Add Team Modal -->
|
||||
<div class="modal fade" id="addTeamModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
@ -130,4 +154,5 @@
|
||||
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
383
proprietary/src/main/resources/templates/audit/dashboard.html
Normal file
383
proprietary/src/main/resources/templates/audit/dashboard.html
Normal file
@ -0,0 +1,383 @@
|
||||
<!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='Audit Dashboard', header='Audit Dashboard')}"></th:block>
|
||||
|
||||
<!-- Include Chart.js for visualizations -->
|
||||
<script th:src="@{/js/thirdParty/chart.umd.min.js}"></script>
|
||||
|
||||
<!-- Include custom CSS -->
|
||||
<link rel="stylesheet" th:href="@{/css/audit-dashboard.css}" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="page-container">
|
||||
<div id="content-wrap">
|
||||
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
||||
|
||||
<div class="container-fluid mt-4">
|
||||
<h1 class="mb-4" th:text="#{audit.dashboard.title}">Audit Dashboard</h1>
|
||||
|
||||
<!-- System Status Card -->
|
||||
<div class="card dashboard-card mb-4">
|
||||
<div class="card-header">
|
||||
<h2 class="h5 mb-0" th:text="#{audit.dashboard.systemStatus}">Audit System Status</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" th:text="#{audit.dashboard.status}">Status</div>
|
||||
<div class="stat-number">
|
||||
<span th:if="${auditEnabled}" class="text-success" th:text="#{audit.dashboard.enabled}">Enabled</span>
|
||||
<span th:unless="${auditEnabled}" class="text-danger" th:text="#{audit.dashboard.disabled}">Disabled</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" th:text="#{audit.dashboard.currentLevel}">Current Level</div>
|
||||
<div class="stat-number">
|
||||
<span th:class="'level-indicator level-' + ${auditLevelInt}" th:text="${auditLevel}">STANDARD</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" th:text="#{audit.dashboard.retentionPeriod}">Retention Period</div>
|
||||
<div class="stat-number" th:text="${retentionDays} + ' ' + #{audit.dashboard.days}">90 days</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" th:text="#{audit.dashboard.totalEvents}">Total Events</div>
|
||||
<div class="stat-number" id="total-events">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs for different sections -->
|
||||
<ul class="nav nav-tabs" id="auditTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="dashboard-tab" data-bs-toggle="tab" data-bs-target="#dashboard" type="button" role="tab" aria-controls="dashboard" aria-selected="true" th:text="#{audit.dashboard.tab.dashboard}">Dashboard</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="events-tab" data-bs-toggle="tab" data-bs-target="#events" type="button" role="tab" aria-controls="events" aria-selected="false" th:text="#{audit.dashboard.tab.events}">Audit Events</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="export-tab" data-bs-toggle="tab" data-bs-target="#export" type="button" role="tab" aria-controls="export" aria-selected="false" th:text="#{audit.dashboard.tab.export}">Export</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="auditTabsContent">
|
||||
<!-- Dashboard Tab -->
|
||||
<div class="tab-pane fade show active" id="dashboard" role="tabpanel" aria-labelledby="dashboard-tab">
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card dashboard-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h3 class="h5 mb-0" th:text="#{audit.dashboard.eventsByType}">Events by Type</h3>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="loadStats(7)" th:text="#{audit.dashboard.period.7days}">7 Days</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="loadStats(30)" th:text="#{audit.dashboard.period.30days}">30 Days</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="loadStats(90)" th:text="#{audit.dashboard.period.90days}">90 Days</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container position-relative">
|
||||
<div class="loading-overlay" id="type-chart-loading">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden" th:text="#{loading}">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="typeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card dashboard-card">
|
||||
<div class="card-header">
|
||||
<h3 class="h5 mb-0" th:text="#{audit.dashboard.eventsByUser}">Events by User</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container position-relative">
|
||||
<div class="loading-overlay" id="user-chart-loading">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden" th:text="#{loading}">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="userChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card dashboard-card">
|
||||
<div class="card-header">
|
||||
<h3 class="h5 mb-0" th:text="#{audit.dashboard.eventsOverTime}">Events Over Time</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container position-relative">
|
||||
<div class="loading-overlay" id="time-chart-loading">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden" th:text="#{loading}">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="timeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Events Tab -->
|
||||
<div class="tab-pane fade" id="events" role="tabpanel" aria-labelledby="events-tab">
|
||||
<div class="card dashboard-card mt-4">
|
||||
<div class="card-header">
|
||||
<h3 class="h5 mb-0" th:text="#{audit.dashboard.auditEvents}">Audit Events</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Filters -->
|
||||
<div class="card filter-card">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label for="typeFilter" class="form-label" th:text="#{audit.dashboard.filter.eventType}">Event Type</label>
|
||||
<select class="form-select" id="typeFilter">
|
||||
<option value="" th:text="#{audit.dashboard.filter.allEventTypes}">All event types</option>
|
||||
<!-- Will be populated from API -->
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label for="principalFilter" class="form-label" th:text="#{audit.dashboard.filter.user}">User</label>
|
||||
<input type="text" class="form-control" id="principalFilter" th:placeholder="#{audit.dashboard.filter.userPlaceholder}" placeholder="Filter by user">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label for="startDateFilter" class="form-label" th:text="#{audit.dashboard.filter.startDate}">Start Date</label>
|
||||
<input type="date" class="form-control" id="startDateFilter">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label for="endDateFilter" class="form-label" th:text="#{audit.dashboard.filter.endDate}">End Date</label>
|
||||
<input type="date" class="form-control" id="endDateFilter">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<button id="applyFilters" class="btn btn-primary" th:text="#{audit.dashboard.filter.apply}">Apply Filters</button>
|
||||
<button id="resetFilters" class="btn btn-secondary" th:text="#{reset}">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Table -->
|
||||
<div class="table-responsive position-relative">
|
||||
<div class="loading-overlay" id="table-loading">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden" th:text="#{loading}">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-striped table-hover audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th th:text="#{audit.dashboard.table.id}">ID</th>
|
||||
<th th:text="#{audit.dashboard.table.time}">Time</th>
|
||||
<th th:text="#{audit.dashboard.table.user}">User</th>
|
||||
<th th:text="#{audit.dashboard.table.type}">Type</th>
|
||||
<th th:text="#{audit.dashboard.table.details}">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="auditTableBody">
|
||||
<!-- Table rows will be populated by JavaScript -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination-container">
|
||||
<div>
|
||||
<span th:text="#{audit.dashboard.pagination.show}">Show</span>
|
||||
<select id="pageSizeSelect" class="form-select form-select-sm d-inline-block w-auto mx-2">
|
||||
<option value="10">10</option>
|
||||
<option value="20" selected>20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
<span th:text="#{audit.dashboard.pagination.entries}">entries</span>
|
||||
<span class="mx-3" th:text="#{audit.dashboard.pagination.pageInfo1}">Page </span><span id="currentPage">1</span> <span th:text="#{audit.dashboard.pagination.pageInfo2}">of</span> <span id="totalPages">1</span> (<span th:text="#{audit.dashboard.pagination.totalRecords}">Total records:</span> <span id="totalRecords">0</span>)
|
||||
</div>
|
||||
<nav aria-label="Audit events pagination">
|
||||
<div class="btn-group" role="group" aria-label="Pagination">
|
||||
<button type="button" class="btn btn-outline-primary" id="page-first">«</button>
|
||||
<button type="button" class="btn btn-outline-primary" id="page-prev">‹</button>
|
||||
<span class="btn btn-outline-secondary disabled" id="page-indicator">Page 1 of 1</span>
|
||||
<button type="button" class="btn btn-outline-primary" id="page-next">›</button>
|
||||
<button type="button" class="btn btn-outline-primary" id="page-last">»</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Details Modal -->
|
||||
<div class="modal fade" id="eventDetailsModal" tabindex="-1" aria-labelledby="eventDetailsModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="eventDetailsModalLabel" th:text="#{audit.dashboard.modal.eventDetails}">Event Details</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" th:aria-label="#{close}" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<strong th:text="#{audit.dashboard.modal.id} + ':'">ID:</strong> <span id="modal-id"></span>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<strong th:text="#{audit.dashboard.modal.user} + ':'">User:</strong> <span id="modal-principal"></span>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<strong th:text="#{audit.dashboard.modal.type} + ':'">Type:</strong> <span id="modal-type"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-12">
|
||||
<strong th:text="#{audit.dashboard.modal.time} + ':'">Time:</strong> <span id="modal-timestamp"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<strong th:text="#{audit.dashboard.modal.data} + ':'">Data:</strong>
|
||||
<div class="json-viewer" id="modal-data"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" th:text="#{close}">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Tab -->
|
||||
<div class="tab-pane fade" id="export" role="tabpanel" aria-labelledby="export-tab">
|
||||
<div class="card dashboard-card mt-4">
|
||||
<div class="card-header">
|
||||
<h3 class="h5 mb-0" th:text="#{audit.dashboard.export.title}">Export Audit Data</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Export Filters -->
|
||||
<div class="card filter-card">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label for="exportTypeFilter" class="form-label" th:text="#{audit.dashboard.filter.eventType}">Event Type</label>
|
||||
<select class="form-select" id="exportTypeFilter">
|
||||
<option value="" th:text="#{audit.dashboard.filter.allEventTypes}">All event types</option>
|
||||
<!-- Will be populated from API -->
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label for="exportPrincipalFilter" class="form-label" th:text="#{audit.dashboard.filter.user}">User</label>
|
||||
<input type="text" class="form-control" id="exportPrincipalFilter" th:placeholder="#{audit.dashboard.filter.userPlaceholder}" placeholder="Filter by user">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label for="exportStartDateFilter" class="form-label" th:text="#{audit.dashboard.filter.startDate}">Start Date</label>
|
||||
<input type="date" class="form-control" id="exportStartDateFilter">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label for="exportEndDateFilter" class="form-label" th:text="#{audit.dashboard.filter.endDate}">End Date</label>
|
||||
<input type="date" class="form-control" id="exportEndDateFilter">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-6">
|
||||
<h5 th:text="#{audit.dashboard.export.format}">Export Format</h5>
|
||||
<div>
|
||||
<label class="btn btn-outline-primary" style="margin-right: 10px;">
|
||||
<input type="radio" name="exportFormat" id="formatCSV" value="csv" checked style="margin-right: 5px;">
|
||||
<span th:text="#{audit.dashboard.export.csv}">CSV (Comma Separated Values)</span>
|
||||
</label>
|
||||
<label class="btn btn-outline-primary">
|
||||
<input type="radio" name="exportFormat" id="formatJSON" value="json" style="margin-right: 5px;">
|
||||
<span th:text="#{audit.dashboard.export.json}">JSON (JavaScript Object Notation)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<button id="exportButton" class="btn btn-primary mt-4">
|
||||
<i class="bi bi-download"></i> <span th:text="#{audit.dashboard.export.button}">Export Data</span>
|
||||
</button>
|
||||
<button id="resetExportFilters" class="btn btn-secondary mt-4 ms-2">
|
||||
<span th:text="#{audit.dashboard.filter.reset}">Reset Filters</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-3">
|
||||
<h5 th:text="#{audit.dashboard.export.infoTitle}">Export Information</h5>
|
||||
<p th:text="#{audit.dashboard.export.infoDesc1}">The export will include all audit events matching the selected filters. For large datasets, the export may take a few moments to generate.</p>
|
||||
<p th:text="#{audit.dashboard.export.infoDesc2}">Exported data will include:</p>
|
||||
<ul>
|
||||
<li th:text="#{audit.dashboard.export.infoItem1}">Event ID</li>
|
||||
<li th:text="#{audit.dashboard.export.infoItem2}">User</li>
|
||||
<li th:text="#{audit.dashboard.export.infoItem3}">Event Type</li>
|
||||
<li th:text="#{audit.dashboard.export.infoItem4}">Timestamp</li>
|
||||
<li th:text="#{audit.dashboard.export.infoItem5}">Event Data</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS is loaded by the common fragments -->
|
||||
<script th:src="@{/js/thirdParty/jquery.min.js}"></script>
|
||||
<script th:src="@{/js/thirdParty/bootstrap.min.js}"></script>
|
||||
|
||||
<!-- Internationalization data for JavaScript -->
|
||||
<script th:inline="javascript">
|
||||
window.i18n = {
|
||||
loading: /*[[#{loading}]]*/ 'Loading...',
|
||||
noEventsFound: /*[[#{audit.dashboard.js.noEventsFound}]]*/ 'No audit events found matching the current filters',
|
||||
errorLoading: /*[[#{audit.dashboard.js.errorLoading}]]*/ 'Error loading data:',
|
||||
errorRendering: /*[[#{audit.dashboard.js.errorRendering}]]*/ 'Error rendering table:',
|
||||
loadingPage: /*[[#{audit.dashboard.js.loadingPage}]]*/ 'Loading page',
|
||||
eventsByType: /*[[#{audit.dashboard.eventsByType}]]*/ 'Events by Type',
|
||||
eventsByUser: /*[[#{audit.dashboard.eventsByUser}]]*/ 'Events by User',
|
||||
eventsOverTime: /*[[#{audit.dashboard.eventsOverTime}]]*/ 'Events Over Time',
|
||||
viewDetails: /*[[#{audit.dashboard.table.viewDetails}]]*/ 'View Details'
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- Load custom JavaScript -->
|
||||
<script th:src="@{/js/audit/dashboard.js}"></script>
|
||||
</div>
|
||||
</div>
|
||||
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
@ -79,7 +79,7 @@ class UserServiceTest {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
AuthenticationType authType = AuthenticationType.WEB;
|
||||
|
||||
|
||||
when(teamRepository.findByName("Default")).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
@ -99,7 +99,7 @@ class UserServiceTest {
|
||||
String password = "password123";
|
||||
Long teamId = 1L;
|
||||
String encodedPassword = "encodedPassword123";
|
||||
|
||||
|
||||
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
@ -124,7 +124,7 @@ class UserServiceTest {
|
||||
String role = Role.ADMIN.getRoleId();
|
||||
boolean firstLogin = true;
|
||||
String encodedPassword = "encodedPassword123";
|
||||
|
||||
|
||||
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
@ -150,7 +150,7 @@ class UserServiceTest {
|
||||
IllegalArgumentException.class,
|
||||
() -> userService.saveUser(invalidUsername, authType)
|
||||
);
|
||||
|
||||
|
||||
verify(userRepository, never()).save(any(User.class));
|
||||
verify(databaseService, never()).exportDatabase();
|
||||
}
|
||||
@ -160,7 +160,7 @@ class UserServiceTest {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
Long teamId = 1L;
|
||||
|
||||
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
@ -181,7 +181,7 @@ class UserServiceTest {
|
||||
String username = "testuser";
|
||||
String emptyPassword = "";
|
||||
Long teamId = 1L;
|
||||
|
||||
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
@ -201,7 +201,7 @@ class UserServiceTest {
|
||||
// Given
|
||||
String emailUsername = "test@example.com";
|
||||
AuthenticationType authType = AuthenticationType.SSO;
|
||||
|
||||
|
||||
when(teamRepository.findByName("Default")).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
@ -225,7 +225,7 @@ class UserServiceTest {
|
||||
IllegalArgumentException.class,
|
||||
() -> userService.saveUser(reservedUsername, authType)
|
||||
);
|
||||
|
||||
|
||||
verify(userRepository, never()).save(any(User.class));
|
||||
verify(databaseService, never()).exportDatabase();
|
||||
}
|
||||
@ -241,7 +241,7 @@ class UserServiceTest {
|
||||
IllegalArgumentException.class,
|
||||
() -> userService.saveUser(anonymousUsername, authType)
|
||||
);
|
||||
|
||||
|
||||
verify(userRepository, never()).save(any(User.class));
|
||||
verify(databaseService, never()).exportDatabase();
|
||||
}
|
||||
@ -253,7 +253,7 @@ class UserServiceTest {
|
||||
String password = "password123";
|
||||
Long teamId = 1L;
|
||||
String encodedPassword = "encodedPassword123";
|
||||
|
||||
|
||||
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
@ -261,7 +261,7 @@ class UserServiceTest {
|
||||
|
||||
// When & Then
|
||||
assertThrows(SQLException.class, () -> userService.saveUser(username, password, teamId));
|
||||
|
||||
|
||||
// Verify user was still saved before the exception
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(databaseService).exportDatabase();
|
||||
@ -276,7 +276,7 @@ class UserServiceTest {
|
||||
boolean firstLogin = true;
|
||||
boolean enabled = false;
|
||||
String encodedPassword = "encodedPassword123";
|
||||
|
||||
|
||||
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
@ -299,7 +299,7 @@ class UserServiceTest {
|
||||
Long teamId = 1L;
|
||||
String customRole = Role.LIMITED_API_USER.getRoleId();
|
||||
String encodedPassword = "encodedPassword123";
|
||||
|
||||
|
||||
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
|
@ -258,7 +258,6 @@ ignore = [
|
||||
|
||||
[es_ES]
|
||||
ignore = [
|
||||
'adminUserSettings.roles',
|
||||
'error',
|
||||
'lang.asm',
|
||||
'lang.ceb',
|
||||
@ -421,22 +420,27 @@ ignore = [
|
||||
|
||||
[hu_HU]
|
||||
ignore = [
|
||||
'lang.bre',
|
||||
'lang.ceb',
|
||||
'lang.chr',
|
||||
'lang.div',
|
||||
'lang.dzo',
|
||||
'lang.fao',
|
||||
'lang.iku',
|
||||
'lang.kan',
|
||||
'lang.lao',
|
||||
'lang.mar',
|
||||
'lang.mri',
|
||||
'lang.ori',
|
||||
'lang.que',
|
||||
'lang.tel',
|
||||
'lang.tgl',
|
||||
'AddStampRequest.alphabet',
|
||||
'AddStampRequest.position',
|
||||
'adminUserSettings.admin',
|
||||
'alphabet',
|
||||
'audit.dashboard.export.json',
|
||||
'audit.dashboard.modal.id',
|
||||
'audit.dashboard.table.id',
|
||||
'certSign.name',
|
||||
'cookieBanner.popUp.acceptAllBtn',
|
||||
'endpointStatistics.top10',
|
||||
'endpointStatistics.top20',
|
||||
'language.direction',
|
||||
'licenses.version',
|
||||
'poweredBy',
|
||||
'pro',
|
||||
'sponsor',
|
||||
'text',
|
||||
'validateSignature.cert.bits',
|
||||
'validateSignature.cert.version',
|
||||
'validateSignature.status',
|
||||
'watermark.type.1',
|
||||
]
|
||||
|
||||
[id_ID]
|
||||
@ -1000,9 +1004,6 @@ ignore = [
|
||||
|
||||
[zh_CN]
|
||||
ignore = [
|
||||
'lang.dzo',
|
||||
'lang.iku',
|
||||
'lang.que',
|
||||
'language.direction',
|
||||
]
|
||||
|
||||
|
@ -12,6 +12,19 @@ configurations {
|
||||
}
|
||||
}
|
||||
|
||||
spotless {
|
||||
java {
|
||||
target sourceSets.main.allJava
|
||||
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
||||
|
||||
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
||||
toggleOffOn()
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
if (System.getenv('STIRLING_PDF_DESKTOP_UI') != 'false'
|
||||
|| (project.hasProperty('STIRLING_PDF_DESKTOP_UI')
|
||||
@ -33,7 +46,7 @@ dependencies {
|
||||
implementation 'commons-io:commons-io:2.19.0'
|
||||
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
|
||||
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
|
||||
implementation 'io.micrometer:micrometer-core:1.14.6'
|
||||
implementation 'io.micrometer:micrometer-core:1.15.1'
|
||||
implementation 'com.google.zxing:core:3.5.3'
|
||||
implementation "org.commonmark:commonmark:$commonmarkVersion" // https://mvnrepository.com/artifact/org.commonmark/commonmark
|
||||
implementation "org.commonmark:commonmark-ext-gfm-tables:$commonmarkVersion"
|
||||
@ -49,10 +62,10 @@ dependencies {
|
||||
exclude group: 'com.google.code.gson', module: 'gson'
|
||||
}
|
||||
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
|
||||
implementation 'com.opencsv:opencsv:5.11' // https://mvnrepository.com/artifact/com.opencsv/opencsv
|
||||
implementation 'com.opencsv:opencsv:5.11.1' // https://mvnrepository.com/artifact/com.opencsv/opencsv
|
||||
|
||||
// Batik
|
||||
implementation 'org.apache.xmlgraphics:batik-all:1.18'
|
||||
implementation 'org.apache.xmlgraphics:batik-all:1.19'
|
||||
|
||||
// TwelveMonkeys
|
||||
runtimeOnly "com.twelvemonkeys.imageio:imageio-batik:$imageioVersion"
|
||||
@ -99,7 +112,7 @@ sourceSets {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -118,7 +131,7 @@ bootJar {
|
||||
// from {
|
||||
// configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
|
||||
// }
|
||||
|
||||
|
||||
// Exclude signature files to prevent "Invalid signature file digest" errors
|
||||
exclude 'META-INF/*.SF'
|
||||
exclude 'META-INF/*.DSA'
|
||||
@ -134,4 +147,4 @@ bootJar {
|
||||
}
|
||||
|
||||
bootJar.dependsOn ':common:jar'
|
||||
bootJar.dependsOn ':proprietary:jar'
|
||||
bootJar.dependsOn ':proprietary:jar'
|
||||
|
@ -175,7 +175,6 @@ public class SPDFApplication {
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info("Running configs {}", applicationProperties.toString());
|
||||
}
|
||||
|
||||
public static void setServerPortStatic(String port) {
|
||||
@ -208,20 +207,19 @@ public class SPDFApplication {
|
||||
if (arg.startsWith("--spring.profiles.active=")) {
|
||||
String[] provided = arg.substring(arg.indexOf('=') + 1).split(",");
|
||||
if (provided.length > 0) {
|
||||
log.info("#######0000000000000###############################");
|
||||
return provided;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info("######################################");
|
||||
|
||||
// 2. Detect if SecurityConfiguration is present on classpath
|
||||
if (isClassPresent(
|
||||
"stirling.software.proprietary.security.configuration.SecurityConfiguration")) {
|
||||
log.info("security");
|
||||
log.info("Additional features in jar");
|
||||
return new String[] {"security"};
|
||||
} else {
|
||||
log.info("default");
|
||||
log.info("Without additional features in jar");
|
||||
return new String[] {"default"};
|
||||
}
|
||||
}
|
||||
|
@ -23,7 +23,13 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
|
||||
"errorOAuth",
|
||||
"file",
|
||||
"messageType",
|
||||
"infoMessage");
|
||||
"infoMessage",
|
||||
"page",
|
||||
"size",
|
||||
"type",
|
||||
"principal",
|
||||
"startDate",
|
||||
"endDate");
|
||||
|
||||
@Override
|
||||
public boolean preHandle(
|
||||
|
@ -142,6 +142,7 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Convert", "markdown-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-csv");
|
||||
addEndpointToGroup("Convert", "pdf-to-markdown");
|
||||
addEndpointToGroup("Convert", "eml-to-pdf");
|
||||
|
||||
// Adding endpoints to "Security" group
|
||||
addEndpointToGroup("Security", "add-password");
|
||||
@ -173,6 +174,7 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Other", "get-info-on-pdf");
|
||||
addEndpointToGroup("Other", "show-javascript");
|
||||
addEndpointToGroup("Other", "remove-image-pdf");
|
||||
addEndpointToGroup("Other", "add-attachments");
|
||||
|
||||
// CLI
|
||||
addEndpointToGroup("CLI", "compress-pdf");
|
||||
@ -251,6 +253,7 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "pdf-to-text");
|
||||
addEndpointToGroup("Java", "remove-image-pdf");
|
||||
addEndpointToGroup("Java", "pdf-to-markdown");
|
||||
addEndpointToGroup("Java", "add-attachments");
|
||||
|
||||
// Javascript
|
||||
addEndpointToGroup("Javascript", "pdf-organizer");
|
||||
@ -265,6 +268,7 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Weasyprint", "html-to-pdf");
|
||||
addEndpointToGroup("Weasyprint", "url-to-pdf");
|
||||
addEndpointToGroup("Weasyprint", "markdown-to-pdf");
|
||||
addEndpointToGroup("Weasyprint", "eml-to-pdf");
|
||||
|
||||
// Pdftohtml dependent endpoints
|
||||
addEndpointToGroup("Pdftohtml", "pdf-to-html");
|
||||
|
@ -225,7 +225,7 @@ public class MergeController {
|
||||
String mergedFileName =
|
||||
files[0].getOriginalFilename().replaceFirst("[.][^.]+$", "")
|
||||
+ "_merged_unsigned.pdf";
|
||||
return WebResponseUtils.boasToWebResponse(
|
||||
return WebResponseUtils.baosToWebResponse(
|
||||
baos, mergedFileName); // Return the modified PDF
|
||||
|
||||
} catch (Exception ex) {
|
||||
|
@ -1,13 +1,57 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Calendar;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TimeZone;
|
||||
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSBase;
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdfwriter.compress.CompressParameters;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDMetadata;
|
||||
import org.apache.pdfbox.pdmodel.common.PDStream;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFontDescriptor;
|
||||
import org.apache.pdfbox.pdmodel.font.PDTrueTypeFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.color.PDOutputIntent;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationTextMarkup;
|
||||
import org.apache.pdfbox.pdmodel.interactive.viewerpreferences.PDViewerPreferences;
|
||||
import org.apache.xmpbox.XMPMetadata;
|
||||
import org.apache.xmpbox.schema.AdobePDFSchema;
|
||||
import org.apache.xmpbox.schema.DublinCoreSchema;
|
||||
import org.apache.xmpbox.schema.PDFAIdentificationSchema;
|
||||
import org.apache.xmpbox.schema.XMPBasicSchema;
|
||||
import org.apache.xmpbox.xml.DomXmpParser;
|
||||
import org.apache.xmpbox.xml.XmpSerializer;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
@ -60,54 +104,37 @@ public class ConvertPDFToPDFA {
|
||||
: originalFileName;
|
||||
|
||||
Path tempInputFile = null;
|
||||
Path tempOutputDir = null;
|
||||
byte[] fileBytes;
|
||||
Path loPdfPath = null; // Used for LibreOffice conversion output
|
||||
File preProcessedFile = null;
|
||||
int pdfaPart = 2;
|
||||
|
||||
try {
|
||||
// Save uploaded file to temp location
|
||||
tempInputFile = Files.createTempFile("input_", ".pdf");
|
||||
inputFile.transferTo(tempInputFile);
|
||||
|
||||
// Create temp output directory
|
||||
tempOutputDir = Files.createTempDirectory("output_");
|
||||
|
||||
// Determine PDF/A filter based on requested format
|
||||
String pdfFilter =
|
||||
"pdfa".equals(outputFormat)
|
||||
? "pdf:writer_pdf_Export:{\"SelectPdfVersion\":{\"type\":\"long\",\"value\":\"2\"}}"
|
||||
: "pdf:writer_pdf_Export:{\"SelectPdfVersion\":{\"type\":\"long\",\"value\":\"1\"}}";
|
||||
|
||||
// Prepare LibreOffice command
|
||||
List<String> command =
|
||||
new ArrayList<>(
|
||||
Arrays.asList(
|
||||
"soffice",
|
||||
"--headless",
|
||||
"--nologo",
|
||||
"--convert-to",
|
||||
pdfFilter,
|
||||
"--outdir",
|
||||
tempOutputDir.toString(),
|
||||
tempInputFile.toString()));
|
||||
|
||||
ProcessExecutorResult returnCode =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
|
||||
.runCommandWithOutputHandling(command);
|
||||
|
||||
if (returnCode.getRc() != 0) {
|
||||
log.error("PDF/A conversion failed with return code: {}", returnCode.getRc());
|
||||
throw new RuntimeException("PDF/A conversion failed");
|
||||
// Branch conversion based on desired output PDF/A format
|
||||
if ("pdfa".equals(outputFormat)) {
|
||||
preProcessedFile = tempInputFile.toFile();
|
||||
} else {
|
||||
pdfaPart = 1;
|
||||
preProcessedFile = preProcessHighlights(tempInputFile.toFile());
|
||||
}
|
||||
|
||||
// Get the output file
|
||||
File[] outputFiles = tempOutputDir.toFile().listFiles();
|
||||
if (outputFiles == null || outputFiles.length != 1) {
|
||||
throw new RuntimeException(
|
||||
"Expected exactly one output file but found "
|
||||
+ (outputFiles == null ? "none" : outputFiles.length));
|
||||
Set<String> missingFonts = new HashSet<>();
|
||||
boolean needImgs = false;
|
||||
try (PDDocument doc = Loader.loadPDF(preProcessedFile)) {
|
||||
missingFonts = findUnembeddedFontNames(doc);
|
||||
needImgs = (pdfaPart == 1) && hasTransparentImages(doc);
|
||||
if (!missingFonts.isEmpty() || needImgs) {
|
||||
// Run LibreOffice conversion to get flattened images and embedded fonts
|
||||
loPdfPath = runLibreOfficeConversion(preProcessedFile.toPath(), pdfaPart);
|
||||
}
|
||||
}
|
||||
fileBytes =
|
||||
convertToPdfA(
|
||||
preProcessedFile.toPath(), loPdfPath, pdfaPart, missingFonts, needImgs);
|
||||
|
||||
fileBytes = FileUtils.readFileToByteArray(outputFiles[0]);
|
||||
String outputFilename = baseFileName + "_PDFA.pdf";
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
@ -118,9 +145,557 @@ public class ConvertPDFToPDFA {
|
||||
if (tempInputFile != null) {
|
||||
Files.deleteIfExists(tempInputFile);
|
||||
}
|
||||
if (tempOutputDir != null) {
|
||||
FileUtils.deleteDirectory(tempOutputDir.toFile());
|
||||
if (loPdfPath != null && loPdfPath.getParent() != null) {
|
||||
FileUtils.deleteDirectory(loPdfPath.getParent().toFile());
|
||||
}
|
||||
if (preProcessedFile != null) {
|
||||
Files.deleteIfExists(preProcessedFile.toPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge fonts & flattened images from loPdfPath into basePdfPath, then run the standard
|
||||
* PDFBox/A pipeline.
|
||||
*
|
||||
* @param basePdfPath Path to the original (or highlight‐preprocessed) PDF
|
||||
* @param loPdfPath Path to the LibreOffice–flattened PDF/A, or null if not used
|
||||
* @param pdfaPart 1 (PDF/A-1B) or 2 (PDF/A-2B)
|
||||
* @return the final PDF/A bytes
|
||||
*/
|
||||
private byte[] convertToPdfA(
|
||||
Path basePdfPath,
|
||||
Path loPdfPath,
|
||||
int pdfaPart,
|
||||
Set<String> missingFonts,
|
||||
boolean importImages)
|
||||
throws Exception {
|
||||
try (PDDocument baseDoc = Loader.loadPDF(basePdfPath.toFile())) {
|
||||
|
||||
if (loPdfPath != null) {
|
||||
try (PDDocument loDoc = Loader.loadPDF(loPdfPath.toFile())) {
|
||||
if (!missingFonts.isEmpty()) {
|
||||
embedMissingFonts(loDoc, baseDoc, missingFonts);
|
||||
}
|
||||
if (importImages) {
|
||||
importFlattenedImages(loDoc, baseDoc);
|
||||
}
|
||||
}
|
||||
}
|
||||
return processWithPDFBox(baseDoc, pdfaPart);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] processWithPDFBox(PDDocument document, int pdfaPart) throws Exception {
|
||||
|
||||
removeElementsForPdfA(document, pdfaPart);
|
||||
|
||||
mergeAndAddXmpMetadata(document, pdfaPart);
|
||||
|
||||
addICCProfileIfNotPresent(document);
|
||||
|
||||
// Mark the document as PDF/A
|
||||
PDDocumentCatalog catalog = document.getDocumentCatalog();
|
||||
catalog.setMetadata(
|
||||
document.getDocumentCatalog().getMetadata()); // Ensure metadata is linked
|
||||
catalog.setViewerPreferences(
|
||||
new PDViewerPreferences(catalog.getCOSObject())); // PDF/A best practice
|
||||
document.getDocument().setVersion(pdfaPart == 1 ? 1.4f : 1.7f);
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
if (pdfaPart == 1) {
|
||||
document.save(baos, CompressParameters.NO_COMPRESSION);
|
||||
} else {
|
||||
document.save(baos);
|
||||
}
|
||||
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private Path runLibreOfficeConversion(Path tempInputFile, int pdfaPart) throws Exception {
|
||||
// Create temp output directory
|
||||
Path tempOutputDir = Files.createTempDirectory("output_");
|
||||
|
||||
// Determine PDF/A filter based on requested format
|
||||
String pdfFilter =
|
||||
pdfaPart == 2
|
||||
? "pdf:writer_pdf_Export:{\"SelectPdfVersion\":{\"type\":\"long\",\"value\":\"2\"}}"
|
||||
: "pdf:writer_pdf_Export:{\"SelectPdfVersion\":{\"type\":\"long\",\"value\":\"1\"}}";
|
||||
|
||||
// Prepare LibreOffice command
|
||||
List<String> command =
|
||||
new ArrayList<>(
|
||||
Arrays.asList(
|
||||
"soffice",
|
||||
"--headless",
|
||||
"--nologo",
|
||||
"--convert-to",
|
||||
pdfFilter,
|
||||
"--outdir",
|
||||
tempOutputDir.toString(),
|
||||
tempInputFile.toString()));
|
||||
|
||||
ProcessExecutorResult returnCode =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
|
||||
.runCommandWithOutputHandling(command);
|
||||
|
||||
if (returnCode.getRc() != 0) {
|
||||
log.error("PDF/A conversion failed with return code: {}", returnCode.getRc());
|
||||
throw new RuntimeException("PDF/A conversion failed");
|
||||
}
|
||||
|
||||
// Get the output file
|
||||
File[] outputFiles = tempOutputDir.toFile().listFiles();
|
||||
if (outputFiles == null || outputFiles.length != 1) {
|
||||
throw new RuntimeException(
|
||||
"Expected one output PDF, found "
|
||||
+ (outputFiles == null ? "none" : outputFiles.length));
|
||||
}
|
||||
return outputFiles[0].toPath();
|
||||
}
|
||||
|
||||
private void embedMissingFonts(PDDocument loDoc, PDDocument baseDoc, Set<String> missingFonts)
|
||||
throws IOException {
|
||||
List<PDPage> loPages = new ArrayList<>();
|
||||
loDoc.getPages().forEach(loPages::add);
|
||||
List<PDPage> basePages = new ArrayList<>();
|
||||
baseDoc.getPages().forEach(basePages::add);
|
||||
|
||||
for (int i = 0; i < loPages.size(); i++) {
|
||||
PDResources loRes = loPages.get(i).getResources();
|
||||
PDResources baseRes = basePages.get(i).getResources();
|
||||
|
||||
for (COSName fontKey : loRes.getFontNames()) {
|
||||
PDFont loFont = loRes.getFont(fontKey);
|
||||
if (loFont == null) continue;
|
||||
|
||||
String psName = loFont.getName();
|
||||
if (!missingFonts.contains(psName)) continue;
|
||||
|
||||
PDFontDescriptor desc = loFont.getFontDescriptor();
|
||||
if (desc == null) continue;
|
||||
|
||||
PDStream fontStream = null;
|
||||
if (desc.getFontFile() != null) {
|
||||
fontStream = desc.getFontFile();
|
||||
} else if (desc.getFontFile2() != null) {
|
||||
fontStream = desc.getFontFile2();
|
||||
} else if (desc.getFontFile3() != null) {
|
||||
fontStream = desc.getFontFile3();
|
||||
}
|
||||
if (fontStream == null) continue;
|
||||
|
||||
try (InputStream in = fontStream.createInputStream()) {
|
||||
PDFont newFont = null;
|
||||
try {
|
||||
newFont = PDType0Font.load(baseDoc, in, false);
|
||||
} catch (IOException e1) {
|
||||
try {
|
||||
newFont = PDTrueTypeFont.load(baseDoc, in, null);
|
||||
} catch (IOException | IllegalArgumentException e2) {
|
||||
log.error("Could not embed font {}: {}", psName, e2.getMessage());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (newFont != null) {
|
||||
baseRes.put(fontKey, newFont);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Set<String> findUnembeddedFontNames(PDDocument doc) throws IOException {
|
||||
Set<String> missing = new HashSet<>();
|
||||
for (PDPage page : doc.getPages()) {
|
||||
PDResources res = page.getResources();
|
||||
for (COSName name : res.getFontNames()) {
|
||||
PDFont font = res.getFont(name);
|
||||
if (font != null && !font.isEmbedded()) {
|
||||
missing.add(font.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
private void importFlattenedImages(PDDocument loDoc, PDDocument baseDoc) throws IOException {
|
||||
List<PDPage> loPages = new ArrayList<>();
|
||||
loDoc.getPages().forEach(loPages::add);
|
||||
List<PDPage> basePages = new ArrayList<>();
|
||||
baseDoc.getPages().forEach(basePages::add);
|
||||
|
||||
for (int i = 0; i < loPages.size(); i++) {
|
||||
PDPage loPage = loPages.get(i);
|
||||
PDPage basePage = basePages.get(i);
|
||||
|
||||
PDResources loRes = loPage.getResources();
|
||||
PDResources baseRes = basePage.getResources();
|
||||
Set<COSName> toReplace = detectTransparentXObjects(basePage);
|
||||
|
||||
for (COSName name : toReplace) {
|
||||
PDXObject loXo = loRes.getXObject(name);
|
||||
if (!(loXo instanceof PDImageXObject img)) continue;
|
||||
|
||||
PDImageXObject newImg = LosslessFactory.createFromImage(baseDoc, img.getImage());
|
||||
|
||||
// replace the resource under the same name
|
||||
baseRes.put(name, newImg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Set<COSName> detectTransparentXObjects(PDPage page) {
|
||||
Set<COSName> transparentObjects = new HashSet<>();
|
||||
PDResources res = page.getResources();
|
||||
if (res == null) return transparentObjects;
|
||||
|
||||
for (COSName name : res.getXObjectNames()) {
|
||||
try {
|
||||
PDXObject xo = res.getXObject(name);
|
||||
if (xo instanceof PDImageXObject img) {
|
||||
COSDictionary d = img.getCOSObject();
|
||||
if (d.containsKey(COSName.SMASK)
|
||||
|| isTransparencyGroup(d)
|
||||
|| d.getBoolean(COSName.INTERPOLATE, false)) {
|
||||
transparentObjects.add(name);
|
||||
}
|
||||
}
|
||||
} catch (IOException ioe) {
|
||||
log.error("Error processing XObject {}: {}", name.getName(), ioe.getMessage());
|
||||
}
|
||||
}
|
||||
return transparentObjects;
|
||||
}
|
||||
|
||||
private boolean isTransparencyGroup(COSDictionary dict) {
|
||||
COSBase g = dict.getDictionaryObject(COSName.GROUP);
|
||||
return g instanceof COSDictionary gd
|
||||
&& COSName.TRANSPARENCY.equals(gd.getCOSName(COSName.S));
|
||||
}
|
||||
|
||||
private boolean hasTransparentImages(PDDocument doc) {
|
||||
for (PDPage page : doc.getPages()) {
|
||||
PDResources res = page.getResources();
|
||||
if (res == null) continue;
|
||||
for (COSName name : res.getXObjectNames()) {
|
||||
try {
|
||||
PDXObject xo = res.getXObject(name);
|
||||
if (xo instanceof PDImageXObject img) {
|
||||
COSDictionary dict = img.getCOSObject();
|
||||
if (dict.containsKey(COSName.SMASK)) return true;
|
||||
COSBase g = dict.getDictionaryObject(COSName.GROUP);
|
||||
if (g instanceof COSDictionary gd
|
||||
&& COSName.TRANSPARENCY.equals(gd.getCOSName(COSName.S))) {
|
||||
return true;
|
||||
}
|
||||
if (dict.getBoolean(COSName.INTERPOLATE, false)) return true;
|
||||
}
|
||||
} catch (IOException ioe) {
|
||||
log.error("Error processing XObject {}: {}", name.getName(), ioe.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void sanitizePdfA(COSBase base, PDResources resources, int pdfaPart) {
|
||||
if (base instanceof COSDictionary dict) {
|
||||
if (pdfaPart == 1) {
|
||||
// Remove transparency-related elements
|
||||
COSBase group = dict.getDictionaryObject(COSName.GROUP);
|
||||
if (group instanceof COSDictionary gDict
|
||||
&& COSName.TRANSPARENCY.equals(gDict.getCOSName(COSName.S))) {
|
||||
dict.removeItem(COSName.GROUP);
|
||||
}
|
||||
|
||||
dict.removeItem(COSName.SMASK);
|
||||
// Transparency blending constants (/CA, /ca) — disallowed in PDF/A-1
|
||||
dict.removeItem(COSName.CA);
|
||||
dict.removeItem(COSName.getPDFName("ca"));
|
||||
}
|
||||
|
||||
// Interpolation (non-deterministic image scaling) — required to be false
|
||||
if (dict.containsKey(COSName.INTERPOLATE)
|
||||
&& dict.getBoolean(COSName.INTERPOLATE, true)) {
|
||||
dict.setBoolean(COSName.INTERPOLATE, false);
|
||||
}
|
||||
|
||||
// Remove common forbidden features (for PDF/A 1 and 2)
|
||||
dict.removeItem(COSName.JAVA_SCRIPT);
|
||||
dict.removeItem(COSName.getPDFName("JS"));
|
||||
dict.removeItem(COSName.getPDFName("RichMedia"));
|
||||
dict.removeItem(COSName.getPDFName("Movie"));
|
||||
dict.removeItem(COSName.getPDFName("Sound"));
|
||||
dict.removeItem(COSName.getPDFName("Launch"));
|
||||
dict.removeItem(COSName.URI);
|
||||
dict.removeItem(COSName.getPDFName("GoToR"));
|
||||
dict.removeItem(COSName.EMBEDDED_FILES);
|
||||
dict.removeItem(COSName.FILESPEC);
|
||||
|
||||
// Recurse through all entries in the dictionary
|
||||
for (Map.Entry<COSName, COSBase> entry : dict.entrySet()) {
|
||||
sanitizePdfA(entry.getValue(), resources, pdfaPart);
|
||||
}
|
||||
|
||||
} else if (base instanceof COSArray arr) {
|
||||
// Recursively sanitize each item in the array
|
||||
for (COSBase item : arr) {
|
||||
sanitizePdfA(item, resources, pdfaPart);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void removeElementsForPdfA(PDDocument doc, int pdfaPart) {
|
||||
|
||||
if (pdfaPart == 1) {
|
||||
// Remove Optional Content (Layers) - not allowed in PDF/A-1
|
||||
doc.getDocumentCatalog().getCOSObject().removeItem(COSName.getPDFName("OCProperties"));
|
||||
}
|
||||
|
||||
for (PDPage page : doc.getPages()) {
|
||||
if (pdfaPart == 1) {
|
||||
page.setAnnotations(Collections.emptyList());
|
||||
}
|
||||
PDResources res = page.getResources();
|
||||
// Clean page-level dictionary
|
||||
sanitizePdfA(page.getCOSObject(), res, pdfaPart);
|
||||
|
||||
// sanitize each Form XObject
|
||||
if (res != null) {
|
||||
for (COSName name : res.getXObjectNames()) {
|
||||
try {
|
||||
PDXObject xo = res.getXObject(name);
|
||||
if (xo instanceof PDFormXObject form) {
|
||||
sanitizePdfA(form.getCOSObject(), res, pdfaPart);
|
||||
} else if (xo instanceof PDImageXObject img) {
|
||||
sanitizePdfA(img.getCOSObject(), res, pdfaPart);
|
||||
}
|
||||
} catch (IOException ioe) {
|
||||
log.error("Cannot load XObject {}: {}", name.getName(), ioe.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Embbeds the XMP metadata required for PDF/A compliance. */
|
||||
private void mergeAndAddXmpMetadata(PDDocument document, int pdfaPart) throws Exception {
|
||||
PDMetadata existingMetadata = document.getDocumentCatalog().getMetadata();
|
||||
XMPMetadata xmp;
|
||||
|
||||
// Load existing XMP if available
|
||||
if (existingMetadata != null) {
|
||||
try (InputStream xmpStream = existingMetadata.createInputStream()) {
|
||||
DomXmpParser parser = new DomXmpParser();
|
||||
parser.setStrictParsing(false);
|
||||
xmp = parser.parse(xmpStream);
|
||||
} catch (Exception e) {
|
||||
xmp = XMPMetadata.createXMPMetadata();
|
||||
}
|
||||
} else {
|
||||
xmp = XMPMetadata.createXMPMetadata();
|
||||
}
|
||||
|
||||
PDDocumentInformation docInfo = document.getDocumentInformation();
|
||||
if (docInfo == null) {
|
||||
docInfo = new PDDocumentInformation();
|
||||
}
|
||||
|
||||
String originalCreator = Optional.ofNullable(docInfo.getCreator()).orElse("Unknown");
|
||||
String originalProducer = Optional.ofNullable(docInfo.getProducer()).orElse("Unknown");
|
||||
|
||||
// Only keep the original creator so it can match xmp creator tool for compliance
|
||||
DublinCoreSchema dcSchema = xmp.getDublinCoreSchema();
|
||||
if (dcSchema != null) {
|
||||
List<String> existingCreators = dcSchema.getCreators();
|
||||
if (existingCreators != null) {
|
||||
for (String creator : new ArrayList<>(existingCreators)) {
|
||||
dcSchema.removeCreator(creator);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dcSchema = xmp.createAndAddDublinCoreSchema();
|
||||
}
|
||||
dcSchema.addCreator(originalCreator);
|
||||
|
||||
PDFAIdentificationSchema pdfaSchema =
|
||||
(PDFAIdentificationSchema) xmp.getSchema(PDFAIdentificationSchema.class);
|
||||
if (pdfaSchema == null) {
|
||||
pdfaSchema = xmp.createAndAddPDFAIdentificationSchema();
|
||||
}
|
||||
pdfaSchema.setPart(pdfaPart);
|
||||
pdfaSchema.setConformance("B");
|
||||
|
||||
XMPBasicSchema xmpBasicSchema = xmp.getXMPBasicSchema();
|
||||
if (xmpBasicSchema == null) {
|
||||
xmpBasicSchema = xmp.createAndAddXMPBasicSchema();
|
||||
}
|
||||
|
||||
AdobePDFSchema adobePdfSchema = xmp.getAdobePDFSchema();
|
||||
if (adobePdfSchema == null) {
|
||||
adobePdfSchema = xmp.createAndAddAdobePDFSchema();
|
||||
}
|
||||
|
||||
docInfo.setCreator(originalCreator);
|
||||
xmpBasicSchema.setCreatorTool(originalCreator);
|
||||
|
||||
docInfo.setProducer(originalProducer);
|
||||
adobePdfSchema.setProducer(originalProducer);
|
||||
|
||||
String originalAuthor = docInfo.getAuthor();
|
||||
if (originalAuthor != null && !originalAuthor.isBlank()) {
|
||||
docInfo.setAuthor(null);
|
||||
// If the author is set, we keep it in the XMP metadata
|
||||
if (!originalCreator.equals(originalAuthor)) {
|
||||
dcSchema.addCreator(originalAuthor);
|
||||
}
|
||||
}
|
||||
|
||||
String title = docInfo.getTitle();
|
||||
if (title != null && !title.isBlank()) {
|
||||
dcSchema.setTitle(title);
|
||||
}
|
||||
String subject = docInfo.getSubject();
|
||||
if (subject != null && !subject.isBlank()) {
|
||||
dcSchema.addSubject(subject);
|
||||
}
|
||||
String keywords = docInfo.getKeywords();
|
||||
if (keywords != null && !keywords.isBlank()) {
|
||||
adobePdfSchema.setKeywords(keywords);
|
||||
}
|
||||
|
||||
// Set creation and modification dates
|
||||
Calendar now = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
|
||||
Calendar originalCreationDate = docInfo.getCreationDate();
|
||||
if (originalCreationDate == null) {
|
||||
originalCreationDate = now;
|
||||
}
|
||||
docInfo.setCreationDate(originalCreationDate);
|
||||
xmpBasicSchema.setCreateDate(originalCreationDate);
|
||||
|
||||
docInfo.setModificationDate(now);
|
||||
xmpBasicSchema.setModifyDate(now);
|
||||
xmpBasicSchema.setMetadataDate(now);
|
||||
|
||||
// Serialize the created metadata so it can be attached to the existent metadata
|
||||
ByteArrayOutputStream xmpOut = new ByteArrayOutputStream();
|
||||
new XmpSerializer().serialize(xmp, xmpOut, true);
|
||||
|
||||
PDMetadata newMetadata = new PDMetadata(document);
|
||||
newMetadata.importXMPMetadata(xmpOut.toByteArray());
|
||||
document.getDocumentCatalog().setMetadata(newMetadata);
|
||||
}
|
||||
|
||||
private void addICCProfileIfNotPresent(PDDocument document) throws Exception {
|
||||
if (document.getDocumentCatalog().getOutputIntents().isEmpty()) {
|
||||
try (InputStream colorProfile = getClass().getResourceAsStream("/icc/sRGB2014.icc")) {
|
||||
PDOutputIntent outputIntent = new PDOutputIntent(document, colorProfile);
|
||||
outputIntent.setInfo("sRGB IEC61966-2.1");
|
||||
outputIntent.setOutputCondition("sRGB IEC61966-2.1");
|
||||
outputIntent.setOutputConditionIdentifier("sRGB IEC61966-2.1");
|
||||
outputIntent.setRegistryName("http://www.color.org");
|
||||
document.getDocumentCatalog().addOutputIntent(outputIntent);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load ICC profile: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private File preProcessHighlights(File inputPdf) throws Exception {
|
||||
|
||||
try (PDDocument document = Loader.loadPDF(inputPdf)) {
|
||||
|
||||
for (PDPage page : document.getPages()) {
|
||||
// Retrieve the annotations on the page.
|
||||
List<PDAnnotation> annotations = page.getAnnotations();
|
||||
for (PDAnnotation annot : annotations) {
|
||||
// Process only highlight annotations.
|
||||
if ("Highlight".equals(annot.getSubtype())
|
||||
&& annot instanceof PDAnnotationTextMarkup highlight) {
|
||||
// Create a new appearance stream with the same bounding box.
|
||||
float[] colorComponents =
|
||||
highlight.getColor() != null
|
||||
? highlight.getColor().getComponents()
|
||||
: new float[] {1f, 1f, 0f};
|
||||
Color highlightColor =
|
||||
new Color(
|
||||
colorComponents[0], colorComponents[1], colorComponents[2]);
|
||||
|
||||
float[] quadPoints = highlight.getQuadPoints();
|
||||
if (quadPoints != null) {
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.PREPEND,
|
||||
true,
|
||||
true)) {
|
||||
|
||||
cs.setStrokingColor(highlightColor);
|
||||
cs.setLineWidth(0.05f);
|
||||
float spacing = 2f;
|
||||
// Draw diagonal lines across the highlight area to simulate
|
||||
// transparency.
|
||||
for (int i = 0; i < quadPoints.length; i += 8) {
|
||||
float minX =
|
||||
Math.min(
|
||||
Math.min(quadPoints[i], quadPoints[i + 2]),
|
||||
Math.min(quadPoints[i + 4], quadPoints[i + 6]));
|
||||
float maxX =
|
||||
Math.max(
|
||||
Math.max(quadPoints[i], quadPoints[i + 2]),
|
||||
Math.max(quadPoints[i + 4], quadPoints[i + 6]));
|
||||
float minY =
|
||||
Math.min(
|
||||
Math.min(quadPoints[i + 1], quadPoints[i + 3]),
|
||||
Math.min(quadPoints[i + 5], quadPoints[i + 7]));
|
||||
float maxY =
|
||||
Math.max(
|
||||
Math.max(quadPoints[i + 1], quadPoints[i + 3]),
|
||||
Math.max(quadPoints[i + 5], quadPoints[i + 7]));
|
||||
|
||||
float width = maxX - minX;
|
||||
float height = maxY - minY;
|
||||
|
||||
for (float y = minY; y <= maxY; y += spacing) {
|
||||
float len = Math.min(width, maxY - y);
|
||||
cs.moveTo(minX, y);
|
||||
cs.lineTo(minX + len, y + len);
|
||||
}
|
||||
for (float x = minX + spacing; x <= maxX; x += spacing) {
|
||||
float len = Math.min(maxX - x, height);
|
||||
cs.moveTo(x, minY);
|
||||
cs.lineTo(x + len, minY + len);
|
||||
}
|
||||
}
|
||||
|
||||
cs.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
page.getAnnotations().remove(highlight);
|
||||
COSDictionary pageDict = page.getCOSObject();
|
||||
|
||||
if (pageDict.containsKey(COSName.GROUP)) {
|
||||
COSDictionary groupDict =
|
||||
(COSDictionary) pageDict.getDictionaryObject(COSName.GROUP);
|
||||
|
||||
if (groupDict != null) {
|
||||
if (COSName.TRANSPARENCY
|
||||
.getName()
|
||||
.equalsIgnoreCase(groupDict.getNameAsString(COSName.S))) {
|
||||
pageDict.removeItem(COSName.GROUP);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Save the modified document to a temporary file.
|
||||
File preProcessedFile = Files.createTempFile("preprocessed_", ".pdf").toFile();
|
||||
document.save(preProcessedFile);
|
||||
return preProcessedFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,57 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.AddAttachmentRequest;
|
||||
import stirling.software.SPDF.service.AttachmentServiceInterface;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/misc")
|
||||
@Tag(name = "Misc", description = "Miscellaneous APIs")
|
||||
public class AttachmentController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
private final AttachmentServiceInterface pdfAttachmentService;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/add-attachments")
|
||||
@Operation(
|
||||
summary = "Add attachments to PDF",
|
||||
description =
|
||||
"This endpoint adds embedded files (attachments) to a PDF and sets the PageMode to UseAttachments to make them visible. Input:PDF + Files Output:PDF Type:MISO")
|
||||
public ResponseEntity<byte[]> addAttachments(@ModelAttribute AddAttachmentRequest request)
|
||||
throws IOException {
|
||||
MultipartFile fileInput = request.getFileInput();
|
||||
List<MultipartFile> attachments = request.getAttachments();
|
||||
|
||||
PDDocument document =
|
||||
pdfAttachmentService.addAttachment(
|
||||
pdfDocumentFactory.load(fileInput, false), attachments);
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
Filenames.toSimpleFileName(fileInput.getOriginalFilename())
|
||||
.replaceFirst("[.][^.]+$", "")
|
||||
+ "_with_attachments.pdf");
|
||||
}
|
||||
}
|
@ -144,7 +144,7 @@ public class BlankPageController {
|
||||
zos.close();
|
||||
|
||||
log.info("Returning ZIP file: {}", filename + "_processed.zip");
|
||||
return WebResponseUtils.boasToWebResponse(
|
||||
return WebResponseUtils.baosToWebResponse(
|
||||
baos, filename + "_processed.zip", MediaType.APPLICATION_OCTET_STREAM);
|
||||
|
||||
} catch (IOException e) {
|
||||
|
@ -148,7 +148,7 @@ public class ExtractImagesController {
|
||||
// Create ByteArrayResource from byte array
|
||||
byte[] zipContents = baos.toByteArray();
|
||||
|
||||
return WebResponseUtils.boasToWebResponse(
|
||||
return WebResponseUtils.baosToWebResponse(
|
||||
baos, filename + "_extracted-images.zip", MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
|
||||
|
@ -118,7 +118,7 @@ public class PipelineController {
|
||||
}
|
||||
zipOut.close();
|
||||
log.info("Returning zipped file response...");
|
||||
return WebResponseUtils.boasToWebResponse(
|
||||
return WebResponseUtils.baosToWebResponse(
|
||||
baos, "output.zip", MediaType.APPLICATION_OCTET_STREAM);
|
||||
} catch (Exception e) {
|
||||
log.error("Error handling data: ", e);
|
||||
|
@ -205,7 +205,7 @@ public class CertSignController {
|
||||
location,
|
||||
reason,
|
||||
showLogo);
|
||||
return WebResponseUtils.boasToWebResponse(
|
||||
return WebResponseUtils.baosToWebResponse(
|
||||
baos,
|
||||
Filenames.toSimpleFileName(pdf.getOriginalFilename()).replaceFirst("[.][^.]+$", "")
|
||||
+ "_signed.pdf");
|
||||
|
@ -191,4 +191,11 @@ public class OtherWebController {
|
||||
model.addAttribute("currentPage", "auto-rename");
|
||||
return "misc/auto-rename";
|
||||
}
|
||||
|
||||
@GetMapping("/add-attachments")
|
||||
@Hidden
|
||||
public String attachmentsForm(Model model) {
|
||||
model.addAttribute("currentPage", "add-attachments");
|
||||
return "misc/add-attachments";
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,23 @@
|
||||
package stirling.software.SPDF.model.api.misc;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class AddAttachmentRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description = "The image file to be overlaid onto the PDF.",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
format = "binary")
|
||||
private List<MultipartFile> attachments;
|
||||
}
|
@ -0,0 +1,98 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import static stirling.software.common.util.AttachmentUtils.setCatalogViewerPreferences;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.GregorianCalendar;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
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.PageMode;
|
||||
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
|
||||
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class AttachmentService implements AttachmentServiceInterface {
|
||||
|
||||
@Override
|
||||
public PDDocument addAttachment(PDDocument document, List<MultipartFile> attachments)
|
||||
throws IOException {
|
||||
PDDocumentCatalog catalog = document.getDocumentCatalog();
|
||||
PDDocumentNameDictionary documentNames = catalog.getNames();
|
||||
PDEmbeddedFilesNameTreeNode embeddedFilesTree = new PDEmbeddedFilesNameTreeNode();
|
||||
|
||||
if (documentNames != null) {
|
||||
embeddedFilesTree = documentNames.getEmbeddedFiles();
|
||||
} else {
|
||||
documentNames = new PDDocumentNameDictionary(catalog);
|
||||
documentNames.setEmbeddedFiles(embeddedFilesTree);
|
||||
}
|
||||
|
||||
catalog.setNames(documentNames);
|
||||
Map<String, PDComplexFileSpecification> existingNames;
|
||||
|
||||
try {
|
||||
Map<String, PDComplexFileSpecification> originalNames = embeddedFilesTree.getNames();
|
||||
|
||||
if (originalNames == null) {
|
||||
log.debug("No existing embedded files found, creating new names map.");
|
||||
existingNames = new HashMap<>();
|
||||
} else {
|
||||
existingNames = new HashMap<>(originalNames);
|
||||
log.debug("Embedded files: {}", existingNames.keySet());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Could not retrieve existing embedded files", e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
attachments.forEach(
|
||||
attachment -> {
|
||||
String filename = attachment.getOriginalFilename();
|
||||
|
||||
try {
|
||||
PDEmbeddedFile embeddedFile =
|
||||
new PDEmbeddedFile(document, attachment.getInputStream());
|
||||
embeddedFile.setSize((int) attachment.getSize());
|
||||
embeddedFile.setCreationDate(new GregorianCalendar());
|
||||
embeddedFile.setModDate(new GregorianCalendar());
|
||||
String contentType = attachment.getContentType();
|
||||
if (StringUtils.isNotBlank(contentType)) {
|
||||
embeddedFile.setSubtype(contentType);
|
||||
}
|
||||
|
||||
// Create attachments specification and associate embedded attachment with
|
||||
// file
|
||||
PDComplexFileSpecification fileSpecification =
|
||||
new PDComplexFileSpecification();
|
||||
fileSpecification.setFile(filename);
|
||||
fileSpecification.setFileUnicode(filename);
|
||||
fileSpecification.setFileDescription("Embedded attachment: " + filename);
|
||||
fileSpecification.setEmbeddedFile(embeddedFile);
|
||||
fileSpecification.setEmbeddedFileUnicode(embeddedFile);
|
||||
|
||||
existingNames.put(filename, fileSpecification);
|
||||
|
||||
log.info("Added attachment: {} ({} bytes)", filename, attachment.getSize());
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to create embedded file for attachment: {}", filename, e);
|
||||
}
|
||||
});
|
||||
|
||||
embeddedFilesTree.setNames(existingNames);
|
||||
setCatalogViewerPreferences(document, PageMode.USE_ATTACHMENTS);
|
||||
|
||||
return document;
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
public interface AttachmentServiceInterface {
|
||||
|
||||
PDDocument addAttachment(PDDocument document, List<MultipartFile> attachments)
|
||||
throws IOException;
|
||||
}
|
BIN
stirling-pdf/src/main/resources/icc/sRGB2014.icc
Normal file
BIN
stirling-pdf/src/main/resources/icc/sRGB2014.icc
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user