mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-06-23 07:55:07 +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 }}"
|
57
.github/workflows/build.yml
vendored
57
.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/")
|
||||
for dir in "${dirs[@]}"; do
|
||||
if [ ! -d "$dir" ]; then
|
||||
missing_reports+=("$dir")
|
||||
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
|
||||
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,7 +251,6 @@ public class AppConfig {
|
||||
return applicationProperties.getSystem().getDatasource();
|
||||
}
|
||||
|
||||
|
||||
@Bean(name = "runningProOrHigher")
|
||||
@Profile("default")
|
||||
public boolean runningProOrHigher() {
|
||||
@ -273,7 +275,6 @@ public class AppConfig {
|
||||
return "NORMAL";
|
||||
}
|
||||
|
||||
|
||||
@Bean(name = "disablePixel")
|
||||
public boolean disablePixel() {
|
||||
return Boolean.parseBoolean(env.getProperty("DISABLE_PIXEL", "false"));
|
||||
|
@ -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
|
||||
|
@ -1,44 +1,49 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
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;
|
||||
|
||||
@Slf4j
|
||||
public class PDFAttachmentUtils {
|
||||
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) {
|
||||
// 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
|
||||
catalog.setPageMode(pageMode);
|
||||
catalogDict.setName(COSName.PAGE_MODE, pageMode.stringValue());
|
||||
|
||||
// Also set viewer preferences for better attachment viewing experience
|
||||
COSDictionary viewerPrefs = (COSDictionary) catalogDict.getDictionaryObject(COSName.VIEWER_PREFERENCES);
|
||||
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"), pageMode.stringValue());
|
||||
viewerPrefs.setName(
|
||||
COSName.getPDFName("NonFullScreenPageMode"), pageMode.stringValue());
|
||||
|
||||
// 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");
|
||||
log.info(
|
||||
"Set PDF PageMode to UseAttachments to automatically show attachments pane");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Log error but don't fail the entire operation for viewer preferences
|
||||
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;
|
||||
@ -19,10 +21,7 @@ import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import lombok.Data;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary;
|
||||
import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode;
|
||||
@ -36,8 +35,15 @@ import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.api.converters.EmlToPdfRequest;
|
||||
import static stirling.software.common.util.PDFAttachmentUtils.setCatalogViewerPreferences;
|
||||
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
@ -46,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";
|
||||
|
||||
@ -67,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 {
|
||||
@ -87,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
|
||||
@ -98,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) {
|
||||
@ -110,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()) {
|
||||
@ -144,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;
|
||||
@ -162,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");
|
||||
}
|
||||
@ -179,11 +190,14 @@ public class EmlToPdf {
|
||||
&& !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(
|
||||
@ -194,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,
|
||||
@ -215,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");
|
||||
}
|
||||
@ -246,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");
|
||||
|
||||
@ -285,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(
|
||||
@ -303,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");
|
||||
@ -327,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) {
|
||||
@ -343,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);
|
||||
}
|
||||
@ -476,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()) {
|
||||
@ -500,14 +516,17 @@ 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:")
|
||||
boolean hasTo =
|
||||
lowerContent.contains("to:")
|
||||
|| lowerContent.contains("cc:")
|
||||
|| lowerContent.contains("bcc:");
|
||||
boolean hasMimeStructure = lowerContent.contains("multipart/")
|
||||
boolean hasMimeStructure =
|
||||
lowerContent.contains("multipart/")
|
||||
|| lowerContent.contains("text/plain")
|
||||
|| lowerContent.contains("text/html")
|
||||
|| lowerContent.contains("boundary=");
|
||||
@ -633,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;
|
||||
@ -641,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;
|
||||
@ -681,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");
|
||||
@ -743,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
|
||||
@ -784,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);
|
||||
@ -823,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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -840,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);
|
||||
@ -860,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);
|
||||
@ -893,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;
|
||||
@ -905,28 +1022,37 @@ 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 {
|
||||
// For embedded images, always include data regardless of size
|
||||
// to ensure inline display works
|
||||
if (attachment.isEmbedded()) {
|
||||
attachment.setData(attachmentData);
|
||||
attachment.setSizeBytes(attachmentData.length);
|
||||
} else {
|
||||
// Still show attachment info even if too large
|
||||
attachment.setSizeBytes(attachmentData.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Error extracting attachment data: {}", e.getMessage());
|
||||
}
|
||||
@ -960,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");
|
||||
|
||||
@ -971,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> ")
|
||||
@ -982,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()));
|
||||
@ -1011,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");
|
||||
@ -1028,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");
|
||||
@ -1047,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()) {
|
||||
@ -1101,7 +1236,8 @@ 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());
|
||||
|
||||
@ -1145,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('.');
|
||||
@ -1169,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) {
|
||||
@ -1225,7 +1363,8 @@ public class EmlToPdf {
|
||||
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) {
|
||||
@ -1241,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
|
||||
);
|
||||
iconHeight);
|
||||
}
|
||||
|
||||
private static String formatEmailDate(Date date) {
|
||||
@ -1370,13 +1512,73 @@ public class EmlToPdf {
|
||||
}
|
||||
|
||||
try {
|
||||
if (isJakartaMailAvailable()) {
|
||||
// Use Jakarta Mail's MimeUtility for proper MIME decoding
|
||||
Class<?> mimeUtilityClass = Class.forName("jakarta.mail.internet.MimeUtility");
|
||||
Method decodeText = mimeUtilityClass.getMethod("decodeText", String.class);
|
||||
return (String) decodeText.invoke(null, headerValue.trim());
|
||||
} else {
|
||||
// Fallback to basic MIME decoding
|
||||
return decodeMimeHeader(headerValue.trim());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to decode MIME header, using original: {}", headerValue, e);
|
||||
return headerValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isValidJakartaMailPart(Object part) {
|
||||
if (part == null) return false;
|
||||
|
||||
try {
|
||||
// Check if the object implements jakarta.mail.Part interface
|
||||
Class<?> partInterface = Class.forName("jakarta.mail.Part");
|
||||
if (!partInterface.isInstance(part)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional check for MimePart
|
||||
try {
|
||||
Class<?> mimePartInterface = Class.forName("jakarta.mail.internet.MimePart");
|
||||
return mimePartInterface.isInstance(part);
|
||||
} catch (ClassNotFoundException e) {
|
||||
// MimePart not available, but Part is sufficient
|
||||
return true;
|
||||
}
|
||||
} catch (ClassNotFoundException e) {
|
||||
log.debug("Jakarta Mail Part interface not available for validation");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isValidJakartaMailMultipart(Object multipart) {
|
||||
if (multipart == null) return false;
|
||||
|
||||
try {
|
||||
// Check if the object implements jakarta.mail.Multipart interface
|
||||
Class<?> multipartInterface = Class.forName("jakarta.mail.Multipart");
|
||||
if (!multipartInterface.isInstance(multipart)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional check for MimeMultipart
|
||||
try {
|
||||
Class<?> mimeMultipartClass = Class.forName("jakarta.mail.internet.MimeMultipart");
|
||||
if (mimeMultipartClass.isInstance(multipart)) {
|
||||
log.debug("Found MimeMultipart instance for enhanced processing");
|
||||
return true;
|
||||
}
|
||||
} catch (ClassNotFoundException e) {
|
||||
log.debug("MimeMultipart not available, using base Multipart interface");
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (ClassNotFoundException e) {
|
||||
log.debug("Jakarta Mail Multipart interface not available for validation");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class EmailContent {
|
||||
private String subject;
|
||||
@ -1421,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;
|
||||
@ -1438,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;
|
||||
@ -1466,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();
|
||||
|
||||
@ -1476,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(
|
||||
MarkerPosition position =
|
||||
new MarkerPosition(
|
||||
currentPageIndex,
|
||||
textPosition.getXDirAdj(),
|
||||
textPosition.getYDirAdj(),
|
||||
paperclipEmoji
|
||||
);
|
||||
attachmentMarker);
|
||||
positions.add(position);
|
||||
}
|
||||
}
|
||||
@ -1512,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")
|
||||
|
@ -44,8 +44,7 @@ public class WebResponseUtils {
|
||||
headers.setContentType(mediaType);
|
||||
headers.setContentLength(bytes.length);
|
||||
String encodedDocName =
|
||||
URLEncoder.encode(docName, StandardCharsets.UTF_8)
|
||||
.replaceAll("\\+", "%20");
|
||||
URLEncoder.encode(docName, StandardCharsets.UTF_8).replaceAll("\\+", "%20");
|
||||
headers.setContentDispositionFormData("attachment", encodedDocName);
|
||||
return new ResponseEntity<>(bytes, headers, HttpStatus.OK);
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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) {
|
||||
if (user.getUsername().equalsIgnoreCase(Role.INTERNAL_API_USER.getRoleId())) {
|
||||
user.setTeam(internalTeam);
|
||||
} else {
|
||||
user.setTeam(defaultTeam);
|
||||
}
|
||||
}
|
||||
|
||||
userService.saveAll(usersWithoutTeam); // batch save
|
||||
if (usersWithoutTeam != null && !usersWithoutTeam.isEmpty()) {
|
||||
log.info(
|
||||
"Assigned {} user(s) without a team to the default team.", usersWithoutTeam.size());
|
||||
"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());
|
||||
}
|
||||
|
@ -239,7 +239,8 @@ public class AccountWebController {
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@ -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,9 +355,15 @@ 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))
|
||||
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);
|
||||
|
||||
|
@ -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,34 +73,35 @@ 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)
|
||||
Team team =
|
||||
teamRepository
|
||||
.findById(teamId)
|
||||
.orElseThrow(() -> new RuntimeException("Team not found"));
|
||||
|
||||
// Prevent adding users to the Internal team
|
||||
@ -109,11 +110,14 @@ public class TeamController {
|
||||
}
|
||||
|
||||
// Find the user
|
||||
User user = userRepository.findById(userId)
|
||||
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");
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
@ -250,15 +251,18 @@ public class UserController {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -316,12 +320,15 @@ public class UserController {
|
||||
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);
|
||||
|
@ -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,12 +37,13 @@ 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()
|
||||
List<TeamWithUserCountDTO> teamsWithCounts =
|
||||
allTeamsWithCounts.stream()
|
||||
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME))
|
||||
.toList();
|
||||
|
||||
@ -55,6 +58,27 @@ 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);
|
||||
@ -64,9 +88,12 @@ public class TeamWebController {
|
||||
|
||||
@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)
|
||||
Team team =
|
||||
teamRepository
|
||||
.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("Team not found"));
|
||||
|
||||
// Prevent access to Internal team
|
||||
@ -80,9 +107,18 @@ public class TeamWebController {
|
||||
// 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)))
|
||||
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
|
||||
@ -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);
|
||||
|
@ -30,7 +30,8 @@ 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,11 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
|
||||
<head>
|
||||
<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>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body>
|
||||
<th:block th:insert="~{fragments/common :: game}"></th:block>
|
||||
<div id="page-container">
|
||||
<div id="content-wrap">
|
||||
@ -25,11 +27,16 @@
|
||||
<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-label" th:text="#{team.totalMembers}">Total Members:</div>
|
||||
<div class="data-stat-value" th:text="${teamUsers.size()}">1</div>
|
||||
</div>
|
||||
</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>
|
||||
@ -37,17 +44,18 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="data-section-title">Members</div>
|
||||
<div class="data-section-title" th:text="#{team.members}">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>
|
||||
<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>
|
||||
@ -55,15 +63,17 @@
|
||||
<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
|
||||
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>
|
||||
Enabled
|
||||
<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>
|
||||
Disabled
|
||||
<span th:text="#{team.disabled}">Disabled</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@ -74,7 +84,7 @@
|
||||
<!-- 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>
|
||||
<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>
|
||||
@ -123,7 +133,7 @@
|
||||
warningDiv.style.display = 'block';
|
||||
|
||||
// Add confirmation to submit button
|
||||
submitButton.onclick = function(e) {
|
||||
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);
|
||||
@ -159,9 +169,7 @@
|
||||
<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}"
|
||||
<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
|
||||
@ -192,5 +200,6 @@
|
||||
|
||||
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||
</div>
|
||||
</body>
|
||||
</body>
|
||||
|
||||
</html>
|
@ -1,9 +1,14 @@
|
||||
<!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">
|
||||
@ -24,16 +29,32 @@
|
||||
<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"
|
||||
<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}">
|
||||
@ -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()">
|
||||
<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' : ''"
|
||||
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>
|
||||
@ -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>
|
@ -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"
|
||||
|
@ -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");
|
||||
@ -267,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");
|
||||
|
@ -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,20 +104,121 @@ 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);
|
||||
|
||||
// Branch conversion based on desired output PDF/A format
|
||||
if ("pdfa".equals(outputFormat)) {
|
||||
preProcessedFile = tempInputFile.toFile();
|
||||
} else {
|
||||
pdfaPart = 1;
|
||||
preProcessedFile = preProcessHighlights(tempInputFile.toFile());
|
||||
}
|
||||
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);
|
||||
|
||||
String outputFilename = baseFileName + "_PDFA.pdf";
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
fileBytes, outputFilename, MediaType.APPLICATION_PDF);
|
||||
|
||||
} finally {
|
||||
// Clean up temporary files
|
||||
if (tempInputFile != null) {
|
||||
Files.deleteIfExists(tempInputFile);
|
||||
}
|
||||
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
|
||||
tempOutputDir = Files.createTempDirectory("output_");
|
||||
Path tempOutputDir = Files.createTempDirectory("output_");
|
||||
|
||||
// Determine PDF/A filter based on requested format
|
||||
String pdfFilter =
|
||||
"pdfa".equals(outputFormat)
|
||||
pdfaPart == 2
|
||||
? "pdf:writer_pdf_Export:{\"SelectPdfVersion\":{\"type\":\"long\",\"value\":\"2\"}}"
|
||||
: "pdf:writer_pdf_Export:{\"SelectPdfVersion\":{\"type\":\"long\",\"value\":\"1\"}}";
|
||||
|
||||
@ -103,24 +248,454 @@ public class ConvertPDFToPDFA {
|
||||
File[] outputFiles = tempOutputDir.toFile().listFiles();
|
||||
if (outputFiles == null || outputFiles.length != 1) {
|
||||
throw new RuntimeException(
|
||||
"Expected exactly one output file but found "
|
||||
"Expected one output PDF, found "
|
||||
+ (outputFiles == null ? "none" : outputFiles.length));
|
||||
}
|
||||
|
||||
fileBytes = FileUtils.readFileToByteArray(outputFiles[0]);
|
||||
String outputFilename = baseFileName + "_PDFA.pdf";
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
fileBytes, outputFilename, MediaType.APPLICATION_PDF);
|
||||
|
||||
} finally {
|
||||
// Clean up temporary files
|
||||
if (tempInputFile != null) {
|
||||
Files.deleteIfExists(tempInputFile);
|
||||
return outputFiles[0].toPath();
|
||||
}
|
||||
if (tempOutputDir != null) {
|
||||
FileUtils.deleteDirectory(tempOutputDir.toFile());
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
@ -1,100 +0,0 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.*;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
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.service.PDFAttachmentServiceInterface;
|
||||
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 AttachmentsController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
private final PDFAttachmentServiceInterface pdfAttachmentService;
|
||||
|
||||
@SuppressWarnings("DataFlowIssue")
|
||||
@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(
|
||||
@RequestParam("fileInput") MultipartFile pdfFile,
|
||||
@RequestParam("attachments") List<MultipartFile> attachments)
|
||||
throws IOException {
|
||||
|
||||
// Load the PDF document
|
||||
PDDocument document = pdfDocumentFactory.load(pdfFile, false);
|
||||
|
||||
// Get or create the document catalog
|
||||
PDDocumentCatalog catalog = document.getDocumentCatalog();
|
||||
|
||||
// Create embedded files name tree if it doesn't exist
|
||||
PDDocumentNameDictionary documentNames = catalog.getNames();
|
||||
PDEmbeddedFilesNameTreeNode embeddedFilesTree = new PDEmbeddedFilesNameTreeNode();
|
||||
|
||||
if (documentNames != null) {
|
||||
embeddedFilesTree = documentNames.getEmbeddedFiles();
|
||||
} else {
|
||||
documentNames = new PDDocumentNameDictionary(catalog);
|
||||
documentNames.setEmbeddedFiles(embeddedFilesTree);
|
||||
}
|
||||
|
||||
// Add attachments
|
||||
catalog.setNames(documentNames);
|
||||
byte[] output =
|
||||
pdfAttachmentService.addAttachment(document, embeddedFilesTree, attachments);
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
output,
|
||||
Filenames.toSimpleFileName(pdfFile.getOriginalFilename())
|
||||
.replaceFirst("[.][^.]+$", "")
|
||||
+ "_with_attachments.pdf");
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/remove-attachments")
|
||||
@Operation(
|
||||
summary = "Remove attachments from PDF",
|
||||
description =
|
||||
"This endpoint removes all embedded files (attachments) from a PDF. Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> removeAttachments(
|
||||
@RequestParam("fileInput") MultipartFile pdfFile) throws IOException {
|
||||
|
||||
// Load the PDF document and document catalog
|
||||
PDDocument document = pdfDocumentFactory.load(pdfFile);
|
||||
PDDocumentCatalog catalog = document.getDocumentCatalog();
|
||||
|
||||
// Remove embedded files
|
||||
if (catalog.getNames() != null) {
|
||||
catalog.getNames().setEmbeddedFiles(null);
|
||||
}
|
||||
|
||||
// Reset PageMode to UseNone (default)
|
||||
catalog.setPageMode(PageMode.USE_NONE);
|
||||
|
||||
// Return the modified PDF
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
Filenames.toSimpleFileName(pdfFile.getOriginalFilename())
|
||||
.replaceFirst("[.][^.]+$", "")
|
||||
+ "_attachments_removed.pdf");
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import static stirling.software.common.util.AttachmentUtils.setCatalogViewerPreferences;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.GregorianCalendar;
|
||||
import java.util.HashMap;
|
||||
@ -9,48 +10,53 @@ 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.apache.pdfbox.pdmodel.encryption.AccessPermission;
|
||||
import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.util.PDFAttachmentUtils;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class PDFAttachmentService implements PDFAttachmentServiceInterface {
|
||||
public class AttachmentService implements AttachmentServiceInterface {
|
||||
|
||||
@Override
|
||||
public byte[] addAttachment(
|
||||
PDDocument document,
|
||||
PDEmbeddedFilesNameTreeNode embeddedFilesTree,
|
||||
List<MultipartFile> attachments)
|
||||
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 {
|
||||
existingNames = embeddedFilesTree.getNames();
|
||||
Map<String, PDComplexFileSpecification> originalNames = embeddedFilesTree.getNames();
|
||||
|
||||
if (existingNames == null) {
|
||||
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;
|
||||
}
|
||||
|
||||
grantAccessPermissions(document);
|
||||
final Map<String, PDComplexFileSpecification> existingEmbeddedFiles = existingNames;
|
||||
|
||||
attachments.forEach(
|
||||
attachment -> {
|
||||
String filename = attachment.getOriginalFilename();
|
||||
@ -73,12 +79,10 @@ public class PDFAttachmentService implements PDFAttachmentServiceInterface {
|
||||
fileSpecification.setFile(filename);
|
||||
fileSpecification.setFileUnicode(filename);
|
||||
fileSpecification.setFileDescription("Embedded attachment: " + filename);
|
||||
embeddedFile.setFile(fileSpecification);
|
||||
fileSpecification.setEmbeddedFile(embeddedFile);
|
||||
fileSpecification.setEmbeddedFileUnicode(embeddedFile);
|
||||
|
||||
// Add to the existing files map
|
||||
existingEmbeddedFiles.put(filename, fileSpecification);
|
||||
existingNames.put(filename, fileSpecification);
|
||||
|
||||
log.info("Added attachment: {} ({} bytes)", filename, attachment.getSize());
|
||||
} catch (IOException e) {
|
||||
@ -87,39 +91,8 @@ public class PDFAttachmentService implements PDFAttachmentServiceInterface {
|
||||
});
|
||||
|
||||
embeddedFilesTree.setNames(existingNames);
|
||||
PDFAttachmentUtils.setCatalogViewerPreferences(document, PageMode.USE_ATTACHMENTS);
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
document.save(output);
|
||||
setCatalogViewerPreferences(document, PageMode.USE_ATTACHMENTS);
|
||||
|
||||
return output.toByteArray();
|
||||
}
|
||||
|
||||
private void grantAccessPermissions(PDDocument document) {
|
||||
try {
|
||||
AccessPermission currentPermissions = document.getCurrentAccessPermission();
|
||||
|
||||
currentPermissions.setCanAssembleDocument(true);
|
||||
currentPermissions.setCanFillInForm(currentPermissions.canFillInForm());
|
||||
currentPermissions.setCanModify(true);
|
||||
currentPermissions.setCanPrint(true);
|
||||
currentPermissions.setCanPrintFaithful(true);
|
||||
|
||||
// Ensure these permissions are enabled for embedded file access
|
||||
currentPermissions.setCanExtractContent(true);
|
||||
currentPermissions.setCanExtractForAccessibility(true);
|
||||
currentPermissions.setCanModifyAnnotations(true);
|
||||
|
||||
var protectionPolicy = new StandardProtectionPolicy(null, null, currentPermissions);
|
||||
|
||||
if (!document.isAllSecurityToBeRemoved()) {
|
||||
document.setAllSecurityToBeRemoved(true);
|
||||
}
|
||||
|
||||
document.protect(protectionPolicy);
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
document.save(output);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
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;
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
public interface PDFAttachmentServiceInterface {
|
||||
|
||||
byte[] addAttachment(
|
||||
PDDocument document,
|
||||
PDEmbeddedFilesNameTreeNode efTree,
|
||||
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.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
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