mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
71
Commits
langUpdates
...
v2.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7b713ac80 | ||
|
|
d4d4538630 | ||
|
|
d2677e64dd | ||
|
|
0ae108ca11 | ||
|
|
5236c1ccf6 | ||
|
|
72389f5872 | ||
|
|
a284dbfc15 | ||
|
|
ff078b9d88 | ||
|
|
18be8f4692 | ||
|
|
dd09f7b7cf | ||
|
|
8394b71014 | ||
|
|
b4dd7afe03 | ||
|
|
ef7a3fa933 | ||
|
|
772ed6f52b | ||
|
|
4a1039fa97 | ||
|
|
c158664d2c | ||
|
|
d9caa3482c | ||
|
|
c44b7e229c | ||
|
|
f38e3d2b2c | ||
|
|
b6e675fab3 | ||
|
|
91bf9abbaa | ||
|
|
faf0a3555e | ||
|
|
fbc5d91c2c | ||
|
|
0c96133544 | ||
|
|
991d158cb8 | ||
|
|
02f9785212 | ||
|
|
3fd0398648 | ||
|
|
70fc6348f3 | ||
|
|
8f1af5f967 | ||
|
|
16cd453870 | ||
|
|
915a33bbc2 | ||
|
|
42f9384ece | ||
|
|
804f1d8975 | ||
|
|
182eb504de | ||
|
|
98fa5dfcc1 | ||
|
|
429520d3f9 | ||
|
|
fbfe1da7da | ||
|
|
c047bdda8c | ||
|
|
a33e22559b | ||
|
|
1318604f32 | ||
|
|
43a5f72b01 | ||
|
|
d97820c232 | ||
|
|
03cd7c1456 | ||
|
|
b1b37366e8 | ||
|
|
5cd9377bba | ||
|
|
a3e1cc8393 | ||
|
|
1810a12cf3 | ||
|
|
c3cd8c48b5 | ||
|
|
2502a8def0 | ||
|
|
54cd804319 | ||
|
|
1b0a1e938e | ||
|
|
69b035c6e1 | ||
|
|
8164c56d33 | ||
|
|
098f2badec | ||
|
|
d2a4054120 | ||
|
|
7baf503d60 | ||
|
|
d9622d0fac | ||
|
|
ddba5a3eab | ||
|
|
0bfb90cfc1 | ||
|
|
c454a72f08 | ||
|
|
34fd328444 | ||
|
|
738dd0645d | ||
|
|
e997f0d4ff | ||
|
|
e137436656 | ||
|
|
1649b9bffb | ||
|
|
c990ab3216 | ||
|
|
e6d3f20c36 | ||
|
|
79d5bc942d | ||
|
|
d652fc1d1c | ||
|
|
22a1412242 | ||
|
|
0c3c0765a3 |
+5
-8
@@ -2,20 +2,17 @@
|
||||
* @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
|
||||
|
||||
# Backend
|
||||
/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
|
||||
|
||||
#V1 frontend
|
||||
/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
|
||||
/app/core/src/main/resources/templates/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
|
||||
/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh @balazs-szucs
|
||||
|
||||
#V2 frontend
|
||||
/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle
|
||||
/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @balazs-szucs
|
||||
/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87 @balazs-szucs
|
||||
|
||||
#V2 docker
|
||||
/docker/backend/** @Frooodle @Ludy87 @DarioGii @Ludy87
|
||||
/docker/backend/** @Frooodle @Ludy87 @DarioGii
|
||||
/docker/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
|
||||
/docker/compose/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
|
||||
|
||||
|
||||
#GHA (All users)
|
||||
/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
|
||||
/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87 @balazs-szucs
|
||||
|
||||
@@ -2,12 +2,9 @@ build: &build
|
||||
- build.gradle
|
||||
- app/(common|core|proprietary)/build.gradle
|
||||
|
||||
app: &app
|
||||
- app/(common|core|proprietary)/src/main/java/**
|
||||
|
||||
openapi: &openapi
|
||||
- *build
|
||||
- *app
|
||||
- app/(common|core|proprietary)/src/main/java/**
|
||||
|
||||
docker: &docker
|
||||
- Dockerfile
|
||||
@@ -34,9 +31,30 @@ project: &project
|
||||
- settings.gradle
|
||||
- frontend/**
|
||||
- docker/**
|
||||
- scripts/RestartHelper.java
|
||||
|
||||
frontend: &frontend
|
||||
- frontend/**
|
||||
- .github/workflows/testdriver.yml
|
||||
- testing/**
|
||||
- docker/**
|
||||
- scripts/translations/*.py
|
||||
- scripts/build-tauri-jlink.bat
|
||||
- scripts/build-tauri-jlink.sh
|
||||
- scripts/convert_cff_to_ttf.py
|
||||
- scripts/harvest_type3_fonts.py
|
||||
- scripts/ignore_translation.toml
|
||||
- scripts/index_type3_catalogue.py
|
||||
- scripts/summarize_type3_signatures.py
|
||||
- scripts/type3_to_cff.py
|
||||
- scripts/update_type3_library.py
|
||||
|
||||
licenses-frontend: &licenses-frontend
|
||||
- ".github/workflows/frontend-backend-licenses-update.yml"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/scripts/generate-licenses.js"
|
||||
|
||||
licenses-backend: &licenses-backend
|
||||
- ".github/workflows/frontend-backend-licenses-update.yml"
|
||||
- *build
|
||||
@@ -1 +1 @@
|
||||
allow-ghsas: GHSA-wrw7-89jp-8q8g
|
||||
allow-ghsas: GHSA-wrw7-89jp-8q8g
|
||||
|
||||
+49
-2
@@ -6,18 +6,65 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gradle" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
directories:
|
||||
- "/" # Location of package manifests
|
||||
- "/app/common"
|
||||
- "/app/core"
|
||||
- "/app/proprietary"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/" # Location of Dockerfile
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /devTools
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: /docker/backend
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: /docker/embedded
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: /docker/frontend
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /frontend
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: cargo
|
||||
directory: /frontend/src-tauri
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: pip
|
||||
directory: /testing/cucumber
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
@@ -46,26 +46,34 @@ labels:
|
||||
- label: 'API'
|
||||
title: '.*openapi.*|.*swagger.*|.*api.*'
|
||||
|
||||
- label: 'v2'
|
||||
base-branch: 'V2'
|
||||
- label: 'v3'
|
||||
base-branch: 'V3'
|
||||
|
||||
- label: 'Translation'
|
||||
files:
|
||||
- 'app/core/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}.properties'
|
||||
- 'frontend/public/locales/[a-zA-Z]{2}-[a-zA-Z\-]{2,7}/translation.toml'
|
||||
- 'scripts/ignore_translation.toml'
|
||||
- 'app/core/src/main/resources/templates/fragments/languages.html'
|
||||
- '.github/scripts/check_language_properties.py'
|
||||
- 'scripts/remove_translation_keys.sh'
|
||||
- 'scripts/replace_translation_line.sh'
|
||||
- 'scripts/translations/.*'
|
||||
- '.github/scripts/check_language_toml.py'
|
||||
- 'scripts/counter_translation_v3.py'
|
||||
|
||||
- label: 'Front End'
|
||||
files:
|
||||
- 'app/core/src/main/resources/templates/.*'
|
||||
- 'app/proprietary/src/main/resources/templates/.*'
|
||||
- 'app/core/src/main/resources/static/.*'
|
||||
- 'app/proprietary/src/main/resources/static/.*'
|
||||
- 'app/core/src/main/java/stirling/software/SPDF/controller/web/.*'
|
||||
- 'app/core/src/main/java/stirling/software/SPDF/UI/.*'
|
||||
- 'app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/.*'
|
||||
- 'frontend/**'
|
||||
- 'frontend/.*'
|
||||
- 'frontend/**/.*'
|
||||
|
||||
- label: 'Tauri'
|
||||
files:
|
||||
- 'frontend/src-tauri/**'
|
||||
- 'frontend/src-tauri/.*'
|
||||
|
||||
- label: 'Java'
|
||||
files:
|
||||
|
||||
@@ -84,6 +84,9 @@
|
||||
- name: "v2"
|
||||
color: "FFFF00"
|
||||
description: "Issues or pull requests related to the v2 branch"
|
||||
- name: "v3"
|
||||
color: "FFA500"
|
||||
description: "Issues or pull requests related to the v3 branch"
|
||||
- name: "wontfix"
|
||||
description: "This will not be worked on"
|
||||
color: "FFFFFF"
|
||||
@@ -187,3 +190,14 @@
|
||||
- name: "break-change"
|
||||
color: "FF0000"
|
||||
description: "This PR introduces a breaking API change."
|
||||
- name: "Rust"
|
||||
color: "DEA584"
|
||||
description: "Pull requests that update Rust code"
|
||||
from_name: "rust"
|
||||
- name: "Tauri"
|
||||
color: "24C8FF"
|
||||
description: "Pull requests that update Tauri code"
|
||||
from_name: "tauri"
|
||||
- name: "license-review-required"
|
||||
color: "EDEDED"
|
||||
description: "This PR requires a license review"
|
||||
|
||||
@@ -14,12 +14,10 @@ Usage:
|
||||
# Sample for Windows:
|
||||
# python .github/scripts/check_language_toml.py --reference-file frontend/public/locales/en-GB/translation.toml --branch "" --files frontend/public/locales/de-DE/translation.toml frontend/public/locales/fr-FR/translation.toml
|
||||
|
||||
import copy
|
||||
import glob
|
||||
import os
|
||||
import argparse
|
||||
import re
|
||||
import json
|
||||
import tomllib # Python 3.11+ (stdlib)
|
||||
import tomli_w # For writing TOML files
|
||||
|
||||
@@ -38,7 +36,7 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
|
||||
duplicates = []
|
||||
|
||||
# Load TOML file
|
||||
with open(file_path, 'rb') as file:
|
||||
with open(file_path, "rb") as file:
|
||||
data = tomllib.load(file)
|
||||
|
||||
def process_dict(obj, current_prefix=""):
|
||||
@@ -67,7 +65,7 @@ def parse_toml_file(file_path):
|
||||
:param file_path: Path to the TOML file.
|
||||
:return: Dictionary with flattened keys.
|
||||
"""
|
||||
with open(file_path, 'rb') as file:
|
||||
with open(file_path, "rb") as file:
|
||||
data = tomllib.load(file)
|
||||
|
||||
def flatten_dict(d, parent_key="", sep="."):
|
||||
@@ -193,13 +191,13 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
basename_current_file = os.path.basename(os.path.join(branch, file_normpath))
|
||||
locale_dir = os.path.basename(os.path.dirname(file_normpath))
|
||||
|
||||
if (
|
||||
basename_current_file == basename_reference_file
|
||||
and locale_dir == "en-GB"
|
||||
):
|
||||
if basename_current_file == basename_reference_file and locale_dir == "en-GB":
|
||||
continue
|
||||
|
||||
if not file_normpath.endswith(".toml") or basename_current_file != "translation.toml":
|
||||
if (
|
||||
not file_normpath.endswith(".toml")
|
||||
or basename_current_file != "translation.toml"
|
||||
):
|
||||
continue
|
||||
|
||||
only_reference_file = False
|
||||
@@ -288,7 +286,9 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Find missing keys in TOML translation files")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Find missing keys in TOML translation files"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--actor",
|
||||
required=False,
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
#
|
||||
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' --strip-extras '.github\scripts\requirements_pre_commit.in'
|
||||
#
|
||||
cfgv==3.4.0 \
|
||||
--hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \
|
||||
--hash=sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560
|
||||
cfgv==3.5.0 \
|
||||
--hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \
|
||||
--hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132
|
||||
# via pre-commit
|
||||
distlib==0.4.0 \
|
||||
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
|
||||
@@ -28,9 +28,9 @@ platformdirs==4.5.0 \
|
||||
--hash=sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312 \
|
||||
--hash=sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3
|
||||
# via virtualenv
|
||||
pre-commit==4.3.0 \
|
||||
--hash=sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8 \
|
||||
--hash=sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16
|
||||
pre-commit==4.5.0 \
|
||||
--hash=sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1 \
|
||||
--hash=sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b
|
||||
# via -r .github/scripts/requirements_pre_commit.in
|
||||
pyyaml==6.0.3 \
|
||||
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
tomlkit
|
||||
tomli-w
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
#
|
||||
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_sync_readme.txt' --strip-extras '.github\scripts\requirements_sync_readme.in'
|
||||
#
|
||||
tomli-w==1.2.0 \
|
||||
--hash=sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90 \
|
||||
--hash=sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021
|
||||
# via -r .github/scripts/requirements_sync_readme.in
|
||||
tomlkit==0.13.3 \
|
||||
--hash=sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1 \
|
||||
--hash=sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
- name: Resolve PR info
|
||||
id: resolve
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
@@ -128,13 +128,13 @@ jobs:
|
||||
|
||||
- name: Add deployment started comment
|
||||
id: deployment-started
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = ${{ needs.check-pr.outputs.pr_number }};
|
||||
|
||||
|
||||
// Delete previous V2 deployment comments to avoid clutter
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
@@ -157,7 +157,7 @@ jobs:
|
||||
comment_id: comment.id
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Create new deployment started comment
|
||||
const { data: newComment } = await github.rest.issues.createComment({
|
||||
owner,
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
repository: ${{ needs.check-pr.outputs.pr_repository }}
|
||||
ref: ${{ needs.check-pr.outputs.pr_ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0 # Fetch full history for commit hash detection
|
||||
fetch-depth: 0 # Fetch full history for commit hash detection
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
@@ -204,20 +204,20 @@ jobs:
|
||||
if [ -z "$BACKEND_HASH" ]; then
|
||||
BACKEND_HASH="no-backend-changes"
|
||||
fi
|
||||
|
||||
|
||||
echo "Frontend hash: $FRONTEND_HASH"
|
||||
echo "Backend hash: $BACKEND_HASH"
|
||||
|
||||
|
||||
echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
|
||||
echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
# Short hashes for tags
|
||||
if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
|
||||
echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
|
||||
if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
|
||||
echo "backend_short=no-backend" >> $GITHUB_OUTPUT
|
||||
else
|
||||
@@ -271,7 +271,7 @@ jobs:
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Deploy V2 to VPS
|
||||
@@ -280,7 +280,7 @@ jobs:
|
||||
# Use same port strategy as regular PRs - just the PR number
|
||||
V2_PORT=${{ needs.check-pr.outputs.pr_number }}
|
||||
BACKEND_PORT=$((V2_PORT + 10000)) # Backend on higher port to avoid conflicts
|
||||
|
||||
|
||||
# Create docker-compose for V2 with separate frontend and backend
|
||||
cat > docker-compose.yml << EOF
|
||||
version: '3.3'
|
||||
@@ -316,16 +316,16 @@ jobs:
|
||||
ports:
|
||||
- "${V2_PORT}:80" # Frontend port (same as regular PRs)
|
||||
environment:
|
||||
VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:${BACKEND_PORT}"
|
||||
VITE_API_BASE_URL: "http://${{ secrets.NEW_VPS_HOST }}:${BACKEND_PORT}"
|
||||
depends_on:
|
||||
- stirling-pdf-v2-backend
|
||||
restart: on-failure:5
|
||||
EOF
|
||||
|
||||
# Deploy to VPS
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose-v2.yml
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose-v2.yml
|
||||
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH
|
||||
# Create V2 PR-specific directories
|
||||
mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs}
|
||||
|
||||
@@ -352,14 +352,14 @@ jobs:
|
||||
|
||||
- name: Post V2 deployment URL to PR
|
||||
if: success()
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = ${{ needs.check-pr.outputs.pr_number }};
|
||||
const v2Port = ${{ steps.deploy.outputs.v2_port }};
|
||||
|
||||
|
||||
// Delete the "deploying..." comment since we're posting the final result
|
||||
const deploymentStartedId = ${{ steps.deployment-started.outputs.result }};
|
||||
if (deploymentStartedId) {
|
||||
@@ -374,8 +374,8 @@ jobs:
|
||||
console.log(`Could not delete deployment started comment: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${v2Port}`;
|
||||
|
||||
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`;
|
||||
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
|
||||
|
||||
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
|
||||
@@ -419,25 +419,25 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Clean up V2 deployment comments
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = ${{ github.event.pull_request.number }};
|
||||
|
||||
|
||||
// Find and delete V2 deployment comments
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber
|
||||
});
|
||||
|
||||
|
||||
const v2Comments = comments.filter(c =>
|
||||
c.body?.includes("## 🚀 V2 Auto-Deployment Complete!") &&
|
||||
c.user?.type === "Bot"
|
||||
);
|
||||
|
||||
|
||||
for (const comment of v2Comments) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
@@ -450,12 +450,12 @@ jobs:
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Cleanup V2 deployment
|
||||
run: |
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << 'ENDSSH'
|
||||
if [ -d "/stirling/V2-PR-${{ github.event.pull_request.number }}" ]; then
|
||||
echo "Found V2 PR directory, proceeding with cleanup..."
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
- name: Set up JDK
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
@@ -190,7 +190,7 @@ jobs:
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Deploy to VPS
|
||||
@@ -252,9 +252,9 @@ jobs:
|
||||
EOF
|
||||
|
||||
# Then copy the file and execute commands
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose.yml
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose.yml
|
||||
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH
|
||||
# Create PR-specific directories
|
||||
mkdir -p /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/{data,config,logs}
|
||||
|
||||
@@ -336,7 +336,7 @@ jobs:
|
||||
const prNumber = ${{ needs.check-comment.outputs.pr_number }};
|
||||
const securityStatus = process.env.security_status || "Security Disabled";
|
||||
|
||||
const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${prNumber}`;
|
||||
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${prNumber}`;
|
||||
const commentBody = `## 🚀 PR Test Deployment\n\n` +
|
||||
`Your PR has been deployed for testing!\n\n` +
|
||||
`🔗 **Test URL:** [${deploymentUrl}](${deploymentUrl})\n` +
|
||||
|
||||
@@ -8,7 +8,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
SERVER_IP: ${{ secrets.VPS_IP }} # Add this to your GitHub secrets
|
||||
SERVER_IP: ${{ secrets.NEW_VPS_IP }} # Add this to your GitHub secrets
|
||||
CLEANUP_PERFORMED: "false" # Add flag to track if cleanup occurred
|
||||
|
||||
jobs:
|
||||
@@ -96,19 +96,18 @@ jobs:
|
||||
const hasDeploymentComment = deploymentComments.length > 0;
|
||||
core.setOutput('present', (hasLabel || hasDeploymentComment) ? 'true' : 'false');
|
||||
|
||||
|
||||
- name: Set up SSH
|
||||
if: steps.remove-label-comment.outputs.present == 'true'
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Cleanup PR deployment
|
||||
if: steps.remove-label-comment.outputs.present == 'true'
|
||||
id: cleanup
|
||||
run: |
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << 'ENDSSH'
|
||||
if [ -d "/stirling/PR-${{ github.event.pull_request.number }}" ]; then
|
||||
echo "Found PR directory, proceeding with cleanup..."
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
types: [opened, edited]
|
||||
branches: [main]
|
||||
|
||||
permissions: # required for secure-repo hardening
|
||||
permissions: # required for secure-repo hardening
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
- name: AI PR Title Analysis
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
id: ai-title-analysis
|
||||
uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1.2.8
|
||||
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt-file: ".github/config/system-prompt.txt"
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
types: [opened, synchronize]
|
||||
branches:
|
||||
- main
|
||||
- V2
|
||||
- V3
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
+25
-27
@@ -2,7 +2,7 @@ name: Build and Test Workflow
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["main", "V2", "V2-gha"]
|
||||
branches: ["main"]
|
||||
workflow_dispatch:
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
@@ -27,15 +27,16 @@ jobs:
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
build: ${{ steps.changes.outputs.build }}
|
||||
app: ${{ steps.changes.outputs.app }}
|
||||
project: ${{ steps.changes.outputs.project }}
|
||||
openapi: ${{ steps.changes.outputs.openapi }}
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
@@ -66,14 +67,12 @@ jobs:
|
||||
with:
|
||||
java-version: ${{ matrix.jdk-version }}
|
||||
distribution: "temurin"
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
|
||||
run: ./gradlew clean build -PnoSpotless
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.spring-security }}
|
||||
|
||||
- name: Check Test Reports Exist
|
||||
if: always()
|
||||
run: |
|
||||
@@ -91,6 +90,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Upload Test Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
@@ -117,18 +117,17 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 17
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Generate OpenAPI documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
|
||||
- name: Upload OpenAPI Documentation
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
@@ -136,6 +135,8 @@ jobs:
|
||||
path: ./SwaggerDoc.json
|
||||
|
||||
frontend-validation:
|
||||
if: needs.files-changed.outputs.frontend == 'true'
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -147,8 +148,8 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install frontend dependencies
|
||||
run: cd frontend && npm ci
|
||||
@@ -176,12 +177,14 @@ jobs:
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up JDK 17
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: check the licenses for compatibility
|
||||
@@ -223,10 +226,10 @@ jobs:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up Java 17
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
@@ -238,10 +241,10 @@ jobs:
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: 'pip' # caching pip dependencies
|
||||
cache: "pip" # caching pip dependencies
|
||||
cache-dependency-path: ./testing/cucumber/requirements.txt
|
||||
|
||||
- name: Pip requirements
|
||||
@@ -285,17 +288,12 @@ jobs:
|
||||
docker system prune -af || true
|
||||
echo "Disk space after cleanup:" && df -h
|
||||
|
||||
- name: Set up JDK 17
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Build application
|
||||
run: ./gradlew clean build
|
||||
env:
|
||||
|
||||
@@ -194,13 +194,12 @@ jobs:
|
||||
core.exportVariable("REFERENCE_FILE", referenceFilePath);
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
pip install tomli-w
|
||||
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
|
||||
|
||||
- name: Run Python script to check files
|
||||
id: run-check
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
#disable for now
|
||||
#on:
|
||||
# push:
|
||||
# branches: ["main"]
|
||||
# pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
# branches: ["main"]
|
||||
# schedule:
|
||||
# - cron: "0 0 * * 1"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ["java"]
|
||||
# CodeQL supports [ $supported-codeql-languages ]
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -26,4 +26,4 @@ jobs:
|
||||
- name: "Dependency Review"
|
||||
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||
with:
|
||||
config-file: './.github/config/dependency-review-config.yml'
|
||||
config-file: "./.github/config/dependency-review-config.yml"
|
||||
|
||||
@@ -23,10 +23,10 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Get commit hashes for frontend and backend
|
||||
id: commit-hashes
|
||||
@@ -36,26 +36,26 @@ jobs:
|
||||
if [ -z "$FRONTEND_HASH" ]; then
|
||||
FRONTEND_HASH="no-frontend-changes"
|
||||
fi
|
||||
|
||||
|
||||
# Get last commit that touched backend code, docker/backend, or docker/compose
|
||||
BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
|
||||
if [ -z "$BACKEND_HASH" ]; then
|
||||
BACKEND_HASH="no-backend-changes"
|
||||
fi
|
||||
|
||||
|
||||
echo "Frontend hash: $FRONTEND_HASH"
|
||||
echo "Backend hash: $BACKEND_HASH"
|
||||
|
||||
|
||||
echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
|
||||
echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
# Short hashes for tags
|
||||
if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
|
||||
echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
|
||||
if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
|
||||
echo "backend_short=no-backend" >> $GITHUB_OUTPUT
|
||||
else
|
||||
@@ -84,16 +84,15 @@ jobs:
|
||||
echo "Backend image needs to be built"
|
||||
fi
|
||||
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Build and push frontend image
|
||||
if: steps.check-frontend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/frontend/Dockerfile
|
||||
@@ -103,10 +102,10 @@ jobs:
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
|
||||
- name: Build and push backend image
|
||||
if: steps.check-backend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/backend/Dockerfile
|
||||
@@ -117,18 +116,16 @@ jobs:
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
chmod 600 ../private.key
|
||||
|
||||
|
||||
- name: Deploy to VPS on port 3000
|
||||
run: |
|
||||
export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml
|
||||
|
||||
|
||||
cat > $UNIQUE_NAME << EOF
|
||||
version: '3.3'
|
||||
services:
|
||||
@@ -154,24 +151,24 @@ jobs:
|
||||
SWAGGER_SERVER_URL: "https://demo.stirlingpdf.cloud"
|
||||
baseUrl: "https://demo.stirlingpdf.cloud"
|
||||
restart: on-failure:5
|
||||
|
||||
|
||||
frontend:
|
||||
container_name: stirling-v2-frontend
|
||||
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
|
||||
ports:
|
||||
- "3000:80"
|
||||
environment:
|
||||
VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:13000"
|
||||
VITE_API_BASE_URL: "http://${{ secrets.NEW_VPS_HOST }}:13000"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: on-failure:5
|
||||
EOF
|
||||
|
||||
|
||||
# Copy to remote with unique name
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/$UNIQUE_NAME
|
||||
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/$UNIQUE_NAME
|
||||
|
||||
# SSH and rename/move atomically to avoid interference
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH
|
||||
mkdir -p /stirling/V2/{data,config,logs}
|
||||
mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml
|
||||
cd /stirling/V2
|
||||
@@ -186,4 +183,3 @@ jobs:
|
||||
if: always()
|
||||
run: |
|
||||
rm -f ../private.key
|
||||
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
name: License Report Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
files-changed:
|
||||
name: detect what files changed
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
licenses-frontend: ${{ steps.changes.outputs.licenses-frontend }}
|
||||
licenses-backend: ${{ steps.changes.outputs.licenses-backend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: changes
|
||||
with:
|
||||
filters: .github/config/.files.yaml
|
||||
|
||||
generate-frontend-license-report:
|
||||
if: needs.files-changed.outputs.licenses-frontend == 'true'
|
||||
name: Generate Frontend License Report
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR head (default)
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)) && github.actor != 'dependabot[bot]'
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout BASE branch (safe script)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: base
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
env:
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true"
|
||||
run: npm ci --ignore-scripts --audit=false --fund=false
|
||||
|
||||
- name: Generate frontend license report (internal PR)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
working-directory: frontend
|
||||
env:
|
||||
PR_IS_FORK: "false"
|
||||
run: npm run generate-licenses
|
||||
|
||||
- name: Generate frontend license report (fork PRs, pinned)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
env:
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true"
|
||||
working-directory: frontend
|
||||
run: |
|
||||
mkdir -p src/assets
|
||||
npx --yes license-report --only=prod --output=json > src/assets/3rdPartyLicenses.json
|
||||
|
||||
- name: Postprocess with project script (BASE version)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
env:
|
||||
PR_IS_FORK: "true"
|
||||
run: |
|
||||
node base/frontend/scripts/generate-licenses.js \
|
||||
--input frontend/src/assets/3rdPartyLicenses.json
|
||||
|
||||
- name: Copy postprocessed artifacts back (fork PRs)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
run: |
|
||||
mkdir -p frontend/src/assets
|
||||
if [ -f "base/frontend/src/assets/3rdPartyLicenses.json" ]; then
|
||||
cp base/frontend/src/assets/3rdPartyLicenses.json frontend/src/assets/3rdPartyLicenses.json
|
||||
fi
|
||||
if [ -f "base/frontend/src/assets/license-warnings.json" ]; then
|
||||
cp base/frontend/src/assets/license-warnings.json frontend/src/assets/license-warnings.json
|
||||
fi
|
||||
|
||||
- name: Check for license warnings
|
||||
run: |
|
||||
if [ -f "frontend/src/assets/license-warnings.json" ]; then
|
||||
echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# PR Event: Check licenses and comment on PR
|
||||
- name: Delete previous license check comments
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.issue.number;
|
||||
|
||||
// Get all comments on the PR
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
// Filter for license check comments
|
||||
const licenseComments = comments.filter(comment =>
|
||||
comment.body.includes('## ✅ Frontend License Check Passed') ||
|
||||
comment.body.includes('## ❌ Frontend License Check Failed')
|
||||
);
|
||||
|
||||
// Delete old license check comments
|
||||
for (const comment of licenseComments) {
|
||||
console.log(`Deleting old license check comment: ${comment.id}`);
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: comment.id
|
||||
});
|
||||
}
|
||||
|
||||
- name: Summarize results (fork PRs)
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) || github.actor == 'dependabot[bot]'
|
||||
run: |
|
||||
{
|
||||
echo "## Frontend License Check"
|
||||
echo ""
|
||||
if [ "${LICENSE_WARNINGS_EXIST}" = "true" ]; then
|
||||
echo "❌ **Failed** – incompatible or unknown licenses found."
|
||||
if [ -f "frontend/src/assets/license-warnings.json" ]; then
|
||||
echo ""
|
||||
echo "### Warnings"
|
||||
jq -r '.warnings[] | "- \(.message)"' frontend/src/assets/license-warnings.json || true
|
||||
fi
|
||||
else
|
||||
echo "✅ **Passed** – no license warnings detected."
|
||||
fi
|
||||
echo ""
|
||||
echo "_Note: This is a fork PR. PR comments are disabled; use this summary._"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Comment on PR - License Check Results
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.issue.number;
|
||||
const hasWarnings = process.env.LICENSE_WARNINGS_EXIST === 'true';
|
||||
|
||||
let commentBody;
|
||||
|
||||
if (hasWarnings) {
|
||||
// Read warnings file to get specific issues
|
||||
const fs = require('fs');
|
||||
let warningDetails = '';
|
||||
try {
|
||||
const warnings = JSON.parse(fs.readFileSync('frontend/src/assets/license-warnings.json', 'utf8'));
|
||||
warningDetails = warnings.warnings.map(w => `- ${w.message}`).join('\n');
|
||||
} catch (e) {
|
||||
warningDetails = 'Unable to read warning details';
|
||||
}
|
||||
|
||||
commentBody = `## ❌ Frontend License Check Failed
|
||||
|
||||
The frontend license check has detected compatibility warnings that require review:
|
||||
|
||||
${warningDetails}
|
||||
|
||||
**Action Required:** Please review these licenses to ensure they are acceptable for your use case before merging.
|
||||
|
||||
_This check will fail the PR until license issues are resolved._`;
|
||||
} else {
|
||||
commentBody = `## ✅ Frontend License Check Passed
|
||||
|
||||
All frontend licenses have been validated and no compatibility warnings were detected.
|
||||
|
||||
The frontend license report has been updated successfully.`;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: commentBody
|
||||
});
|
||||
|
||||
- name: Fail workflow if license warnings exist (PR only)
|
||||
if: github.event_name == 'pull_request' && env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
run: |
|
||||
echo "❌ License warnings detected. Failing the workflow."
|
||||
exit 1
|
||||
|
||||
# Push Event: Commit license files and create PR
|
||||
- name: Commit changes (Push only)
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
git add frontend/src/assets/3rdPartyLicenses.json
|
||||
# Note: Do NOT commit license-warnings.json - it's only for PR review
|
||||
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
|
||||
|
||||
- name: Prepare PR body (Push only)
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
PR_BODY="Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
|
||||
|
||||
This PR updates the frontend license report based on changes to package.json dependencies."
|
||||
|
||||
if [ "${{ env.LICENSE_WARNINGS_EXIST }}" = "true" ]; then
|
||||
PR_BODY="$PR_BODY
|
||||
|
||||
## ⚠️ License Compatibility Warnings
|
||||
|
||||
The following licenses may require review for corporate compatibility:
|
||||
|
||||
$(cat frontend/src/assets/license-warnings.json | jq -r '.warnings[].message')
|
||||
|
||||
Please review these licenses to ensure they are acceptable for your use case."
|
||||
fi
|
||||
|
||||
echo "PR_BODY<<EOF" >> $GITHUB_ENV
|
||||
echo "$PR_BODY" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Create Pull Request (Push only)
|
||||
id: cpr
|
||||
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Update Frontend 3rd Party Licenses"
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: update-frontend-3rd-party-licenses
|
||||
base: main
|
||||
title: "Update Frontend 3rd Party Licenses"
|
||||
body: ${{ env.PR_BODY }}
|
||||
labels: Licenses,github-actions,frontend
|
||||
draft: false
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
|
||||
- name: Enable Pull Request Automerge (Push only)
|
||||
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'false'
|
||||
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
- name: Add review required label (Push only)
|
||||
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
run: gh pr edit "${{ steps.cpr.outputs.pull-request-number }}" --add-label "license-review-required"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
generate-backend-license-report:
|
||||
if: needs.files-changed.outputs.licenses-backend == 'true'
|
||||
needs: files-changed
|
||||
name: Generate Backend License Report
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)) && github.actor != 'dependabot[bot]'
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Check licenses and generate report
|
||||
id: license-check
|
||||
run: |
|
||||
./gradlew clean checkLicense generateLicenseReport || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: false
|
||||
STIRLING_PDF_DESKTOP_UI: true
|
||||
|
||||
- name: Check for license compatibility issues
|
||||
run: |
|
||||
if [ -f build/reports/dependency-license/dependencies-without-allowed-license.json ] && \
|
||||
jq '.dependenciesWithoutAllowedLicenses | length > 0' build/reports/dependency-license/dependencies-without-allowed-license.json | grep -q true; then
|
||||
echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
|
||||
fi
|
||||
if: always()
|
||||
|
||||
- name: Upload artifact on license issues
|
||||
if: env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: backend-dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
|
||||
- name: Move license file
|
||||
if: env.LICENSE_CHECK_FAILED != 'true' && env.LICENSE_WARNINGS_EXIST == 'false'
|
||||
run: |
|
||||
mkdir -p app/core/src/main/resources/static
|
||||
cp build/reports/dependency-license/index.json app/core/src/main/resources/static/3rdPartyLicenses.json
|
||||
|
||||
- name: Delete previous backend license check comments
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.issue.number;
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
const backendLicenseComments = comments.filter(comment =>
|
||||
comment.body.includes('## ✅ Backend License Check Passed') ||
|
||||
comment.body.includes('## ❌ Backend License Check Failed')
|
||||
);
|
||||
|
||||
for (const comment of backendLicenseComments) {
|
||||
console.log(`Deleting old backend license comment: ${comment.id}`);
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: comment.id
|
||||
});
|
||||
}
|
||||
|
||||
- name: Comment on PR - Backend License Check Results
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const hasWarnings = process.env.LICENSE_WARNINGS_EXIST === 'true';
|
||||
const fs = require('fs');
|
||||
let warningDetails = '';
|
||||
|
||||
if (hasWarnings) {
|
||||
try {
|
||||
const warningsFile = 'build/reports/dependency-license/dependencies-without-allowed-license.json';
|
||||
if (fs.existsSync(warningsFile)) {
|
||||
const data = JSON.parse(fs.readFileSync(warningsFile, 'utf8'));
|
||||
if (data.length > 0) {
|
||||
warningDetails = data.map(dep => `- **${dep.moduleName}@${dep.moduleVersion}** – ${dep.moduleLicenses.map(l => l.licenseName).join(', ')}`).join('\n');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
warningDetails = 'Unable to parse warning details.';
|
||||
}
|
||||
}
|
||||
|
||||
let commentBody;
|
||||
if (hasWarnings) {
|
||||
commentBody = `## ❌ Backend License Check Failed
|
||||
|
||||
The backend license check has detected dependencies with incompatible or unallowed licenses:
|
||||
|
||||
${warningDetails || 'See uploaded artifact for details.'}
|
||||
|
||||
**Action Required:** Please review these licenses and resolve before merging.
|
||||
|
||||
_This check will fail the PR until license issues are resolved._`;
|
||||
} else {
|
||||
commentBody = `## ✅ Backend License Check Passed
|
||||
|
||||
All backend dependencies have valid and allowed licenses.
|
||||
|
||||
The backend license report has been updated successfully.`;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: commentBody
|
||||
});
|
||||
|
||||
- name: Fail workflow if license warnings exist (PR only)
|
||||
if: github.event_name == 'pull_request' && env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
run: |
|
||||
echo "❌ Backend license warnings detected. Failing the workflow."
|
||||
exit 1
|
||||
|
||||
- name: Commit changes (push only)
|
||||
if: github.event_name == 'push' && env.LICENSE_WARNINGS_EXIST == 'false'
|
||||
run: |
|
||||
git config user.name "${{ steps.setup-bot.outputs.committer }}"
|
||||
git config user.email "${{ steps.setup-bot.outputs.committer-email || 'bot@github.com' }}"
|
||||
git add app/core/src/main/resources/static/3rdPartyLicenses.json
|
||||
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
|
||||
|
||||
- name: Prepare PR body (push only)
|
||||
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
|
||||
run: |
|
||||
PR_BODY="Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
|
||||
|
||||
This PR updates the backend license report based on dependency changes."
|
||||
|
||||
if [ "${{ env.LICENSE_WARNINGS_EXIST }}" = "true" ]; then
|
||||
PR_BODY="$PR_BODY
|
||||
|
||||
## ⚠️ License Compatibility Warnings
|
||||
|
||||
Incompatible licenses detected – manual review required before merge."
|
||||
fi
|
||||
echo "PR_BODY<<EOF" >> $GITHUB_ENV
|
||||
echo "$PR_BODY" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Create Pull Request (push only)
|
||||
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Update Backend 3rd Party Licenses"
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: update-backend-3rd-party-licenses
|
||||
base: main
|
||||
title: "Update Backend 3rd Party Licenses"
|
||||
body: ${{ env.PR_BODY }}
|
||||
labels: Licenses,github-actions,backend
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
|
||||
- name: Enable Pull Request Automerge (push only, no warnings)
|
||||
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'false'
|
||||
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
- name: Add review required label (push only, with warnings)
|
||||
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
run: gh pr edit "${{ steps.cpr.outputs.pull-request-number }}" --add-label "license-review-required"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
@@ -1,282 +0,0 @@
|
||||
name: Frontend License Report Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- V2
|
||||
paths:
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/scripts/generate-licenses.js"
|
||||
pull_request:
|
||||
branches:
|
||||
- V2
|
||||
paths:
|
||||
- ".github/workflows/frontend-licenses-update.yml"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/scripts/generate-licenses.js"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
generate-frontend-license-report:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR head (default)
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout BASE branch (safe script)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: base
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
env:
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true"
|
||||
run: npm ci --ignore-scripts --audit=false --fund=false
|
||||
|
||||
- name: Generate frontend license report (internal PR)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
working-directory: frontend
|
||||
env:
|
||||
PR_IS_FORK: "false"
|
||||
run: npm run generate-licenses
|
||||
|
||||
- name: Generate frontend license report (fork PRs, pinned)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
env:
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true"
|
||||
working-directory: frontend
|
||||
run: |
|
||||
mkdir -p src/assets
|
||||
npx --yes license-report --only=prod --output=json > src/assets/3rdPartyLicenses.json
|
||||
|
||||
- name: Postprocess with project script (BASE version)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
env:
|
||||
PR_IS_FORK: "true"
|
||||
run: |
|
||||
node base/frontend/scripts/generate-licenses.js \
|
||||
--input frontend/src/assets/3rdPartyLicenses.json
|
||||
|
||||
- name: Copy postprocessed artifacts back (fork PRs)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
run: |
|
||||
mkdir -p frontend/src/assets
|
||||
if [ -f "base/frontend/src/assets/3rdPartyLicenses.json" ]; then
|
||||
cp base/frontend/src/assets/3rdPartyLicenses.json frontend/src/assets/3rdPartyLicenses.json
|
||||
fi
|
||||
if [ -f "base/frontend/src/assets/license-warnings.json" ]; then
|
||||
cp base/frontend/src/assets/license-warnings.json frontend/src/assets/license-warnings.json
|
||||
fi
|
||||
|
||||
- name: Check for license warnings
|
||||
run: |
|
||||
if [ -f "frontend/src/assets/license-warnings.json" ]; then
|
||||
echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# PR Event: Check licenses and comment on PR
|
||||
- name: Delete previous license check comments
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.issue.number;
|
||||
|
||||
// Get all comments on the PR
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
// Filter for license check comments
|
||||
const licenseComments = comments.filter(comment =>
|
||||
comment.body.includes('## ✅ Frontend License Check Passed') ||
|
||||
comment.body.includes('## ❌ Frontend License Check Failed')
|
||||
);
|
||||
|
||||
// Delete old license check comments
|
||||
for (const comment of licenseComments) {
|
||||
console.log(`Deleting old license check comment: ${comment.id}`);
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: comment.id
|
||||
});
|
||||
}
|
||||
|
||||
- name: Summarize results (fork PRs)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
run: |
|
||||
{
|
||||
echo "## Frontend License Check"
|
||||
echo ""
|
||||
if [ "${LICENSE_WARNINGS_EXIST}" = "true" ]; then
|
||||
echo "❌ **Failed** – incompatible or unknown licenses found."
|
||||
if [ -f "frontend/src/assets/license-warnings.json" ]; then
|
||||
echo ""
|
||||
echo "### Warnings"
|
||||
jq -r '.warnings[] | "- \(.message)"' frontend/src/assets/license-warnings.json || true
|
||||
fi
|
||||
else
|
||||
echo "✅ **Passed** – no license warnings detected."
|
||||
fi
|
||||
echo ""
|
||||
echo "_Note: This is a fork PR. PR comments are disabled; use this summary._"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Comment on PR - License Check Results
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.issue.number;
|
||||
const hasWarnings = process.env.LICENSE_WARNINGS_EXIST === 'true';
|
||||
|
||||
let commentBody;
|
||||
|
||||
if (hasWarnings) {
|
||||
// Read warnings file to get specific issues
|
||||
const fs = require('fs');
|
||||
let warningDetails = '';
|
||||
try {
|
||||
const warnings = JSON.parse(fs.readFileSync('frontend/src/assets/license-warnings.json', 'utf8'));
|
||||
warningDetails = warnings.warnings.map(w => `- ${w.message}`).join('\n');
|
||||
} catch (e) {
|
||||
warningDetails = 'Unable to read warning details';
|
||||
}
|
||||
|
||||
commentBody = `## ❌ Frontend License Check Failed
|
||||
|
||||
The frontend license check has detected compatibility warnings that require review:
|
||||
|
||||
${warningDetails}
|
||||
|
||||
**Action Required:** Please review these licenses to ensure they are acceptable for your use case before merging.
|
||||
|
||||
_This check will fail the PR until license issues are resolved._`;
|
||||
} else {
|
||||
commentBody = `## ✅ Frontend License Check Passed
|
||||
|
||||
All frontend licenses have been validated and no compatibility warnings were detected.
|
||||
|
||||
The frontend license report has been updated successfully.`;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: commentBody
|
||||
});
|
||||
|
||||
- name: Fail workflow if license warnings exist (PR only)
|
||||
if: github.event_name == 'pull_request' && env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
run: |
|
||||
echo "❌ License warnings detected. Failing the workflow."
|
||||
exit 1
|
||||
|
||||
# Push Event: Commit license files and create PR
|
||||
- name: Commit changes (Push only)
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
git add frontend/src/assets/3rdPartyLicenses.json
|
||||
# Note: Do NOT commit license-warnings.json - it's only for PR review
|
||||
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
|
||||
|
||||
- name: Prepare PR body (Push only)
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
PR_BODY="Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
|
||||
|
||||
This PR updates the frontend license report based on changes to package.json dependencies."
|
||||
|
||||
if [ "${{ env.LICENSE_WARNINGS_EXIST }}" = "true" ]; then
|
||||
PR_BODY="$PR_BODY
|
||||
|
||||
## ⚠️ License Compatibility Warnings
|
||||
|
||||
The following licenses may require review for corporate compatibility:
|
||||
|
||||
$(cat frontend/src/assets/license-warnings.json | jq -r '.warnings[].message')
|
||||
|
||||
Please review these licenses to ensure they are acceptable for your use case."
|
||||
fi
|
||||
|
||||
echo "PR_BODY<<EOF" >> $GITHUB_ENV
|
||||
echo "$PR_BODY" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Create Pull Request (Push only)
|
||||
id: cpr
|
||||
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Update Frontend 3rd Party Licenses"
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: update-frontend-3rd-party-licenses
|
||||
base: V2
|
||||
title: "Update Frontend 3rd Party Licenses"
|
||||
body: ${{ env.PR_BODY }}
|
||||
labels: Licenses,github-actions,frontend
|
||||
draft: false
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
|
||||
- name: Enable Pull Request Automerge (Push only)
|
||||
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'false'
|
||||
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
- name: Add review required label (Push only)
|
||||
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
run: gh pr edit "${{ steps.cpr.outputs.pull-request-number }}" --add-label "license-review-required"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
@@ -1,105 +0,0 @@
|
||||
name: License Report Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "build.gradle"
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
# or a pull request is updated.
|
||||
# It helps to save resources and time by ensuring that only the latest commit is built and tested
|
||||
# This is particularly useful for long-running jobs that may take a while to complete.
|
||||
# The `group` is set to a combination of the workflow name, event name, and branch name.
|
||||
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
|
||||
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
generate-license-report:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Check licenses for compatibility
|
||||
run: ./gradlew clean checkLicense
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: false
|
||||
STIRLING_PDF_DESKTOP_UI: true
|
||||
|
||||
- name: Upload artifact on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
retention-days: 3
|
||||
|
||||
- name: Move and rename license file
|
||||
run: |
|
||||
mv build/reports/dependency-license/index.json app/core/src/main/resources/static/3rdPartyLicenses.json
|
||||
|
||||
- name: Commit changes
|
||||
run: |
|
||||
git add app/core/src/main/resources/static/3rdPartyLicenses.json
|
||||
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
if: env.CHANGES_DETECTED == 'true'
|
||||
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Update 3rd Party Licenses"
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: update-3rd-party-licenses
|
||||
title: "Update 3rd Party Licenses"
|
||||
body: |
|
||||
Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
|
||||
labels: Licenses,github-actions
|
||||
draft: false
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
|
||||
- name: Enable Pull Request Automerge
|
||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
||||
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
@@ -22,7 +22,7 @@ on:
|
||||
- macos
|
||||
- linux
|
||||
push:
|
||||
branches: [main, V2, V2-demo, V2-master]
|
||||
branches: [main, V2-master]
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
@@ -126,8 +126,8 @@ jobs:
|
||||
if: matrix.variant.build_frontend == true
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Build JAR
|
||||
@@ -178,8 +178,8 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
@@ -562,7 +562,7 @@ jobs:
|
||||
run: ls -R ./artifacts
|
||||
|
||||
- name: Upload binaries to Release
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
with:
|
||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -38,10 +38,10 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: 'pip' # caching pip dependencies
|
||||
cache: "pip" # caching pip dependencies
|
||||
cache-dependency-path: ./.github/scripts/requirements_pre_commit.txt
|
||||
|
||||
- name: Run Pre-Commit Hooks
|
||||
@@ -51,10 +51,10 @@ jobs:
|
||||
- run: pre-commit run --all-files -c .pre-commit-config.yaml
|
||||
continue-on-error: true
|
||||
|
||||
- name: Set up JDK
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Build with Gradle
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
if: env.CHANGES_DETECTED == 'true'
|
||||
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: ":file_folder: pre-commit"
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
name: Push Docker Image - V2 Branch
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- V2-master
|
||||
- V1_V2_merge
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
# or a pull request is updated.
|
||||
# It helps to save resources and time by ensuring that only the latest commit is built and tested
|
||||
# This is particularly useful for long-running jobs that may take a while to complete.
|
||||
# The `group` is set to a combination of the workflow name, event name, and branch name.
|
||||
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
|
||||
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
push:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-24.04-8core
|
||||
permissions:
|
||||
packages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate tags for latest (V2-master branch - production)
|
||||
id: meta
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Generate tags for latest (V1_V2_merge branch - test)
|
||||
id: meta-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V1_V2_merge'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Unified Dockerfile (latest variant)
|
||||
id: build-push-latest
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta.outputs.tags || steps.meta-test.outputs.tags }}
|
||||
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta.outputs.labels || steps.meta-test.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign regular images
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-latest.outputs.digest }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --yes \
|
||||
--key env://COSIGN_PRIVATE_KEY \
|
||||
"${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags for latest-fat (V2-master branch - production)
|
||||
id: meta-fat
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
|
||||
type=raw,value=latest-fat
|
||||
|
||||
- name: Generate tags for latest-fat (V1_V2_merge branch - test)
|
||||
id: meta-fat-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V1_V2_merge'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
|
||||
type=raw,value=latest-fat
|
||||
|
||||
- name: Build and push Unified Dockerfile (fat variant)
|
||||
id: build-push-fat
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile.fat
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-fat.outputs.tags || steps.meta-fat-test.outputs.tags }}
|
||||
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-fat.outputs.labels || steps.meta-fat-test.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign fat images
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-fat.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags for ultra-lite (V2-master branch - production)
|
||||
id: meta-lite
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
|
||||
type=raw,value=latest-ultra-lite
|
||||
|
||||
- name: Generate tags for ultra-lite (V1_V2_merge branch - test)
|
||||
id: meta-lite-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V1_V2_merge'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
|
||||
type=raw,value=latest-ultra-lite
|
||||
|
||||
- name: Build and push Unified Dockerfile (ultra-lite variant)
|
||||
id: build-push-lite
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile.ultra-lite
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-lite.outputs.tags || steps.meta-lite-test.outputs.tags }}
|
||||
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-lite.outputs.labels || steps.meta-lite-test.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign ultra-lite images
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-lite.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
@@ -6,6 +6,8 @@ on:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
- V2-master
|
||||
- testMain
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
@@ -25,7 +27,7 @@ permissions:
|
||||
jobs:
|
||||
push:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04-8core
|
||||
permissions:
|
||||
packages: write
|
||||
id-token: write
|
||||
@@ -37,28 +39,16 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up JDK 17
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Run Gradle Command
|
||||
run: ./gradlew clean build
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/master'
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
@@ -67,6 +57,12 @@ jobs:
|
||||
id: versionNumber
|
||||
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
@@ -87,10 +83,9 @@ jobs:
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate tags
|
||||
- name: Generate tags for latest
|
||||
id: meta
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
@@ -98,13 +93,13 @@ jobs:
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/testMain' }}
|
||||
|
||||
- name: Build and push main Dockerfile
|
||||
id: build-push-regular
|
||||
- name: Build and push Unified Dockerfile (latest variant)
|
||||
id: build-push-latest
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
@@ -120,9 +115,9 @@ jobs:
|
||||
sbom: true
|
||||
|
||||
- name: Sign regular images
|
||||
if: github.ref == 'refs/heads/master'
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-regular.outputs.digest }}
|
||||
DIGEST: ${{ steps.build-push-latest.outputs.digest }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
@@ -133,10 +128,10 @@ jobs:
|
||||
"${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags ultra-lite
|
||||
id: meta2
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
- name: Generate tags for latest-fat
|
||||
id: meta-fat
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
@@ -144,43 +139,13 @@ jobs:
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
|
||||
- name: Build and push Dockerfile-ultra-lite
|
||||
id: build-push-lite
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile.ultra-lite
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ steps.meta2.outputs.tags }}
|
||||
labels: ${{ steps.meta2.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Generate tags fat
|
||||
id: meta3
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push main Dockerfile fat
|
||||
- name: Build and push Unified Dockerfile (fat variant)
|
||||
id: build-push-fat
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
@@ -188,18 +153,62 @@ jobs:
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ steps.meta3.outputs.tags }}
|
||||
labels: ${{ steps.meta3.outputs.labels }}
|
||||
tags: ${{ steps.meta-fat.outputs.tags }}
|
||||
labels: ${{ steps.meta-fat.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign fat images
|
||||
if: github.ref == 'refs/heads/master'
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
|
||||
TAGS: ${{ steps.meta3.outputs.tags }}
|
||||
TAGS: ${{ steps.meta-fat.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags for ultra-lite
|
||||
id: meta-lite
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
|
||||
- name: Build and push Unified Dockerfile (ultra-lite variant)
|
||||
id: build-push-lite
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile.ultra-lite
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ steps.meta-lite.outputs.tags }}
|
||||
labels: ${{ steps.meta-lite.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign ultra-lite images
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-lite.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
|
||||
@@ -1,535 +0,0 @@
|
||||
name: Release Artifacts
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platform:
|
||||
description: "Platform to build (windows, macos, linux, or all)"
|
||||
required: true
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- windows
|
||||
- macos
|
||||
- linux
|
||||
push:
|
||||
branches: [main, V2, V2-demo]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
determine-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: |
|
||||
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Determine build matrix
|
||||
id: set-matrix
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
case "${{ github.event.inputs.platform }}" in
|
||||
"windows")
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"macos")
|
||||
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"linux")
|
||||
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
*)
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# For push events, build all platforms
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: determine-matrix
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Build Java backend with JLink
|
||||
working-directory: ./
|
||||
shell: bash
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
echo "🔧 Building Stirling-PDF JAR..."
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Find the built JAR
|
||||
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
|
||||
echo "✅ Built JAR: $STIRLING_JAR"
|
||||
|
||||
# Create Tauri directories
|
||||
mkdir -p ./frontend/src-tauri/libs
|
||||
mkdir -p ./frontend/src-tauri/runtime
|
||||
|
||||
# Copy JAR to Tauri libs
|
||||
cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
|
||||
echo "✅ JAR copied to Tauri libs"
|
||||
|
||||
# Analyze JAR dependencies for jlink modules
|
||||
echo "🔍 Analyzing JAR dependencies..."
|
||||
if command -v jdeps &> /dev/null; then
|
||||
DETECTED_MODULES=$(jdeps --print-module-deps --ignore-missing-deps "$STIRLING_JAR" 2>/dev/null || echo "")
|
||||
if [ -n "$DETECTED_MODULES" ]; then
|
||||
echo "📋 jdeps detected modules: $DETECTED_MODULES"
|
||||
MODULES="$DETECTED_MODULES,java.compiler,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
else
|
||||
echo "⚠️ jdeps analysis failed, using predefined modules"
|
||||
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ jdeps not available, using predefined modules"
|
||||
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
fi
|
||||
|
||||
# Create custom JRE with jlink
|
||||
echo "🔧 Creating custom JRE with jlink..."
|
||||
echo "📋 Using modules: $MODULES"
|
||||
|
||||
# Remove any existing JRE
|
||||
rm -rf ./frontend/src-tauri/runtime/jre
|
||||
|
||||
# Create the custom JRE
|
||||
jlink \
|
||||
--add-modules "$MODULES" \
|
||||
--strip-debug \
|
||||
--compress=2 \
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output ./frontend/src-tauri/runtime/jre
|
||||
|
||||
if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
|
||||
echo "❌ Failed to create JLink runtime"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test the bundled runtime
|
||||
if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then
|
||||
RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
|
||||
echo "✅ Custom JRE created successfully: $RUNTIME_VERSION"
|
||||
else
|
||||
echo "❌ Custom JRE executable not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Calculate runtime size
|
||||
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
|
||||
echo "📊 Custom JRE size: $RUNTIME_SIZE"
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm install
|
||||
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
uses: digicert/ssm-code-signing@v1.1.0
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
|
||||
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
|
||||
- name: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
|
||||
# Decode client certificate
|
||||
$certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}")
|
||||
$certPath = "D:\Certificate_pkcs12.p12"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Set environment variables
|
||||
echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
|
||||
echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV
|
||||
echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV
|
||||
echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV
|
||||
echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV
|
||||
|
||||
# Get PKCS11 config path from DigiCert action
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if ($pkcs11Config) {
|
||||
Write-Host "Found PKCS11_CONFIG: $pkcs11Config"
|
||||
echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV
|
||||
} else {
|
||||
Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path"
|
||||
$defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg"
|
||||
if (Test-Path $defaultPath) {
|
||||
Write-Host "Found config at default path: $defaultPath"
|
||||
echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV
|
||||
} else {
|
||||
Write-Host "Warning: Could not find PKCS11 config file"
|
||||
}
|
||||
}
|
||||
|
||||
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
|
||||
- name: Import Windows Code Signing Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
shell: powershell
|
||||
run: |
|
||||
if ($env:WINDOWS_CERTIFICATE) {
|
||||
Write-Host "Importing Windows Code Signing Certificate..."
|
||||
|
||||
# Decode base64 certificate and save to file
|
||||
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
|
||||
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Import certificate to CurrentUser\My store
|
||||
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
|
||||
|
||||
# Extract and set thumbprint as environment variable
|
||||
$thumbprint = $cert.Thumbprint
|
||||
Write-Host "Certificate imported with thumbprint: $thumbprint"
|
||||
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
|
||||
|
||||
# Clean up certificate file
|
||||
Remove-Item $certPath
|
||||
|
||||
Write-Host "Windows certificate import completed."
|
||||
} else {
|
||||
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
echo "Importing Apple Developer Certificate..."
|
||||
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
|
||||
# Create temporary keychain
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
# Import certificate
|
||||
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
# Clean up
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application")
|
||||
echo "Certificate Info: $CERT_INFO"
|
||||
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
|
||||
echo "Certificate ID: $CERT_ID"
|
||||
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
|
||||
echo "Certificate imported successfully."
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
|
||||
# Only enable Windows signing in Tauri when on main
|
||||
SIGN: ${{ github.ref == 'refs/heads/main' && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
CI: true
|
||||
with:
|
||||
projectPath: ./frontend
|
||||
tauriScript: npx tauri
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
# Sign with DigiCert KeyLocker (post-build)
|
||||
- name: Sign Windows binaries with DigiCert KeyLocker
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "=== DigiCert KeyLocker Signing ==="
|
||||
|
||||
# Test smctl connectivity first
|
||||
Write-Host "Testing smctl connection..."
|
||||
$healthCheck = & smctl healthcheck 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "[SUCCESS] Connected to DigiCert KeyLocker"
|
||||
} else {
|
||||
Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker"
|
||||
Write-Host $healthCheck
|
||||
exit 1
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# Sync certificates to Windows certificate store
|
||||
Write-Host "Syncing certificates to Windows certificate store..."
|
||||
$syncOutput = & smctl windows certsync 2>&1
|
||||
Write-Host "Cert sync result: $syncOutput"
|
||||
Write-Host ""
|
||||
|
||||
# Find only the files we need to sign (not build scripts)
|
||||
$filesToSign = @()
|
||||
|
||||
# Main application executable
|
||||
$mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue
|
||||
if ($mainExe) { $filesToSign += $mainExe }
|
||||
|
||||
# MSI installer
|
||||
$msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
|
||||
$filesToSign += $msiFiles
|
||||
|
||||
if ($filesToSign.Count -eq 0) {
|
||||
Write-Host "[ERROR] No files found to sign"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($filesToSign.Count) files to sign:"
|
||||
foreach ($f in $filesToSign) { Write-Host " - $($f.Name)" }
|
||||
Write-Host ""
|
||||
|
||||
$signedCount = 0
|
||||
foreach ($file in $filesToSign) {
|
||||
Write-Host "Signing: $($file.Name)"
|
||||
|
||||
# Get PKCS11 config file path (set by DigiCert action)
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if (-not $pkcs11Config) {
|
||||
Write-Host "[ERROR] PKCS11_CONFIG environment variable not set"
|
||||
Write-Host "DigiCert KeyLocker action may not have run correctly"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Using PKCS11 config: $pkcs11Config"
|
||||
|
||||
# Try signing with certificate fingerprint first (if available)
|
||||
$fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}"
|
||||
if ($fingerprint -and $fingerprint -ne "") {
|
||||
Write-Host "Attempting to sign with certificate fingerprint..."
|
||||
$output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
} else {
|
||||
Write-Host "No fingerprint provided, using keypair alias..."
|
||||
# Use smctl to sign with keypair alias
|
||||
$output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
}
|
||||
|
||||
Write-Host "Exit code: $exitCode"
|
||||
Write-Host "Output: $output"
|
||||
|
||||
# Check if output contains "FAILED" even with exit code 0
|
||||
if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") {
|
||||
Write-Host ""
|
||||
Write-Host "[ERROR] Signing failed for $($file.Name)"
|
||||
Write-Host "[ERROR] smctl returned success but output indicates failure"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Host "[ERROR] Failed to sign $($file.Name)"
|
||||
Write-Host "Full error output:"
|
||||
Write-Host $output
|
||||
exit 1
|
||||
}
|
||||
|
||||
$signedCount++
|
||||
Write-Host "[SUCCESS] Signed: $($file.Name)"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
Write-Host "=== Summary ==="
|
||||
Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully"
|
||||
|
||||
- name: Rename artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ./dist
|
||||
cd ./frontend/src-tauri/target
|
||||
|
||||
# Find and rename artifacts based on platform
|
||||
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
|
||||
find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \;
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
|
||||
else
|
||||
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
|
||||
fi
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
retention-days: 30
|
||||
|
||||
sign_verify:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: success()
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
name: [windows-x86_64, macos-aarch64, macos-x86_64, linux-x86_64]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
|
||||
- name: Generate key pair
|
||||
run: cosign generate-key-pair
|
||||
|
||||
- name: Sign and generate attestations
|
||||
shell: bash
|
||||
run: |
|
||||
# Sign all artifacts for this platform
|
||||
for file in *; do
|
||||
if [ -f "$file" ] && [[ ! "$file" =~ \.(sig|intoto\.jsonl)$ ]]; then
|
||||
echo "Signing: $file"
|
||||
|
||||
# Sign the artifact
|
||||
cosign sign-blob \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-signature "${file}.sig" \
|
||||
"$file"
|
||||
|
||||
# Generate attestation
|
||||
cosign attest-blob \
|
||||
--predicate - \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-attestation "${file}.intoto.jsonl" \
|
||||
"$file"
|
||||
|
||||
# Verify the signature
|
||||
cosign verify-blob \
|
||||
--key ./cosign.pub \
|
||||
--signature "${file}.sig" \
|
||||
"$file"
|
||||
|
||||
echo "✅ Signed and verified: $file"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Upload signed artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}-signed
|
||||
path: |
|
||||
*
|
||||
!cosign.key
|
||||
!cosign.pub
|
||||
retention-days: 30
|
||||
|
||||
release:
|
||||
needs: [determine-matrix, build, sign_verify]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download all signed artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
pattern: Stirling-PDF-*-signed
|
||||
path: ./artifacts
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R ./artifacts
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
with:
|
||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||
generate_release_notes: true
|
||||
files: ./artifacts/**/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: 30 days stale issues
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 30
|
||||
|
||||
@@ -33,10 +33,10 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up JDK 17
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
@@ -5,7 +5,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- syncLangTest
|
||||
paths:
|
||||
- "build.gradle"
|
||||
- "README.md"
|
||||
@@ -47,14 +46,13 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
pip install tomli-w
|
||||
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
|
||||
|
||||
- name: Sync translation TOML files
|
||||
run: |
|
||||
@@ -65,9 +63,6 @@ jobs:
|
||||
git add frontend/public/locales/*/translation.toml
|
||||
git diff --staged --quiet || git commit -m ":memo: Sync translation files (TOML)" || echo "No changes detected"
|
||||
|
||||
- name: Install README dependencies
|
||||
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
|
||||
|
||||
- name: Sync README.md
|
||||
run: |
|
||||
python scripts/counter_translation_v3.py
|
||||
@@ -79,7 +74,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
if: always()
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: Update files
|
||||
@@ -121,4 +116,4 @@ jobs:
|
||||
add-paths: |
|
||||
README.md
|
||||
frontend/public/locales/*/translation.toml
|
||||
scripts/ignore_translation.toml
|
||||
scripts/ignore_translation.toml
|
||||
|
||||
@@ -14,14 +14,14 @@ on:
|
||||
- macos
|
||||
- linux
|
||||
pull_request:
|
||||
branches: [main, V2, V2-tauri-windows]
|
||||
branches: [main, V2-tauri-windows]
|
||||
paths:
|
||||
- 'frontend/src-tauri/**'
|
||||
- 'frontend/src/desktop/**'
|
||||
- 'frontend/tsconfig.desktop.json'
|
||||
- '.github/workflows/tauri-build.yml'
|
||||
- "frontend/src-tauri/**"
|
||||
- "frontend/src/desktop/**"
|
||||
- "frontend/tsconfig.desktop.json"
|
||||
- ".github/workflows/tauri-build.yml"
|
||||
push:
|
||||
branches: [main, V2]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -83,8 +83,8 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
@@ -111,15 +111,15 @@ jobs:
|
||||
# Find the built JAR
|
||||
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
|
||||
echo "✅ Built JAR: $STIRLING_JAR"
|
||||
|
||||
|
||||
# Create Tauri directories
|
||||
mkdir -p ./frontend/src-tauri/libs
|
||||
mkdir -p ./frontend/src-tauri/runtime
|
||||
|
||||
|
||||
# Copy JAR to Tauri libs
|
||||
cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
|
||||
echo "✅ JAR copied to Tauri libs"
|
||||
|
||||
|
||||
# Analyze JAR dependencies for jlink modules
|
||||
echo "🔍 Analyzing JAR dependencies..."
|
||||
if command -v jdeps &> /dev/null; then
|
||||
@@ -135,14 +135,14 @@ jobs:
|
||||
echo "⚠️ jdeps not available, using predefined modules"
|
||||
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
fi
|
||||
|
||||
|
||||
# Create custom JRE with jlink (always rebuild)
|
||||
echo "🔧 Creating custom JRE with jlink..."
|
||||
echo "📋 Using modules: $MODULES"
|
||||
|
||||
|
||||
# Remove any existing JRE
|
||||
rm -rf ./frontend/src-tauri/runtime/jre
|
||||
|
||||
|
||||
# Create the custom JRE
|
||||
jlink \
|
||||
--add-modules "$MODULES" \
|
||||
@@ -151,12 +151,12 @@ jobs:
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output ./frontend/src-tauri/runtime/jre
|
||||
|
||||
|
||||
if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
|
||||
echo "❌ Failed to create JLink runtime"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Test the bundled runtime
|
||||
if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then
|
||||
RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
echo "❌ Custom JRE executable not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Calculate runtime size
|
||||
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
|
||||
echo "📊 Custom JRE size: $RUNTIME_SIZE"
|
||||
@@ -507,7 +507,6 @@ jobs:
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
|
||||
else
|
||||
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
|
||||
@@ -579,7 +578,7 @@ jobs:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
retention-days: 7
|
||||
|
||||
|
||||
- name: Verify build artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -595,8 +594,8 @@ jobs:
|
||||
fi
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
echo "Checking for macOS artifacts..."
|
||||
find . -name "*.dmg" -o -name "*.app" | head -5
|
||||
if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then
|
||||
find . -name "*.dmg" | head -5
|
||||
if [ $(find . -name "*.dmg" | wc -l) -eq 0 ]; then
|
||||
echo "❌ No macOS artifacts found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -32,11 +32,11 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up JDK
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Deploy to VPS
|
||||
@@ -107,9 +107,9 @@ jobs:
|
||||
restart: on-failure:5
|
||||
EOF
|
||||
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose.yml
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose.yml
|
||||
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << EOF
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << EOF
|
||||
mkdir -p /stirling/test-${{ github.sha }}/{data,config,logs}
|
||||
mv /tmp/docker-compose.yml /stirling/test-${{ github.sha }}/docker-compose.yml
|
||||
cd /stirling/test-${{ github.sha }}
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Run TestDriver.ai
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
npm install
|
||||
npm run build
|
||||
npm install dashcam-chrome --save
|
||||
Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.VPS_HOST }}:1337"
|
||||
Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.NEW_VPS_HOST }}:1337"
|
||||
Start-Sleep -Seconds 20
|
||||
prompt: |
|
||||
1. /run testing/testdriver/test.yml
|
||||
@@ -183,13 +183,13 @@ jobs:
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Cleanup deployment
|
||||
if: always()
|
||||
run: |
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << EOF
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << EOF
|
||||
cd /stirling/test-${{ github.sha }}
|
||||
docker-compose down
|
||||
cd /stirling
|
||||
|
||||
@@ -43,6 +43,8 @@ app/core/src/main/resources/static/og_images/
|
||||
app/core/src/main/resources/static/samples/
|
||||
app/core/src/main/resources/static/manifest-classic.json
|
||||
app/core/src/main/resources/static/robots.txt
|
||||
app/core/src/main/resources/static/pdfium/
|
||||
app/core/src/main/resources/static/vendor/
|
||||
# Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc.
|
||||
|
||||
# Gradle
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.2
|
||||
rev: v0.14.8
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
@@ -22,7 +22,7 @@ repos:
|
||||
files: \.(html|css|js|py|md)$
|
||||
exclude: (.vscode|.devcontainer|app/core/src/main/resources|app/proprietary/src/main/resources|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.28.0
|
||||
rev: v8.30.0
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
|
||||
@@ -96,6 +96,22 @@
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "MPL 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Mozilla Public License, Version 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "CDDL+GPL License"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "BSD"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "UnboundID SCIM2 SDK Free Use License"
|
||||
|
||||
@@ -37,6 +37,7 @@ dependencies {
|
||||
api 'com.drewnoakes:metadata-extractor:2.19.0' // Image metadata extractor
|
||||
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
|
||||
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
|
||||
api "org.apache.pdfbox:pdfbox-io:$pdfboxVersion"
|
||||
api "org.apache.pdfbox:xmpbox:$pdfboxVersion"
|
||||
api "org.apache.pdfbox:preflight:$pdfboxVersion"
|
||||
api 'com.github.junrar:junrar:7.5.7' // RAR archive support for CBR files
|
||||
|
||||
@@ -156,19 +156,19 @@ public class EndpointConfiguration {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rule 2: For tool groups, they're enabled unless explicitly disabled (handled above)
|
||||
if (isToolGroup(group)) {
|
||||
log.debug("isGroupEnabled('{}') -> true (tool group not disabled)", group);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Rule 3: For functional groups, check if all endpoints are enabled
|
||||
Set<String> endpoints = endpointGroups.get(group);
|
||||
if (endpoints == null || endpoints.isEmpty()) {
|
||||
log.debug("isGroupEnabled('{}') -> false (no endpoints)", group);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rule 2: For functional groups, check if all endpoints are enabled
|
||||
// Rule 3: For tool groups, they're enabled unless explicitly disabled (handled above)
|
||||
if (isToolGroup(group)) {
|
||||
log.debug("isGroupEnabled('{}') -> true (tool group not disabled)", group);
|
||||
return true;
|
||||
}
|
||||
|
||||
// For functional groups, check each endpoint individually
|
||||
for (String endpoint : endpoints) {
|
||||
if (!isEndpointEnabledDirectly(endpoint)) {
|
||||
@@ -353,6 +353,7 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Security", "auto-redact");
|
||||
addEndpointToGroup("Security", "redact");
|
||||
addEndpointToGroup("Security", "validate-signature");
|
||||
addEndpointToGroup("Security", "verify-pdf");
|
||||
addEndpointToGroup("Security", "stamp");
|
||||
addEndpointToGroup("Security", "sign");
|
||||
|
||||
@@ -479,6 +480,8 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "pdf-to-json");
|
||||
addEndpointToGroup("Java", "json-to-pdf");
|
||||
addEndpointToGroup("rar", "pdf-to-cbr");
|
||||
addEndpointToGroup("Java", "pdf-to-video");
|
||||
addEndpointToGroup("Java", "verify-pdf");
|
||||
|
||||
// Javascript
|
||||
addEndpointToGroup("Javascript", "pdf-organizer");
|
||||
@@ -535,6 +538,9 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Weasyprint", "markdown-to-pdf");
|
||||
addEndpointToGroup("Weasyprint", "eml-to-pdf");
|
||||
|
||||
// veraPDF dependent endpoints
|
||||
addEndpointToGroup("veraPDF", "verify-pdf");
|
||||
|
||||
// Pdftohtml dependent endpoints
|
||||
addEndpointToGroup("Pdftohtml", "pdf-to-html");
|
||||
addEndpointToGroup("Pdftohtml", "pdf-to-markdown");
|
||||
@@ -585,7 +591,10 @@ public class EndpointConfiguration {
|
||||
|| "Weasyprint".equals(group)
|
||||
|| "Pdftohtml".equals(group)
|
||||
|| "ImageMagick".equals(group)
|
||||
|| "rar".equals(group);
|
||||
|| "rar".equals(group)
|
||||
|| "Calibre".equals(group)
|
||||
|| "FFmpeg".equals(group)
|
||||
|| "veraPDF".equals(group);
|
||||
}
|
||||
|
||||
private boolean isEndpointEnabledDirectly(String endpoint) {
|
||||
|
||||
+11
-8
@@ -94,16 +94,19 @@ public class RuntimePathConfig {
|
||||
defaultSOfficePath, operations != null ? operations.getSoffice() : null);
|
||||
|
||||
// Initialize Tesseract data path
|
||||
String defaultTessDataPath =
|
||||
isDocker ? "/usr/share/tesseract-ocr/5/tessdata" : "/usr/share/tessdata";
|
||||
|
||||
// Priority: config setting > TESSDATA_PREFIX env var > default path
|
||||
String tessPath = system.getTessdataDir();
|
||||
String tessdataDir = java.lang.System.getenv("TESSDATA_PREFIX");
|
||||
String tessdataPrefix = java.lang.System.getenv("TESSDATA_PREFIX");
|
||||
String defaultPath = "/usr/share/tesseract-ocr/5/tessdata";
|
||||
|
||||
if (tessPath != null && !tessPath.isEmpty()) {
|
||||
this.tessDataPath = tessPath;
|
||||
} else if (tessdataPrefix != null && !tessdataPrefix.isEmpty()) {
|
||||
this.tessDataPath = tessdataPrefix;
|
||||
} else {
|
||||
this.tessDataPath = defaultPath;
|
||||
}
|
||||
|
||||
this.tessDataPath =
|
||||
resolvePath(
|
||||
defaultTessDataPath,
|
||||
(tessPath != null && !tessPath.isEmpty()) ? tessPath : tessdataDir);
|
||||
log.info("Using Tesseract data path: {}", this.tessDataPath);
|
||||
}
|
||||
|
||||
|
||||
@@ -393,6 +393,7 @@ public class ApplicationProperties {
|
||||
private boolean googlevisibility;
|
||||
private boolean showUpdate;
|
||||
private boolean showUpdateOnlyAdmin;
|
||||
private boolean showSettingsWhenNoLogin = true;
|
||||
private boolean customHTMLFiles;
|
||||
private String tessdataDir;
|
||||
private boolean enableAlphaFunctionality;
|
||||
@@ -416,6 +417,16 @@ public class ApplicationProperties {
|
||||
private String frontendUrl; // Frontend URL for invite email links (e.g.
|
||||
|
||||
// 'https://app.example.com'). If not set, falls back to backendUrl.
|
||||
private boolean enableMobileScanner = false; // Enable mobile phone QR code upload feature
|
||||
private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings();
|
||||
|
||||
@Data
|
||||
public static class MobileScannerSettings {
|
||||
private boolean convertToPdf = true; // Whether to automatically convert images to PDF
|
||||
private String imageResolution = "full"; // Options: "full", "reduced"
|
||||
private String pageFormat = "A4"; // Options: "keep", "A4", "letter"
|
||||
private boolean stretchToFit = false; // Whether to stretch image to fill page
|
||||
}
|
||||
|
||||
public boolean isAnalyticsEnabled() {
|
||||
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Service for handling mobile scanner file uploads and temporary storage. Files are stored
|
||||
* temporarily and automatically cleaned up after 10 minutes or upon retrieval.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class MobileScannerService {
|
||||
|
||||
private static final long SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
private final Map<String, SessionData> activeSessions = new ConcurrentHashMap<>();
|
||||
private final Path tempDirectory;
|
||||
|
||||
public MobileScannerService() throws IOException {
|
||||
// Create temp directory for mobile scanner uploads
|
||||
this.tempDirectory =
|
||||
Paths.get(System.getProperty("java.io.tmpdir"), "stirling-mobile-scanner");
|
||||
Files.createDirectories(tempDirectory);
|
||||
log.info("Mobile scanner temp directory: {}", tempDirectory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new session (called by desktop when QR code is generated)
|
||||
*
|
||||
* @param sessionId Unique session identifier
|
||||
* @return SessionInfo with creation time and expiry
|
||||
*/
|
||||
public SessionInfo createSession(String sessionId) {
|
||||
validateSessionId(sessionId);
|
||||
|
||||
SessionData session = new SessionData(sessionId);
|
||||
activeSessions.put(sessionId, session);
|
||||
|
||||
log.info("Created mobile scanner session: {}", sessionId);
|
||||
return new SessionInfo(
|
||||
sessionId,
|
||||
session.createdAt,
|
||||
session.createdAt + SESSION_TIMEOUT_MS,
|
||||
SESSION_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a session exists and is not expired
|
||||
*
|
||||
* @param sessionId Session identifier to validate
|
||||
* @return SessionInfo if valid, null if invalid/expired
|
||||
*/
|
||||
public SessionInfo validateSession(String sessionId) {
|
||||
SessionData session = activeSessions.get(sessionId);
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
long expiryTime = session.getLastAccessTime() + SESSION_TIMEOUT_MS;
|
||||
|
||||
// Check if expired
|
||||
if (now > expiryTime) {
|
||||
deleteSession(sessionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
session.updateLastAccess();
|
||||
return new SessionInfo(sessionId, session.createdAt, expiryTime, SESSION_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores uploaded files for a session
|
||||
*
|
||||
* @param sessionId Unique session identifier
|
||||
* @param files Files to upload
|
||||
* @throws IOException If file storage fails
|
||||
*/
|
||||
public void uploadFiles(String sessionId, List<MultipartFile> files) throws IOException {
|
||||
validateSessionId(sessionId);
|
||||
|
||||
SessionData session =
|
||||
activeSessions.computeIfAbsent(sessionId, id -> new SessionData(sessionId));
|
||||
|
||||
// Create session directory
|
||||
Path sessionDir = getSafeSessionDirectory(sessionId);
|
||||
Files.createDirectories(sessionDir);
|
||||
|
||||
// Save each file
|
||||
for (MultipartFile file : files) {
|
||||
if (file.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
if (originalFilename == null || originalFilename.isBlank()) {
|
||||
originalFilename = "upload-" + System.currentTimeMillis();
|
||||
}
|
||||
|
||||
// Sanitize filename
|
||||
String safeFilename = sanitizeFilename(originalFilename);
|
||||
Path filePath = sessionDir.resolve(safeFilename).normalize().toAbsolutePath();
|
||||
|
||||
// Ensure resulting path stays within the session directory
|
||||
if (!filePath.startsWith(sessionDir)) {
|
||||
throw new IOException("Invalid filename");
|
||||
}
|
||||
|
||||
// Handle duplicate filenames
|
||||
int counter = 1;
|
||||
while (Files.exists(filePath)) {
|
||||
String nameWithoutExt = safeFilename.replaceFirst("[.][^.]+$", "");
|
||||
String ext =
|
||||
safeFilename.contains(".")
|
||||
? safeFilename.substring(safeFilename.lastIndexOf("."))
|
||||
: "";
|
||||
safeFilename = nameWithoutExt + "-" + counter + ext;
|
||||
filePath = sessionDir.resolve(safeFilename).normalize().toAbsolutePath();
|
||||
if (!filePath.startsWith(sessionDir)) {
|
||||
throw new IOException("Invalid filename");
|
||||
}
|
||||
counter++;
|
||||
}
|
||||
|
||||
file.transferTo(filePath);
|
||||
session.addFile(new FileMetadata(safeFilename, file.getSize(), file.getContentType()));
|
||||
log.info(
|
||||
"Uploaded file for session {}: {} ({} bytes)",
|
||||
sessionId,
|
||||
safeFilename,
|
||||
file.getSize());
|
||||
}
|
||||
|
||||
session.updateLastAccess();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves file metadata for a session
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @return List of file metadata, or empty list if session doesn't exist
|
||||
*/
|
||||
public List<FileMetadata> getSessionFiles(String sessionId) {
|
||||
SessionData session = activeSessions.get(sessionId);
|
||||
if (session == null) {
|
||||
return List.of();
|
||||
}
|
||||
session.updateLastAccess();
|
||||
return new ArrayList<>(session.getFiles());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves actual file data for download
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @param filename Filename to retrieve
|
||||
* @return File path
|
||||
* @throws IOException If file not found or session doesn't exist
|
||||
*/
|
||||
public Path getFile(String sessionId, String filename) throws IOException {
|
||||
SessionData session = activeSessions.get(sessionId);
|
||||
if (session == null) {
|
||||
throw new IOException("Session not found: " + sessionId);
|
||||
}
|
||||
|
||||
Path filePath = getSafeFilePath(sessionId, filename);
|
||||
if (!Files.exists(filePath)) {
|
||||
throw new IOException("File not found: " + filename);
|
||||
}
|
||||
|
||||
session.updateLastAccess();
|
||||
session.markFileAsDownloaded(filename);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a file after it has been served to the client. Should be called after successful
|
||||
* download.
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @param filename Filename to delete
|
||||
*/
|
||||
public void deleteFileAfterDownload(String sessionId, String filename) {
|
||||
try {
|
||||
Path filePath = getSafeFilePath(sessionId, filename);
|
||||
Files.deleteIfExists(filePath);
|
||||
log.info("Deleted file after download: {}/{}", sessionId, filename);
|
||||
|
||||
// Check if all files have been downloaded - if so, delete the entire session
|
||||
SessionData session = activeSessions.get(sessionId);
|
||||
if (session != null && session.allFilesDownloaded()) {
|
||||
deleteSession(sessionId);
|
||||
log.info("All files downloaded - deleted session: {}", sessionId);
|
||||
}
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
log.warn("Failed to delete file after download: {}/{}", sessionId, filename, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a session and all its files
|
||||
*
|
||||
* @param sessionId Session to delete
|
||||
*/
|
||||
public void deleteSession(String sessionId) {
|
||||
SessionData session = activeSessions.remove(sessionId);
|
||||
if (session != null) {
|
||||
try {
|
||||
Path sessionDir = getSafeSessionDirectory(sessionId);
|
||||
if (Files.exists(sessionDir)) {
|
||||
// Delete all files in session directory
|
||||
Files.walk(sessionDir)
|
||||
.sorted(
|
||||
(a, b) ->
|
||||
-a.compareTo(b)) // Reverse order to delete files before
|
||||
// directory
|
||||
.forEach(
|
||||
path -> {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete file: {}", path, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
log.info("Deleted session: {}", sessionId);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn(
|
||||
"Refused to delete session with invalid sessionId '{}': {}",
|
||||
sessionId,
|
||||
e.getMessage());
|
||||
} catch (IOException e) {
|
||||
log.error("Error deleting session directory: {}", sessionId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Scheduled cleanup of expired sessions (runs every 5 minutes) */
|
||||
@Scheduled(fixedRate = 5 * 60 * 1000)
|
||||
public void cleanupExpiredSessions() {
|
||||
long now = System.currentTimeMillis();
|
||||
List<String> expiredSessions = new ArrayList<>();
|
||||
|
||||
activeSessions.forEach(
|
||||
(sessionId, session) -> {
|
||||
if (now - session.getLastAccessTime() > SESSION_TIMEOUT_MS) {
|
||||
expiredSessions.add(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
if (!expiredSessions.isEmpty()) {
|
||||
log.info("Cleaning up {} expired mobile scanner sessions", expiredSessions.size());
|
||||
expiredSessions.forEach(this::deleteSession);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateSessionId(String sessionId) {
|
||||
if (sessionId == null || sessionId.isBlank()) {
|
||||
throw new IllegalArgumentException("Session ID cannot be empty");
|
||||
}
|
||||
// Basic validation: alphanumeric and hyphens only
|
||||
if (!sessionId.matches("[a-zA-Z0-9-]+")) {
|
||||
throw new IllegalArgumentException("Invalid session ID format");
|
||||
}
|
||||
}
|
||||
|
||||
private String sanitizeFilename(String filename) {
|
||||
// Remove path traversal attempts and dangerous characters
|
||||
String sanitized = filename.replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||
// Ensure we have a non-empty, safe filename
|
||||
if (sanitized.isBlank()) {
|
||||
sanitized = "upload-" + System.currentTimeMillis();
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely resolves and validates a session directory path to prevent directory traversal
|
||||
* attacks.
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @return Normalized absolute path to the session directory
|
||||
* @throws IllegalArgumentException if the resolved path escapes the temp directory
|
||||
*/
|
||||
private Path getSafeSessionDirectory(String sessionId) {
|
||||
validateSessionId(sessionId);
|
||||
|
||||
Path baseDir = tempDirectory.normalize().toAbsolutePath();
|
||||
Path sessionDir = baseDir.resolve(sessionId).normalize().toAbsolutePath();
|
||||
|
||||
// Verify the resolved path is still within the temp directory
|
||||
if (!sessionDir.startsWith(baseDir)) {
|
||||
throw new IllegalArgumentException("Invalid session ID: path traversal detected");
|
||||
}
|
||||
|
||||
return sessionDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely resolves and validates a file path within a session directory to prevent directory
|
||||
* traversal attacks.
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @param filename Filename within the session
|
||||
* @return Normalized absolute path to the file
|
||||
* @throws IOException if the resolved path escapes the session directory
|
||||
*/
|
||||
private Path getSafeFilePath(String sessionId, String filename) throws IOException {
|
||||
if (filename == null || filename.isBlank()) {
|
||||
throw new IOException("Filename cannot be empty");
|
||||
}
|
||||
|
||||
// Additional validation: reject filenames with path separators or parent references
|
||||
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
|
||||
throw new IOException(
|
||||
"Invalid filename: contains path separators or parent references");
|
||||
}
|
||||
|
||||
Path sessionDir = getSafeSessionDirectory(sessionId);
|
||||
Path filePath = sessionDir.resolve(filename).normalize().toAbsolutePath();
|
||||
|
||||
// Verify the resolved path is still within the session directory
|
||||
if (!filePath.startsWith(sessionDir)) {
|
||||
throw new IOException("Invalid filename: path traversal detected");
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/** Session information for client */
|
||||
public static class SessionInfo {
|
||||
private final String sessionId;
|
||||
private final long createdAt;
|
||||
private final long expiresAt;
|
||||
private final long timeoutMs;
|
||||
|
||||
public SessionInfo(String sessionId, long createdAt, long expiresAt, long timeoutMs) {
|
||||
this.sessionId = sessionId;
|
||||
this.createdAt = createdAt;
|
||||
this.expiresAt = expiresAt;
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
public String getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
public long getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public long getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public long getTimeoutMs() {
|
||||
return timeoutMs;
|
||||
}
|
||||
}
|
||||
|
||||
/** File metadata for client */
|
||||
public static class FileMetadata {
|
||||
private final String filename;
|
||||
private final long size;
|
||||
private final String contentType;
|
||||
|
||||
public FileMetadata(String filename, long size, String contentType) {
|
||||
this.filename = filename;
|
||||
this.size = size;
|
||||
this.contentType = contentType;
|
||||
}
|
||||
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
|
||||
public long getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
}
|
||||
|
||||
/** Session data tracking */
|
||||
private static class SessionData {
|
||||
private final String sessionId;
|
||||
private final List<FileMetadata> files = new ArrayList<>();
|
||||
private final Map<String, Boolean> downloadedFiles = new HashMap<>();
|
||||
private final long createdAt;
|
||||
private long lastAccessTime;
|
||||
|
||||
public SessionData(String sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
this.createdAt = System.currentTimeMillis();
|
||||
this.lastAccessTime = createdAt;
|
||||
}
|
||||
|
||||
public void addFile(FileMetadata file) {
|
||||
files.add(file);
|
||||
downloadedFiles.put(file.getFilename(), false);
|
||||
}
|
||||
|
||||
public List<FileMetadata> getFiles() {
|
||||
return files;
|
||||
}
|
||||
|
||||
public void markFileAsDownloaded(String filename) {
|
||||
downloadedFiles.put(filename, true);
|
||||
}
|
||||
|
||||
public boolean allFilesDownloaded() {
|
||||
return !downloadedFiles.isEmpty()
|
||||
&& downloadedFiles.values().stream().allMatch(downloaded -> downloaded);
|
||||
}
|
||||
|
||||
public void updateLastAccess() {
|
||||
this.lastAccessTime = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
public long getLastAccessTime() {
|
||||
return lastAccessTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,12 @@ public class CbrUtils {
|
||||
new PDRectangle(pdImage.getWidth(), pdImage.getHeight()));
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(document, page)) {
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true,
|
||||
true)) {
|
||||
contentStream.drawImage(pdImage, 0, 0);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
|
||||
@@ -97,7 +97,12 @@ public class CbzUtils {
|
||||
new PDRectangle(pdImage.getWidth(), pdImage.getHeight()));
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(document, page)) {
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true,
|
||||
true)) {
|
||||
contentStream.drawImage(pdImage, 0, 0);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
|
||||
@@ -41,7 +41,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
* <pre>{@code
|
||||
* // In service layer - create exception with ExceptionUtils
|
||||
* try {
|
||||
* PDDocument doc = PDDocument.load(file);
|
||||
* PDDocument doc = Loader.loadPDF(file);
|
||||
* } catch (IOException e) {
|
||||
* throw ExceptionUtils.createPdfCorruptedException("during load", e);
|
||||
* }
|
||||
|
||||
@@ -91,6 +91,14 @@ public class GeneralUtils {
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
if (tempFile.exists()) {
|
||||
try {
|
||||
Files.delete(tempFile.toPath());
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return tempFile;
|
||||
}
|
||||
@@ -499,6 +507,12 @@ public class GeneralUtils {
|
||||
while ((bytesRead = in.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, bytesRead);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
try {
|
||||
Files.deleteIfExists(tempFile);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return tempFile.toFile();
|
||||
}
|
||||
|
||||
@@ -63,15 +63,16 @@ public class ImageProcessingUtils {
|
||||
} else {
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
int[] pixels = new int[width * height];
|
||||
|
||||
image.getRGB(0, 0, width, height, pixels, 0, width);
|
||||
|
||||
byte[] data = new byte[width * height * 3];
|
||||
int index = 0;
|
||||
for (int y = 0; y < height; y++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
int rgb = image.getRGB(x, y);
|
||||
data[index++] = (byte) ((rgb >> 16) & 0xFF); // Red
|
||||
data[index++] = (byte) ((rgb >> 8) & 0xFF); // Green
|
||||
data[index++] = (byte) (rgb & 0xFF); // Blue
|
||||
}
|
||||
for (int rgb : pixels) {
|
||||
data[index++] = (byte) ((rgb >> 16) & 0xFF); // Red
|
||||
data[index++] = (byte) ((rgb >> 8) & 0xFF); // Green
|
||||
data[index++] = (byte) (rgb & 0xFF); // Blue
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ public class PdfToCbrUtils {
|
||||
|
||||
private static byte[] createCbrFromPdf(PDDocument document, int dpi) throws IOException {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
|
||||
|
||||
Path tempDir = Files.createTempDirectory("stirling-pdf-cbr-");
|
||||
List<Path> generatedImages = new ArrayList<>();
|
||||
|
||||
@@ -55,6 +55,7 @@ public class PdfToCbzUtils {
|
||||
|
||||
private static byte[] createCbzFromPdf(PDDocument document, int dpi) throws IOException {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
|
||||
|
||||
try (ByteArrayOutputStream cbzOutputStream = new ByteArrayOutputStream();
|
||||
ZipOutputStream zipOut = new ZipOutputStream(cbzOutputStream)) {
|
||||
|
||||
@@ -119,11 +119,11 @@ public class PdfUtils {
|
||||
|
||||
public boolean hasTextOnPage(PDPage page, String phrase) throws IOException {
|
||||
PDFTextStripper textStripper = new PDFTextStripper();
|
||||
PDDocument tempDoc = new PDDocument();
|
||||
tempDoc.addPage(page);
|
||||
String pageText = textStripper.getText(tempDoc);
|
||||
tempDoc.close();
|
||||
return pageText.contains(phrase);
|
||||
try (PDDocument tempDoc = new PDDocument()) {
|
||||
tempDoc.addPage(page);
|
||||
String pageText = textStripper.getText(tempDoc);
|
||||
return pageText.contains(phrase);
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] convertFromPdf(
|
||||
@@ -153,7 +153,8 @@ public class PdfUtils {
|
||||
maxSafeDpi);
|
||||
}
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(inputStream)) {
|
||||
try (PDDocument document = pdfDocumentFactory.load(inputStream);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true);
|
||||
if (!includeAnnotations) {
|
||||
@@ -161,9 +162,6 @@ public class PdfUtils {
|
||||
}
|
||||
int pageCount = document.getNumberOfPages();
|
||||
|
||||
// Create a ByteArrayOutputStream to save the image(s) to
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
|
||||
if (singleImage) {
|
||||
if ("tiff".equals(imageType.toLowerCase(Locale.ROOT))
|
||||
|| "tif".equals(imageType.toLowerCase(Locale.ROOT))) {
|
||||
@@ -400,55 +398,61 @@ public class PdfUtils {
|
||||
*/
|
||||
public PDDocument convertPdfToPdfImage(PDDocument document) throws IOException {
|
||||
PDDocument imageDocument = new PDDocument();
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true);
|
||||
for (int page = 0; page < document.getNumberOfPages(); ++page) {
|
||||
final int pageIndex = page;
|
||||
BufferedImage bim;
|
||||
try {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true);
|
||||
for (int page = 0; page < document.getNumberOfPages(); ++page) {
|
||||
final int pageIndex = page;
|
||||
BufferedImage bim;
|
||||
|
||||
// Use global maximum DPI setting, fallback to 300 if not set
|
||||
int renderDpi = 300; // Default fallback
|
||||
ApplicationProperties properties =
|
||||
ApplicationContextProvider.getBean(ApplicationProperties.class);
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
renderDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
final int dpi = renderDpi;
|
||||
|
||||
try {
|
||||
bim =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
dpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, dpi, ImageType.RGB));
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage().contains("Maximum size of image exceeded")) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pageTooBigFor300Dpi",
|
||||
"PDF page {0} is too large to render at 300 DPI. The resulting image"
|
||||
+ " would exceed Java's maximum array size. Please use a lower DPI"
|
||||
+ " value for PDF-to-image conversion.",
|
||||
pageIndex + 1);
|
||||
// Use global maximum DPI setting, fallback to 300 if not set
|
||||
int renderDpi = 300; // Default fallback
|
||||
ApplicationProperties properties =
|
||||
ApplicationContextProvider.getBean(ApplicationProperties.class);
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
renderDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
throw e;
|
||||
final int dpi = renderDpi;
|
||||
|
||||
try {
|
||||
bim =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
dpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, dpi, ImageType.RGB));
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage().contains("Maximum size of image exceeded")) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pageTooBigFor300Dpi",
|
||||
"PDF page {0} is too large to render at 300 DPI. The resulting image"
|
||||
+ " would exceed Java's maximum array size. Please use a lower DPI"
|
||||
+ " value for PDF-to-image conversion.",
|
||||
pageIndex + 1);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
PDPage originalPage = document.getPage(page);
|
||||
|
||||
float width = originalPage.getMediaBox().getWidth();
|
||||
float height = originalPage.getMediaBox().getHeight();
|
||||
|
||||
PDPage newPage = new PDPage(new PDRectangle(width, height));
|
||||
imageDocument.addPage(newPage);
|
||||
PDImageXObject pdImage = LosslessFactory.createFromImage(imageDocument, bim);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
imageDocument, newPage, AppendMode.APPEND, true, true)) {
|
||||
contentStream.drawImage(pdImage, 0, 0, width, height);
|
||||
}
|
||||
bim.flush();
|
||||
}
|
||||
PDPage originalPage = document.getPage(page);
|
||||
|
||||
float width = originalPage.getMediaBox().getWidth();
|
||||
float height = originalPage.getMediaBox().getHeight();
|
||||
|
||||
PDPage newPage = new PDPage(new PDRectangle(width, height));
|
||||
imageDocument.addPage(newPage);
|
||||
PDImageXObject pdImage = LosslessFactory.createFromImage(imageDocument, bim);
|
||||
PDPageContentStream contentStream =
|
||||
new PDPageContentStream(imageDocument, newPage, AppendMode.APPEND, true, true);
|
||||
contentStream.drawImage(pdImage, 0, 0, width, height);
|
||||
contentStream.close();
|
||||
return imageDocument;
|
||||
} catch (Exception e) {
|
||||
throw e;
|
||||
}
|
||||
return imageDocument;
|
||||
}
|
||||
|
||||
private BufferedImage prepareImageForPdfToImage(int maxWidth, int height, String imageType) {
|
||||
|
||||
@@ -52,6 +52,11 @@ public class RequestUriUtils {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mobile scanner page for QR code-based file uploads (peer-to-peer, no backend auth needed)
|
||||
if (normalizedUri.startsWith("/mobile-scanner")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Treat common static file extensions as static resources
|
||||
return normalizedUri.endsWith(".svg")
|
||||
|| normalizedUri.endsWith(".png")
|
||||
@@ -168,6 +173,8 @@ public class RequestUriUtils {
|
||||
"/api/v1/ui-data/footer-info") // Public footer configuration
|
||||
|| trimmedUri.startsWith("/api/v1/invite/validate")
|
||||
|| trimmedUri.startsWith("/api/v1/invite/accept")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|
||||
|| trimmedUri.startsWith("/v1/api-docs");
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ public class WebResponseUtils {
|
||||
// Open Byte Array and save document to it
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
document.close();
|
||||
|
||||
return baosToWebResponse(baos, docName);
|
||||
}
|
||||
|
||||
+6
-1
@@ -146,13 +146,18 @@ public class CustomColorReplaceStrategy extends ReplaceAndInvertColorStrategy {
|
||||
// Save the modified PDF to a ByteArrayOutputStream
|
||||
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
|
||||
document.save(byteArrayOutputStream);
|
||||
document.close();
|
||||
|
||||
// Prepare the modified PDF for download
|
||||
ByteArrayInputStream inputStream =
|
||||
new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
|
||||
InputStreamResource resource = new InputStreamResource(inputStream);
|
||||
return resource;
|
||||
} finally {
|
||||
try {
|
||||
Files.deleteIfExists(file.toPath());
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temporary file: {}", file.getAbsolutePath(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+122
-76
@@ -7,6 +7,7 @@ import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
@@ -19,11 +20,14 @@ import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.api.misc.ReplaceAndInvert;
|
||||
import stirling.software.common.util.ApplicationContextProvider;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
@Slf4j
|
||||
public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
|
||||
|
||||
public InvertFullColorStrategy(MultipartFile file, ReplaceAndInvert replaceAndInvert) {
|
||||
@@ -32,83 +36,101 @@ public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
|
||||
|
||||
@Override
|
||||
public InputStreamResource replace() throws IOException {
|
||||
|
||||
File file = null;
|
||||
try {
|
||||
// Create a temporary file, with the original filename from the multipart file
|
||||
file = Files.createTempFile("temp", getFileInput().getOriginalFilename()).toFile();
|
||||
|
||||
try (TempFile tempFile =
|
||||
new TempFile(
|
||||
Files.createTempFile("temp", getFileInput().getOriginalFilename())
|
||||
.toFile())) {
|
||||
// Transfer the content of the multipart file to the file
|
||||
getFileInput().transferTo(file);
|
||||
getFileInput().transferTo(tempFile.getFile());
|
||||
|
||||
// Load the uploaded PDF
|
||||
PDDocument document = Loader.loadPDF(file);
|
||||
try (PDDocument document = Loader.loadPDF(tempFile.getFile())) {
|
||||
// Render each page and invert colors
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(
|
||||
true); // Enable subsampling to reduce memory usage
|
||||
for (int page = 0; page < document.getNumberOfPages(); page++) {
|
||||
BufferedImage image;
|
||||
|
||||
// Render each page and invert colors
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
for (int page = 0; page < document.getNumberOfPages(); page++) {
|
||||
BufferedImage image;
|
||||
// Use global maximum DPI setting, fallback to 300 if not set
|
||||
int renderDpi = 300; // Default fallback
|
||||
ApplicationProperties properties =
|
||||
ApplicationContextProvider.getBean(ApplicationProperties.class);
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
renderDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
final int dpi = renderDpi;
|
||||
final int pageNum = page;
|
||||
|
||||
// Use global maximum DPI setting, fallback to 300 if not set
|
||||
int renderDpi = 300; // Default fallback
|
||||
ApplicationProperties properties =
|
||||
ApplicationContextProvider.getBean(ApplicationProperties.class);
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
renderDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
final int dpi = renderDpi;
|
||||
final int pageNum = page;
|
||||
image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageNum + 1,
|
||||
dpi,
|
||||
() -> pdfRenderer.renderImageWithDPI(pageNum, dpi));
|
||||
|
||||
image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageNum + 1,
|
||||
dpi,
|
||||
() -> pdfRenderer.renderImageWithDPI(pageNum, dpi));
|
||||
// Invert the colors
|
||||
invertImageColors(image);
|
||||
|
||||
// Invert the colors
|
||||
invertImageColors(image);
|
||||
// Create a new PDPage from the inverted image
|
||||
PDPage pdPage = document.getPage(page);
|
||||
File tempImageFile = null;
|
||||
try {
|
||||
tempImageFile = convertToBufferedImageTpFile(image);
|
||||
PDImageXObject pdImage =
|
||||
PDImageXObject.createFromFileByContent(tempImageFile, document);
|
||||
|
||||
// Create a new PDPage from the inverted image
|
||||
PDPage pdPage = document.getPage(page);
|
||||
File tempImageFile = null;
|
||||
try {
|
||||
tempImageFile = convertToBufferedImageTpFile(image);
|
||||
PDImageXObject pdImage =
|
||||
PDImageXObject.createFromFileByContent(tempImageFile, document);
|
||||
// Delete temp file immediately after loading into memory to prevent disk
|
||||
// exhaustion
|
||||
// The file content is now in the PDImageXObject, so the file is no longer
|
||||
// needed
|
||||
try {
|
||||
Files.deleteIfExists(tempImageFile.toPath());
|
||||
tempImageFile = null; // Mark as deleted to avoid double deletion
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Failed to delete temporary image file: {}",
|
||||
tempImageFile.getAbsolutePath(),
|
||||
e);
|
||||
}
|
||||
|
||||
PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
pdPage,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true);
|
||||
contentStream.drawImage(
|
||||
pdImage,
|
||||
0,
|
||||
0,
|
||||
pdPage.getMediaBox().getWidth(),
|
||||
pdPage.getMediaBox().getHeight());
|
||||
contentStream.close();
|
||||
} finally {
|
||||
if (tempImageFile != null && tempImageFile.exists()) {
|
||||
Files.delete(tempImageFile.toPath());
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
pdPage,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true,
|
||||
true)) { // resetContext=true ensures clean graphics state
|
||||
contentStream.drawImage(
|
||||
pdImage,
|
||||
0,
|
||||
0,
|
||||
pdPage.getMediaBox().getWidth(),
|
||||
pdPage.getMediaBox().getHeight());
|
||||
}
|
||||
} finally {
|
||||
// Safety net: ensure temp file is deleted even if an exception occurred
|
||||
if (tempImageFile != null && tempImageFile.exists()) {
|
||||
try {
|
||||
Files.deleteIfExists(tempImageFile.toPath());
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Failed to delete temporary image file: {}",
|
||||
tempImageFile.getAbsolutePath(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save the modified PDF to a ByteArrayOutputStream
|
||||
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
|
||||
document.save(byteArrayOutputStream);
|
||||
document.close();
|
||||
// Save the modified PDF to a ByteArrayOutputStream
|
||||
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
|
||||
document.save(byteArrayOutputStream);
|
||||
|
||||
// Prepare the modified PDF for download
|
||||
ByteArrayInputStream inputStream =
|
||||
new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
|
||||
InputStreamResource resource = new InputStreamResource(inputStream);
|
||||
return resource;
|
||||
} finally {
|
||||
if (file != null && file.exists()) {
|
||||
Files.delete(file.toPath());
|
||||
// Prepare the modified PDF for download
|
||||
ByteArrayInputStream inputStream =
|
||||
new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
|
||||
InputStreamResource resource = new InputStreamResource(inputStream);
|
||||
return resource;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,24 +139,48 @@ public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
|
||||
private void invertImageColors(BufferedImage image) {
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
for (int x = 0; x < width; x++) {
|
||||
for (int y = 0; y < height; y++) {
|
||||
int rgba = image.getRGB(x, y);
|
||||
Color color = new Color(rgba, true);
|
||||
Color invertedColor =
|
||||
new Color(
|
||||
255 - color.getRed(),
|
||||
255 - color.getGreen(),
|
||||
255 - color.getBlue());
|
||||
image.setRGB(x, y, invertedColor.getRGB());
|
||||
}
|
||||
int[] pixels = new int[width * height];
|
||||
image.getRGB(0, 0, width, height, pixels, 0, width);
|
||||
for (int i = 0; i < pixels.length; i++) {
|
||||
int pixel = pixels[i];
|
||||
|
||||
int a = 0xff;
|
||||
|
||||
int r = (pixel >> 16) & 0xff;
|
||||
int g = (pixel >> 8) & 0xff;
|
||||
int b = pixel & 0xff;
|
||||
|
||||
pixels[i] = (a << 24) | ((255 - r) << 16) | ((255 - g) << 8) | (255 - b);
|
||||
}
|
||||
image.setRGB(0, 0, width, height, pixels, 0, width);
|
||||
}
|
||||
|
||||
// Helper method to convert BufferedImage to InputStream
|
||||
private File convertToBufferedImageTpFile(BufferedImage image) throws IOException {
|
||||
File file = File.createTempFile("image", ".png");
|
||||
// Use Files.createTempFile instead of File.createTempFile for better security and modern
|
||||
// Java practices
|
||||
Path tempPath = Files.createTempFile("image", ".png");
|
||||
File file = tempPath.toFile();
|
||||
ImageIO.write(image, "png", file);
|
||||
return file;
|
||||
}
|
||||
|
||||
private static class TempFile implements AutoCloseable {
|
||||
private final File file;
|
||||
|
||||
public TempFile(File file) {
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
public File getFile() {
|
||||
return file;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (file != null && file.exists()) {
|
||||
Files.delete(file.toPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,8 +92,7 @@ public class WebResponseUtilsTest {
|
||||
|
||||
@Test
|
||||
public void testPdfDocToWebResponse() {
|
||||
try {
|
||||
PDDocument document = new PDDocument();
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
document.addPage(new org.apache.pdfbox.pdmodel.PDPage());
|
||||
String docName = "sample.pdf";
|
||||
|
||||
|
||||
+22
-3
@@ -71,6 +71,13 @@ dependencies {
|
||||
implementation "org.apache.pdfbox:preflight:$pdfboxVersion"
|
||||
implementation "org.apache.pdfbox:xmpbox:$pdfboxVersion"
|
||||
|
||||
implementation 'org.verapdf:validation-model:1.28.2'
|
||||
|
||||
// veraPDF still uses javax.xml.bind, not the new jakarta namespace
|
||||
implementation 'javax.xml.bind:jaxb-api:2.3.1'
|
||||
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
|
||||
implementation 'com.sun.xml.bind:jaxb-core:2.3.0.1'
|
||||
|
||||
// https://mvnrepository.com/artifact/technology.tabula/tabula
|
||||
implementation ('technology.tabula:tabula:1.0.5') {
|
||||
exclude group: 'org.slf4j', module: 'slf4j-simple'
|
||||
@@ -268,6 +275,18 @@ tasks.register('cleanFrontendAssets', Delete) {
|
||||
delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) }
|
||||
}
|
||||
|
||||
tasks.register('copyApiLandingPage', Copy) {
|
||||
group = 'frontend'
|
||||
description = 'Copy API landing page to index.html for backend-only mode'
|
||||
from(new File(resourcesStaticDir, 'api-landing.html'))
|
||||
into(resourcesStaticDir)
|
||||
rename('api-landing.html', 'index.html')
|
||||
dependsOn cleanFrontendAssets
|
||||
doFirst {
|
||||
println "Copying API landing page to index.html for backend-only mode..."
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure copyFrontendAssets runs after spotless tasks
|
||||
tasks.named('copyFrontendAssets').configure {
|
||||
mustRunAfter tasks.matching { it.name.startsWith('spotless') }
|
||||
@@ -277,9 +296,9 @@ if (buildWithFrontend) {
|
||||
println "Frontend build enabled - JAR will include React frontend"
|
||||
processResources.dependsOn copyFrontendAssets
|
||||
} else {
|
||||
println "Frontend build disabled - JAR will be backend-only"
|
||||
// When not building the UI, ensure any stale frontend assets are removed
|
||||
processResources.dependsOn cleanFrontendAssets
|
||||
println "Frontend build disabled - JAR will be backend-only with API landing page"
|
||||
// When not building the UI, ensure any stale frontend assets are removed and use API landing page
|
||||
processResources.dependsOn copyApiLandingPage
|
||||
}
|
||||
|
||||
bootJar.dependsOn ':common:jar'
|
||||
|
||||
@@ -141,10 +141,10 @@ public class SPDFApplication {
|
||||
String backendUrl = appConfig.getBackendUrl();
|
||||
String contextPath = appConfig.getContextPath();
|
||||
String serverPort = appConfig.getServerPort();
|
||||
baseUrlStatic = backendUrl;
|
||||
baseUrlStatic = normalizeBackendUrl(backendUrl, serverPort);
|
||||
contextPathStatic = contextPath;
|
||||
serverPortStatic = serverPort;
|
||||
String url = backendUrl + ":" + getStaticPort() + contextPath;
|
||||
String url = buildFullUrl(baseUrlStatic, getStaticPort(), contextPathStatic);
|
||||
|
||||
// Log Tauri mode information
|
||||
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
|
||||
@@ -210,7 +210,7 @@ public class SPDFApplication {
|
||||
|
||||
private static void printStartupLogs() {
|
||||
log.info("Stirling-PDF Started.");
|
||||
String url = baseUrlStatic + ":" + getStaticPort() + contextPathStatic;
|
||||
String url = buildFullUrl(baseUrlStatic, getStaticPort(), contextPathStatic);
|
||||
log.info("Navigate to {}", url);
|
||||
}
|
||||
|
||||
@@ -258,4 +258,79 @@ public class SPDFApplication {
|
||||
public static String getStaticContextPath() {
|
||||
return contextPathStatic;
|
||||
}
|
||||
|
||||
private static String buildFullUrl(String backendUrl, String port, String contextPath) {
|
||||
String normalizedBase = normalizeBackendUrl(backendUrl, port);
|
||||
|
||||
String normalizedContextPath =
|
||||
(contextPath == null || contextPath.isBlank() || "/".equals(contextPath))
|
||||
? "/"
|
||||
: (contextPath.startsWith("/") ? contextPath : "/" + contextPath);
|
||||
|
||||
return normalizedBase + normalizedContextPath;
|
||||
}
|
||||
|
||||
private static String normalizeBackendUrl(String backendUrl, String port) {
|
||||
String trimmedBase =
|
||||
(backendUrl == null || backendUrl.isBlank())
|
||||
? "http://localhost"
|
||||
: backendUrl.trim().replaceAll("/+$", "");
|
||||
boolean hasScheme = trimmedBase.matches("^[a-zA-Z][a-zA-Z0-9+.-]*://.*");
|
||||
String baseForParsing = hasScheme ? trimmedBase : "http://" + trimmedBase;
|
||||
Integer parsedPort = parsePort(port);
|
||||
|
||||
try {
|
||||
java.net.URI uri = new java.net.URI(baseForParsing);
|
||||
String scheme = uri.getScheme() == null ? "http" : uri.getScheme();
|
||||
String host = uri.getHost();
|
||||
if (host == null) {
|
||||
return appendPortFallback(trimmedBase, parsedPort);
|
||||
}
|
||||
|
||||
boolean defaultHttp =
|
||||
"http".equalsIgnoreCase(scheme) && Integer.valueOf(80).equals(parsedPort);
|
||||
boolean defaultHttps =
|
||||
"https".equalsIgnoreCase(scheme) && Integer.valueOf(443).equals(parsedPort);
|
||||
|
||||
int effectivePort = uri.getPort();
|
||||
if (effectivePort == -1 && parsedPort != null && !defaultHttp && !defaultHttps) {
|
||||
effectivePort = parsedPort;
|
||||
}
|
||||
|
||||
java.net.URI rebuilt =
|
||||
new java.net.URI(
|
||||
scheme,
|
||||
uri.getUserInfo(),
|
||||
host,
|
||||
effectivePort,
|
||||
uri.getPath(),
|
||||
uri.getQuery(),
|
||||
uri.getFragment());
|
||||
return rebuilt.toString();
|
||||
} catch (java.net.URISyntaxException e) {
|
||||
return appendPortFallback(trimmedBase, parsedPort);
|
||||
}
|
||||
}
|
||||
|
||||
private static Integer parsePort(String port) {
|
||||
if (port == null || port.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
int parsed = Integer.parseInt(port);
|
||||
return parsed > 0 ? parsed : null;
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String appendPortFallback(String trimmedBase, Integer port) {
|
||||
if (port == null) {
|
||||
return trimmedBase;
|
||||
}
|
||||
if (trimmedBase.matches(".+:\\d+$")) {
|
||||
return trimmedBase;
|
||||
}
|
||||
return trimmedBase + ":" + port;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
|
||||
"principal",
|
||||
"startDate",
|
||||
"endDate",
|
||||
"async");
|
||||
"async",
|
||||
"session");
|
||||
|
||||
@Override
|
||||
public boolean preHandle(
|
||||
|
||||
+25
-24
@@ -29,6 +29,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import stirling.software.SPDF.model.api.general.BookletImpositionRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@@ -68,33 +69,33 @@ public class BookletImpositionController {
|
||||
"Booklet printing uses 2 pages per side (landscape). For 4-up, use the N-up feature.");
|
||||
}
|
||||
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
|
||||
// Create proper booklet with signature-based page ordering
|
||||
PDDocument newDocument =
|
||||
createSaddleBooklet(
|
||||
sourceDocument,
|
||||
totalPages,
|
||||
addBorder,
|
||||
spineLocation,
|
||||
addGutter,
|
||||
gutterSize,
|
||||
doubleSided,
|
||||
duplexPass,
|
||||
flipOnShortEdge);
|
||||
// Create proper booklet with signature-based page ordering
|
||||
try (PDDocument newDocument =
|
||||
createSaddleBooklet(
|
||||
sourceDocument,
|
||||
totalPages,
|
||||
addBorder,
|
||||
spineLocation,
|
||||
addGutter,
|
||||
gutterSize,
|
||||
doubleSided,
|
||||
duplexPass,
|
||||
flipOnShortEdge)) {
|
||||
|
||||
sourceDocument.close();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
newDocument.close();
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
Filenames.toSimpleFileName(file.getOriginalFilename()).replaceFirst("[.][^.]+$", "")
|
||||
+ "_booklet.pdf");
|
||||
byte[] result = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
GeneralUtils.generateFilename(
|
||||
Filenames.toSimpleFileName(file.getOriginalFilename()),
|
||||
"_booklet.pdf"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int padToMultipleOf4(int n) {
|
||||
|
||||
@@ -155,6 +155,7 @@ public class CropController {
|
||||
try (PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
|
||||
PDFRenderer renderer = new PDFRenderer(sourceDocument);
|
||||
renderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
|
||||
for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package stirling.software.SPDF.controller.api;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
@@ -27,7 +28,6 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
@@ -57,12 +57,20 @@ public class MergeController {
|
||||
// Merges a list of PDDocument objects into a single PDDocument
|
||||
public PDDocument mergeDocuments(List<PDDocument> documents) throws IOException {
|
||||
PDDocument mergedDoc = pdfDocumentFactory.createNewDocument();
|
||||
for (PDDocument doc : documents) {
|
||||
for (PDPage page : doc.getPages()) {
|
||||
mergedDoc.addPage(page);
|
||||
boolean success = false;
|
||||
try {
|
||||
for (PDDocument doc : documents) {
|
||||
for (PDPage page : doc.getPages()) {
|
||||
mergedDoc.addPage(page);
|
||||
}
|
||||
}
|
||||
success = true;
|
||||
return mergedDoc;
|
||||
} finally {
|
||||
if (!success) {
|
||||
mergedDoc.close();
|
||||
}
|
||||
}
|
||||
return mergedDoc;
|
||||
}
|
||||
|
||||
// Re-order files to match the explicit order provided by the front-end.
|
||||
@@ -269,7 +277,7 @@ public class MergeController {
|
||||
"This endpoint merges multiple PDF files into a single PDF file. The merged"
|
||||
+ " file will contain all pages from the input files in the order they were"
|
||||
+ " provided. Input:PDF Output:PDF Type:MISO")
|
||||
public ResponseEntity<StreamingResponseBody> mergePdfs(
|
||||
public ResponseEntity<byte[]> mergePdfs(
|
||||
@ModelAttribute MergePdfsRequest request,
|
||||
@RequestParam(value = "fileOrder", required = false) String fileOrder)
|
||||
throws IOException {
|
||||
@@ -296,8 +304,6 @@ public class MergeController {
|
||||
request.getSortType())); // Sort files based on requested sort type
|
||||
}
|
||||
|
||||
ResponseEntity<StreamingResponseBody> response;
|
||||
|
||||
try (TempFile mt = new TempFile(tempFileManager, ".pdf")) {
|
||||
|
||||
PDFMergerUtility mergerUtility = new PDFMergerUtility();
|
||||
@@ -363,9 +369,18 @@ public class MergeController {
|
||||
|
||||
// Save the modified document to a temporary file
|
||||
outputTempFile = new TempFile(tempFileManager, ".pdf");
|
||||
mergedDocument.save(outputTempFile.getFile());
|
||||
try {
|
||||
mergedDocument.save(outputTempFile.getFile());
|
||||
} catch (Exception e) {
|
||||
outputTempFile.close();
|
||||
outputTempFile = null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
if (outputTempFile != null) {
|
||||
outputTempFile.close();
|
||||
}
|
||||
if (ex instanceof IOException && PdfErrorUtils.isCorruptedPdfError((IOException) ex)) {
|
||||
log.warn("Corrupted PDF detected in merge pdf process: {}", ex.getMessage());
|
||||
} else {
|
||||
@@ -382,7 +397,7 @@ public class MergeController {
|
||||
String mergedFileName =
|
||||
GeneralUtils.generateFilename(firstFilename, "_merged_unsigned.pdf");
|
||||
|
||||
response = WebResponseUtils.pdfFileToWebResponse(outputTempFile, mergedFileName);
|
||||
return response;
|
||||
byte[] pdfBytes = Files.readAllBytes(outputTempFile.getPath());
|
||||
return WebResponseUtils.bytesToWebResponse(pdfBytes, mergedFileName);
|
||||
}
|
||||
}
|
||||
|
||||
+98
-97
@@ -70,108 +70,109 @@ public class MultiPageLayoutController {
|
||||
: (int) Math.sqrt(pagesPerSheet);
|
||||
int rows = pagesPerSheet == 2 || pagesPerSheet == 3 ? 1 : (int) Math.sqrt(pagesPerSheet);
|
||||
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
PDPage newPage = new PDPage(PDRectangle.A4);
|
||||
newDocument.addPage(newPage);
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
|
||||
try (PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
float cellWidth = newPage.getMediaBox().getWidth() / cols;
|
||||
float cellHeight = newPage.getMediaBox().getHeight() / rows;
|
||||
// Calculate cell dimensions once (all output pages are A4) - declare outside try
|
||||
// blocks
|
||||
float cellWidth = PDRectangle.A4.getWidth() / cols;
|
||||
float cellHeight = PDRectangle.A4.getHeight() / rows;
|
||||
|
||||
PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
newDocument, newPage, PDPageContentStream.AppendMode.APPEND, true, true);
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
// Process pages in groups of pagesPerSheet, creating a new page and content stream
|
||||
// for each group
|
||||
for (int i = 0; i < totalPages; i += pagesPerSheet) {
|
||||
// Create a new output page for each group of pagesPerSheet
|
||||
PDPage newPage = new PDPage(PDRectangle.A4);
|
||||
newDocument.addPage(newPage);
|
||||
|
||||
float borderThickness = 1.5f; // Specify border thickness as required
|
||||
contentStream.setLineWidth(borderThickness);
|
||||
contentStream.setStrokingColor(Color.BLACK);
|
||||
// Use try-with-resources for each content stream to ensure proper cleanup
|
||||
// resetContext=true: Start with a clean graphics state for new content
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
newDocument,
|
||||
newPage,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true)) {
|
||||
float borderThickness = 1.5f; // Specify border thickness as required
|
||||
contentStream.setLineWidth(borderThickness);
|
||||
contentStream.setStrokingColor(Color.BLACK);
|
||||
|
||||
for (int i = 0; i < totalPages; i++) {
|
||||
if (i != 0 && i % pagesPerSheet == 0) {
|
||||
// Close the current content stream and create a new page and content stream
|
||||
contentStream.close();
|
||||
newPage = new PDPage(PDRectangle.A4);
|
||||
newDocument.addPage(newPage);
|
||||
contentStream =
|
||||
new PDPageContentStream(
|
||||
// Process all pages in this group
|
||||
for (int j = 0; j < pagesPerSheet && (i + j) < totalPages; j++) {
|
||||
int pageIndex = i + j;
|
||||
PDPage sourcePage = sourceDocument.getPage(pageIndex);
|
||||
PDRectangle rect = sourcePage.getMediaBox();
|
||||
float scaleWidth = cellWidth / rect.getWidth();
|
||||
float scaleHeight = cellHeight / rect.getHeight();
|
||||
float scale = Math.min(scaleWidth, scaleHeight);
|
||||
|
||||
int adjustedPageIndex = j % pagesPerSheet;
|
||||
int rowIndex = adjustedPageIndex / cols;
|
||||
int colIndex = adjustedPageIndex % cols;
|
||||
|
||||
float x =
|
||||
colIndex * cellWidth
|
||||
+ (cellWidth - rect.getWidth() * scale) / 2;
|
||||
float y =
|
||||
newPage.getMediaBox().getHeight()
|
||||
- ((rowIndex + 1) * cellHeight
|
||||
- (cellHeight - rect.getHeight() * scale) / 2);
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.transform(Matrix.getTranslateInstance(x, y));
|
||||
contentStream.transform(Matrix.getScaleInstance(scale, scale));
|
||||
|
||||
PDFormXObject formXObject =
|
||||
layerUtility.importPageAsForm(sourceDocument, pageIndex);
|
||||
contentStream.drawForm(formXObject);
|
||||
|
||||
contentStream.restoreGraphicsState();
|
||||
|
||||
if (addBorder) {
|
||||
// Draw border around each page
|
||||
float borderX = colIndex * cellWidth;
|
||||
float borderY =
|
||||
newPage.getMediaBox().getHeight()
|
||||
- (rowIndex + 1) * cellHeight;
|
||||
contentStream.addRect(borderX, borderY, cellWidth, cellHeight);
|
||||
contentStream.stroke();
|
||||
}
|
||||
}
|
||||
} // contentStream is automatically closed here
|
||||
}
|
||||
|
||||
// If any source page is rotated, skip form copying/transformation entirely
|
||||
boolean hasRotation = GeneralFormCopyUtils.hasAnyRotatedPage(sourceDocument);
|
||||
if (hasRotation) {
|
||||
log.info("Source document has rotated pages; skipping form field copying.");
|
||||
} else {
|
||||
try {
|
||||
GeneralFormCopyUtils.copyAndTransformFormFields(
|
||||
sourceDocument,
|
||||
newDocument,
|
||||
newPage,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true);
|
||||
}
|
||||
totalPages,
|
||||
pagesPerSheet,
|
||||
cols,
|
||||
rows,
|
||||
cellWidth,
|
||||
cellHeight);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to copy and transform form fields: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
PDPage sourcePage = sourceDocument.getPage(i);
|
||||
PDRectangle rect = sourcePage.getMediaBox();
|
||||
float scaleWidth = cellWidth / rect.getWidth();
|
||||
float scaleHeight = cellHeight / rect.getHeight();
|
||||
float scale = Math.min(scaleWidth, scaleHeight);
|
||||
|
||||
int adjustedPageIndex =
|
||||
i % pagesPerSheet; // Close the current content stream and create a new
|
||||
// page and content stream
|
||||
int rowIndex = adjustedPageIndex / cols;
|
||||
int colIndex = adjustedPageIndex % cols;
|
||||
|
||||
float x = colIndex * cellWidth + (cellWidth - rect.getWidth() * scale) / 2;
|
||||
float y =
|
||||
newPage.getMediaBox().getHeight()
|
||||
- ((rowIndex + 1) * cellHeight
|
||||
- (cellHeight - rect.getHeight() * scale) / 2);
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.transform(Matrix.getTranslateInstance(x, y));
|
||||
contentStream.transform(Matrix.getScaleInstance(scale, scale));
|
||||
|
||||
PDFormXObject formXObject = layerUtility.importPageAsForm(sourceDocument, i);
|
||||
contentStream.drawForm(formXObject);
|
||||
|
||||
contentStream.restoreGraphicsState();
|
||||
|
||||
if (addBorder) {
|
||||
// Draw border around each page
|
||||
float borderX = colIndex * cellWidth;
|
||||
float borderY = newPage.getMediaBox().getHeight() - (rowIndex + 1) * cellHeight;
|
||||
contentStream.addRect(borderX, borderY, cellWidth, cellHeight);
|
||||
contentStream.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
contentStream.close();
|
||||
|
||||
// If any source page is rotated, skip form copying/transformation entirely
|
||||
boolean hasRotation = GeneralFormCopyUtils.hasAnyRotatedPage(sourceDocument);
|
||||
if (hasRotation) {
|
||||
log.info("Source document has rotated pages; skipping form field copying.");
|
||||
} else {
|
||||
try {
|
||||
GeneralFormCopyUtils.copyAndTransformFormFields(
|
||||
sourceDocument,
|
||||
newDocument,
|
||||
totalPages,
|
||||
pagesPerSheet,
|
||||
cols,
|
||||
rows,
|
||||
cellWidth,
|
||||
cellHeight);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to copy and transform form fields: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
sourceDocument.close();
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
newDocument.close();
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
GeneralUtils.generateFilename(
|
||||
file.getOriginalFilename(), "_multi_page_layout.pdf"));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
byte[] result = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
GeneralUtils.generateFilename(
|
||||
file.getOriginalFilename(), "_multi_page_layout.pdf"));
|
||||
} // newDocument is closed here
|
||||
} // sourceDocument is closed here
|
||||
}
|
||||
}
|
||||
|
||||
+18
-15
@@ -53,25 +53,28 @@ public class PdfImageRemovalController {
|
||||
"This endpoint remove images from file to reduce the file size.Input:PDF"
|
||||
+ " Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> removeImages(@ModelAttribute PDFFile file) throws IOException {
|
||||
// Load the PDF document
|
||||
PDDocument document = pdfDocumentFactory.load(file);
|
||||
// Load the PDF document with proper resource management
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
|
||||
// Remove images from the PDF document using the service
|
||||
PDDocument modifiedDocument = pdfImageRemovalService.removeImagesFromPdf(document);
|
||||
// Remove images from the PDF document using the service
|
||||
try (PDDocument modifiedDocument =
|
||||
pdfImageRemovalService.removeImagesFromPdf(document)) {
|
||||
|
||||
// Create a ByteArrayOutputStream to hold the modified PDF data
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
// Create a ByteArrayOutputStream to hold the modified PDF data
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
|
||||
// Save the modified PDF document to the output stream
|
||||
modifiedDocument.save(outputStream);
|
||||
modifiedDocument.close();
|
||||
// Save the modified PDF document to the output stream
|
||||
modifiedDocument.save(outputStream);
|
||||
|
||||
// Generate a new filename for the modified PDF
|
||||
String mergedFileName =
|
||||
GeneralUtils.generateFilename(
|
||||
file.getFileInput().getOriginalFilename(), "_images_removed.pdf");
|
||||
// Generate a new filename for the modified PDF
|
||||
String mergedFileName =
|
||||
GeneralUtils.generateFilename(
|
||||
file.getFileInput().getOriginalFilename(), "_images_removed.pdf");
|
||||
|
||||
// Convert the byte array to a web response and return it
|
||||
return WebResponseUtils.bytesToWebResponse(outputStream.toByteArray(), mergedFileName);
|
||||
// Convert the byte array to a web response and return it
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
outputStream.toByteArray(), mergedFileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+49
-45
@@ -50,23 +50,25 @@ public class RearrangePagesPDFController {
|
||||
MultipartFile pdfFile = request.getFileInput();
|
||||
String pagesToDelete = request.getPageNumbers();
|
||||
|
||||
PDDocument document = pdfDocumentFactory.load(pdfFile);
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
|
||||
|
||||
// Split the page order string into an array of page numbers or range of numbers
|
||||
String[] pageOrderArr = pagesToDelete.split(",");
|
||||
// Split the page order string into an array of page numbers or range of numbers
|
||||
String[] pageOrderArr = pagesToDelete.split(",");
|
||||
|
||||
List<Integer> pagesToRemove =
|
||||
GeneralUtils.parsePageList(pageOrderArr, document.getNumberOfPages(), false);
|
||||
List<Integer> pagesToRemove =
|
||||
GeneralUtils.parsePageList(pageOrderArr, document.getNumberOfPages(), false);
|
||||
|
||||
Collections.sort(pagesToRemove);
|
||||
Collections.sort(pagesToRemove);
|
||||
|
||||
for (int i = pagesToRemove.size() - 1; i >= 0; i--) {
|
||||
int pageIndex = pagesToRemove.get(i);
|
||||
document.removePage(pageIndex);
|
||||
for (int i = pagesToRemove.size() - 1; i >= 0; i--) {
|
||||
int pageIndex = pagesToRemove.get(i);
|
||||
document.removePage(pageIndex);
|
||||
}
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
pdfFile.getOriginalFilename(), "_removed_pages.pdf"));
|
||||
}
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_removed_pages.pdf"));
|
||||
}
|
||||
|
||||
private List<Integer> removeFirst(int totalPages) {
|
||||
@@ -243,41 +245,43 @@ public class RearrangePagesPDFController {
|
||||
String pageOrder = request.getPageNumbers();
|
||||
String sortType = request.getCustomMode();
|
||||
try {
|
||||
// Load the input PDF
|
||||
PDDocument document = pdfDocumentFactory.load(pdfFile);
|
||||
// Load the input PDF with proper resource management
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
|
||||
|
||||
// Split the page order string into an array of page numbers or range of numbers
|
||||
String[] pageOrderArr = pageOrder != null ? pageOrder.split(",") : new String[0];
|
||||
int totalPages = document.getNumberOfPages();
|
||||
List<Integer> newPageOrder;
|
||||
if (sortType != null
|
||||
&& !sortType.isEmpty()
|
||||
&& !"custom".equals(sortType.toLowerCase(Locale.ROOT))) {
|
||||
newPageOrder = processSortTypes(sortType, totalPages, pageOrder);
|
||||
} else {
|
||||
newPageOrder = GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
|
||||
// Split the page order string into an array of page numbers or range of numbers
|
||||
String[] pageOrderArr = pageOrder != null ? pageOrder.split(",") : new String[0];
|
||||
int totalPages = document.getNumberOfPages();
|
||||
List<Integer> newPageOrder;
|
||||
if (sortType != null
|
||||
&& !sortType.isEmpty()
|
||||
&& !"custom".equals(sortType.toLowerCase(Locale.ROOT))) {
|
||||
newPageOrder = processSortTypes(sortType, totalPages, pageOrder);
|
||||
} else {
|
||||
newPageOrder = GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
|
||||
}
|
||||
log.info("newPageOrder = {}", newPageOrder);
|
||||
log.info("totalPages = {}", totalPages);
|
||||
// Create a new list to hold the pages in the new order
|
||||
List<PDPage> newPages = new ArrayList<>();
|
||||
for (int i = 0; i < newPageOrder.size(); i++) {
|
||||
newPages.add(document.getPage(newPageOrder.get(i)));
|
||||
}
|
||||
|
||||
// Create a new document based on the original one
|
||||
try (PDDocument rearrangedDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document)) {
|
||||
|
||||
// Add the pages in the new order
|
||||
for (PDPage page : newPages) {
|
||||
rearrangedDocument.addPage(page);
|
||||
}
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
rearrangedDocument,
|
||||
GeneralUtils.generateFilename(
|
||||
pdfFile.getOriginalFilename(), "_rearranged.pdf"));
|
||||
}
|
||||
}
|
||||
log.info("newPageOrder = {}", newPageOrder);
|
||||
log.info("totalPages = {}", totalPages);
|
||||
// Create a new list to hold the pages in the new order
|
||||
List<PDPage> newPages = new ArrayList<>();
|
||||
for (int i = 0; i < newPageOrder.size(); i++) {
|
||||
newPages.add(document.getPage(newPageOrder.get(i)));
|
||||
}
|
||||
|
||||
// Create a new document based on the original one
|
||||
PDDocument rearrangedDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document);
|
||||
|
||||
// Add the pages in the new order
|
||||
for (PDPage page : newPages) {
|
||||
rearrangedDocument.addPage(page);
|
||||
}
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
rearrangedDocument,
|
||||
GeneralUtils.generateFilename(
|
||||
pdfFile.getOriginalFilename(), "_rearranged.pdf"));
|
||||
} catch (IOException e) {
|
||||
ExceptionUtils.logException("document rearrangement", e);
|
||||
throw e;
|
||||
|
||||
+12
-11
@@ -47,19 +47,20 @@ public class RotationController {
|
||||
"error.angleNotMultipleOf90", "Angle must be a multiple of 90");
|
||||
}
|
||||
|
||||
// Load the PDF document
|
||||
PDDocument document = pdfDocumentFactory.load(request);
|
||||
// Load the PDF document with proper resource management
|
||||
try (PDDocument document = pdfDocumentFactory.load(request)) {
|
||||
|
||||
// Get the list of pages in the document
|
||||
PDPageTree pages = document.getPages();
|
||||
// Get the list of pages in the document
|
||||
PDPageTree pages = document.getPages();
|
||||
|
||||
for (PDPage page : pages) {
|
||||
page.setRotation(page.getRotation() + angle);
|
||||
for (PDPage page : pages) {
|
||||
page.setRotation(page.getRotation() + angle);
|
||||
}
|
||||
|
||||
// Return the rotated PDF as a response
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_rotated.pdf"));
|
||||
}
|
||||
|
||||
// Return the rotated PDF as a response
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_rotated.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
+217
-131
@@ -2,9 +2,9 @@ package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.*;
|
||||
import java.util.stream.IntStream;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@@ -19,38 +19,37 @@ import org.apache.pdfbox.util.Matrix;
|
||||
import org.springframework.http.MediaType;
|
||||
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 org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
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.config.swagger.MultiFileResponse;
|
||||
import stirling.software.SPDF.model.SplitTypes;
|
||||
import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.PDFService;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@GeneralApi
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class SplitPdfBySectionsController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final PDFService pdfService;
|
||||
|
||||
@PostMapping(value = "/split-pdf-by-sections", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/split-pdf-by-sections")
|
||||
@MultiFileResponse
|
||||
@Operation(
|
||||
summary = "Split PDF pages into smaller sections",
|
||||
description =
|
||||
@@ -58,10 +57,8 @@ public class SplitPdfBySectionsController {
|
||||
+ " which page to split, and how to split"
|
||||
+ " ( halves, thirds, quarters, etc.), both vertically and horizontally."
|
||||
+ " Input:PDF Output:ZIP-PDF Type:SISO")
|
||||
public ResponseEntity<StreamingResponseBody> splitPdf(
|
||||
@ModelAttribute SplitPdfBySectionsRequest request) throws Exception {
|
||||
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
|
||||
|
||||
public ResponseEntity<byte[]> splitPdf(@ModelAttribute SplitPdfBySectionsRequest request)
|
||||
throws Exception {
|
||||
MultipartFile file = request.getFileInput();
|
||||
String pageNumbers = request.getPageNumbers();
|
||||
SplitTypes splitMode =
|
||||
@@ -69,55 +66,216 @@ public class SplitPdfBySectionsController {
|
||||
.map(SplitTypes::valueOf)
|
||||
.orElse(SplitTypes.SPLIT_ALL);
|
||||
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
|
||||
Set<Integer> pagesToSplit =
|
||||
getPagesToSplit(pageNumbers, splitMode, sourceDocument.getNumberOfPages());
|
||||
|
||||
Set<Integer> pagesToSplit =
|
||||
getPagesToSplit(pageNumbers, splitMode, sourceDocument.getNumberOfPages());
|
||||
// Process the PDF based on split parameters
|
||||
int horiz = request.getHorizontalDivisions() + 1;
|
||||
int verti = request.getVerticalDivisions() + 1;
|
||||
boolean merge = Boolean.TRUE.equals(request.getMerge());
|
||||
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "_split");
|
||||
|
||||
// Process the PDF based on split parameters
|
||||
int horiz = request.getHorizontalDivisions() + 1;
|
||||
int verti = request.getVerticalDivisions() + 1;
|
||||
boolean merge = Boolean.TRUE.equals(request.getMerge());
|
||||
List<PDDocument> splitDocuments = splitPdfPages(sourceDocument, verti, horiz, pagesToSplit);
|
||||
|
||||
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "_split.pdf");
|
||||
if (merge) {
|
||||
TempFile tempFile = new TempFile(tempFileManager, ".pdf");
|
||||
try (PDDocument merged = pdfService.mergeDocuments(splitDocuments);
|
||||
OutputStream out = Files.newOutputStream(tempFile.getPath())) {
|
||||
merged.save(out);
|
||||
for (PDDocument d : splitDocuments) d.close();
|
||||
sourceDocument.close();
|
||||
if (merge) {
|
||||
try (PDDocument mergedDoc =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(
|
||||
sourceDocument);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
LayerUtility layerUtility = new LayerUtility(mergedDoc);
|
||||
for (int pageIndex = 0;
|
||||
pageIndex < sourceDocument.getNumberOfPages();
|
||||
pageIndex++) {
|
||||
if (pagesToSplit.contains(pageIndex)) {
|
||||
addSplitPageToTarget(
|
||||
sourceDocument,
|
||||
pageIndex,
|
||||
mergedDoc,
|
||||
layerUtility,
|
||||
horiz,
|
||||
verti);
|
||||
} else {
|
||||
addPageToTarget(sourceDocument, pageIndex, mergedDoc, layerUtility);
|
||||
}
|
||||
}
|
||||
mergedDoc.save(baos);
|
||||
return WebResponseUtils.baosToWebResponse(baos, filename + ".pdf");
|
||||
}
|
||||
} else {
|
||||
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
|
||||
try (ZipOutputStream zipOut =
|
||||
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
|
||||
for (int pageIndex = 0;
|
||||
pageIndex < sourceDocument.getNumberOfPages();
|
||||
pageIndex++) {
|
||||
int pageNum = pageIndex + 1;
|
||||
if (pagesToSplit.contains(pageIndex)) {
|
||||
for (int i = 0; i < horiz; i++) {
|
||||
for (int j = 0; j < verti; j++) {
|
||||
try (PDDocument subDoc =
|
||||
pdfDocumentFactory.createNewDocument()) {
|
||||
LayerUtility subLayerUtility = new LayerUtility(subDoc);
|
||||
addSingleSectionToTarget(
|
||||
sourceDocument,
|
||||
pageIndex,
|
||||
subDoc,
|
||||
subLayerUtility,
|
||||
i,
|
||||
j,
|
||||
horiz,
|
||||
verti);
|
||||
int sectionNum = i * verti + j + 1;
|
||||
String entryName =
|
||||
filename
|
||||
+ "_"
|
||||
+ pageNum
|
||||
+ "_"
|
||||
+ sectionNum
|
||||
+ ".pdf";
|
||||
saveDocToZip(subDoc, zipOut, entryName);
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
"Error creating section {} for page {}",
|
||||
(i * verti + j + 1),
|
||||
pageNum,
|
||||
e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try (PDDocument subDoc = pdfDocumentFactory.createNewDocument()) {
|
||||
LayerUtility subLayerUtility = new LayerUtility(subDoc);
|
||||
addPageToTarget(sourceDocument, pageIndex, subDoc, subLayerUtility);
|
||||
String entryName = filename + "_" + pageNum + "_1.pdf";
|
||||
saveDocToZip(subDoc, zipOut, entryName);
|
||||
} catch (IOException e) {
|
||||
log.error("Error processing unsplit page {}", pageNum, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Error creating ZIP file with split PDF sections", e);
|
||||
throw e;
|
||||
}
|
||||
byte[] zipBytes = Files.readAllBytes(zipTempFile.getPath());
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
zipBytes, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
return WebResponseUtils.pdfFileToWebResponse(tempFile, filename + "_split.pdf");
|
||||
} catch (Exception e) {
|
||||
log.error("Error splitting PDF file: {}", file.getOriginalFilename(), e);
|
||||
throw e;
|
||||
}
|
||||
for (PDDocument doc : splitDocuments) {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
doc.close();
|
||||
splitDocumentsBoas.add(baos);
|
||||
}
|
||||
|
||||
private void addPageToTarget(
|
||||
PDDocument sourceDoc, int pageIndex, PDDocument targetDoc, LayerUtility layerUtility)
|
||||
throws IOException {
|
||||
PDPage sourcePage = sourceDoc.getPage(pageIndex);
|
||||
PDPage newPage = new PDPage(sourcePage.getMediaBox());
|
||||
targetDoc.addPage(newPage);
|
||||
|
||||
PDFormXObject form = layerUtility.importPageAsForm(sourceDoc, pageIndex);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(targetDoc, newPage, AppendMode.APPEND, true, true)) {
|
||||
contentStream.drawForm(form);
|
||||
} catch (IOException e) {
|
||||
log.error("Error adding page {} to target document", pageIndex, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
sourceDocument.close();
|
||||
private void addSplitPageToTarget(
|
||||
PDDocument sourceDoc,
|
||||
int pageIndex,
|
||||
PDDocument targetDoc,
|
||||
LayerUtility layerUtility,
|
||||
int totalHoriz,
|
||||
int totalVert)
|
||||
throws IOException {
|
||||
PDPage sourcePage = sourceDoc.getPage(pageIndex);
|
||||
PDRectangle mediaBox = sourcePage.getMediaBox();
|
||||
float width = mediaBox.getWidth();
|
||||
float height = mediaBox.getHeight();
|
||||
float subPageWidth = width / totalHoriz;
|
||||
float subPageHeight = height / totalVert;
|
||||
|
||||
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
|
||||
try (ZipOutputStream zipOut =
|
||||
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
|
||||
int pageNum = 1;
|
||||
for (int i = 0; i < splitDocumentsBoas.size(); i++) {
|
||||
ByteArrayOutputStream baos = splitDocumentsBoas.get(i);
|
||||
int sectionNum = (i % (horiz * verti)) + 1;
|
||||
String fileName = filename + "_" + pageNum + "_" + sectionNum + ".pdf";
|
||||
byte[] pdf = baos.toByteArray();
|
||||
ZipEntry pdfEntry = new ZipEntry(fileName);
|
||||
zipOut.putNextEntry(pdfEntry);
|
||||
zipOut.write(pdf);
|
||||
zipOut.closeEntry();
|
||||
PDFormXObject form = layerUtility.importPageAsForm(sourceDoc, pageIndex);
|
||||
|
||||
if (sectionNum == horiz * verti) pageNum++;
|
||||
for (int i = 0; i < totalHoriz; i++) {
|
||||
for (int j = 0; j < totalVert; j++) {
|
||||
PDPage subPage = new PDPage(new PDRectangle(subPageWidth, subPageHeight));
|
||||
targetDoc.addPage(subPage);
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
targetDoc, subPage, AppendMode.APPEND, true, true)) {
|
||||
float translateX = -subPageWidth * i;
|
||||
float translateY = -subPageHeight * (totalVert - 1 - j);
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.addRect(0, 0, subPageWidth, subPageHeight);
|
||||
contentStream.clip();
|
||||
contentStream.transform(new Matrix(1, 0, 0, 1, translateX, translateY));
|
||||
contentStream.drawForm(form);
|
||||
contentStream.restoreGraphicsState();
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
"Error adding split section ({}, {}) for page {}", i, j, pageIndex, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + "_split.zip");
|
||||
}
|
||||
|
||||
private void addSingleSectionToTarget(
|
||||
PDDocument sourceDoc,
|
||||
int pageIndex,
|
||||
PDDocument targetDoc,
|
||||
LayerUtility layerUtility,
|
||||
int horizIndex,
|
||||
int vertIndex,
|
||||
int totalHoriz,
|
||||
int totalVert)
|
||||
throws IOException {
|
||||
PDPage sourcePage = sourceDoc.getPage(pageIndex);
|
||||
PDRectangle mediaBox = sourcePage.getMediaBox();
|
||||
float subPageWidth = mediaBox.getWidth() / totalHoriz;
|
||||
float subPageHeight = mediaBox.getHeight() / totalVert;
|
||||
|
||||
PDPage subPage = new PDPage(new PDRectangle(subPageWidth, subPageHeight));
|
||||
targetDoc.addPage(subPage);
|
||||
|
||||
PDFormXObject form = layerUtility.importPageAsForm(sourceDoc, pageIndex);
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(targetDoc, subPage, AppendMode.APPEND, true, true)) {
|
||||
float translateX = -subPageWidth * horizIndex;
|
||||
float translateY = -subPageHeight * (totalVert - 1 - vertIndex);
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.addRect(0, 0, subPageWidth, subPageHeight);
|
||||
contentStream.clip();
|
||||
contentStream.transform(new Matrix(1, 0, 0, 1, translateX, translateY));
|
||||
contentStream.drawForm(form);
|
||||
contentStream.restoreGraphicsState();
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
"Error adding single section ({}, {}) for page {} to target",
|
||||
horizIndex,
|
||||
vertIndex,
|
||||
pageIndex,
|
||||
e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private void saveDocToZip(PDDocument doc, ZipOutputStream zipOut, String entryName)
|
||||
throws IOException {
|
||||
ZipEntry entry = new ZipEntry(entryName);
|
||||
zipOut.putNextEntry(entry);
|
||||
doc.save(zipOut);
|
||||
zipOut.closeEntry();
|
||||
}
|
||||
|
||||
// Based on the mode, get the pages that need to be split and return the pages set
|
||||
@@ -140,27 +298,19 @@ public class SplitPdfBySectionsController {
|
||||
break;
|
||||
|
||||
case SPLIT_ALL:
|
||||
for (int i = 0; i < totalPages; i++) {
|
||||
pagesToSplit.add(i);
|
||||
}
|
||||
pagesToSplit.addAll(IntStream.range(0, totalPages).boxed().toList());
|
||||
break;
|
||||
|
||||
case SPLIT_ALL_EXCEPT_FIRST:
|
||||
for (int i = 1; i < totalPages; i++) {
|
||||
pagesToSplit.add(i);
|
||||
}
|
||||
pagesToSplit.addAll(IntStream.range(1, totalPages).boxed().toList());
|
||||
break;
|
||||
|
||||
case SPLIT_ALL_EXCEPT_LAST:
|
||||
for (int i = 0; i < totalPages - 1; i++) {
|
||||
pagesToSplit.add(i);
|
||||
}
|
||||
pagesToSplit.addAll(IntStream.range(0, totalPages - 1).boxed().toList());
|
||||
break;
|
||||
|
||||
case SPLIT_ALL_EXCEPT_FIRST_AND_LAST:
|
||||
for (int i = 1; i < totalPages - 1; i++) {
|
||||
pagesToSplit.add(i);
|
||||
}
|
||||
pagesToSplit.addAll(IntStream.range(1, totalPages - 1).boxed().toList());
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -170,68 +320,4 @@ public class SplitPdfBySectionsController {
|
||||
|
||||
return pagesToSplit;
|
||||
}
|
||||
|
||||
public List<PDDocument> splitPdfPages(
|
||||
PDDocument document,
|
||||
int horizontalDivisions,
|
||||
int verticalDivisions,
|
||||
Set<Integer> pagesToSplit)
|
||||
throws IOException {
|
||||
List<PDDocument> splitDocuments = new ArrayList<>();
|
||||
|
||||
int pageIndex = 0;
|
||||
for (PDPage originalPage : document.getPages()) {
|
||||
// If current page is not to split, add it to the splitDocuments directly.
|
||||
if (!pagesToSplit.contains(pageIndex)) {
|
||||
PDDocument newDoc = pdfDocumentFactory.createNewDocument();
|
||||
newDoc.addPage(originalPage);
|
||||
splitDocuments.add(newDoc);
|
||||
} else {
|
||||
// Otherwise, split current page.
|
||||
PDRectangle originalMediaBox = originalPage.getMediaBox();
|
||||
float width = originalMediaBox.getWidth();
|
||||
float height = originalMediaBox.getHeight();
|
||||
float subPageWidth = width / horizontalDivisions;
|
||||
float subPageHeight = height / verticalDivisions;
|
||||
|
||||
LayerUtility layerUtility = new LayerUtility(document);
|
||||
|
||||
for (int i = 0; i < horizontalDivisions; i++) {
|
||||
for (int j = 0; j < verticalDivisions; j++) {
|
||||
PDDocument subDoc = new PDDocument();
|
||||
PDPage subPage = new PDPage(new PDRectangle(subPageWidth, subPageHeight));
|
||||
subDoc.addPage(subPage);
|
||||
|
||||
PDFormXObject form =
|
||||
layerUtility.importPageAsForm(
|
||||
document, document.getPages().indexOf(originalPage));
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
subDoc, subPage, AppendMode.APPEND, true, true)) {
|
||||
// Set clipping area and position
|
||||
float translateX = -subPageWidth * i;
|
||||
|
||||
// float translateY = height - subPageHeight * (verticalDivisions - j);
|
||||
float translateY = -subPageHeight * (verticalDivisions - 1 - j);
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.addRect(0, 0, subPageWidth, subPageHeight);
|
||||
contentStream.clip();
|
||||
contentStream.transform(new Matrix(1, 0, 0, 1, translateX, translateY));
|
||||
|
||||
// Draw the form
|
||||
contentStream.drawForm(form);
|
||||
contentStream.restoreGraphicsState();
|
||||
}
|
||||
|
||||
splitDocuments.add(subDoc);
|
||||
}
|
||||
}
|
||||
}
|
||||
pageIndex++;
|
||||
}
|
||||
|
||||
return splitDocuments;
|
||||
}
|
||||
}
|
||||
|
||||
+291
-237
@@ -124,124 +124,168 @@ public class SplitPdfBySizeController {
|
||||
throws IOException {
|
||||
log.debug("Starting handleSplitBySize with maxBytes={}", maxBytes);
|
||||
|
||||
PDDocument currentDoc =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
int fileIndex = 1;
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
int pageAdded = 0;
|
||||
class DocHolder implements AutoCloseable {
|
||||
private PDDocument doc;
|
||||
|
||||
// Smart size check frequency - check more often with larger documents
|
||||
int baseCheckFrequency = 5;
|
||||
public DocHolder(PDDocument doc) {
|
||||
this.doc = doc;
|
||||
}
|
||||
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
PDPage page = sourceDocument.getPage(pageIndex);
|
||||
log.debug("Processing page {} of {}", pageIndex + 1, totalPages);
|
||||
public PDDocument getDoc() {
|
||||
return doc;
|
||||
}
|
||||
|
||||
// Add the page to current document
|
||||
PDPage newPage = new PDPage(page.getCOSObject());
|
||||
currentDoc.addPage(newPage);
|
||||
pageAdded++;
|
||||
|
||||
// Dynamic size checking based on document size and page count
|
||||
boolean shouldCheckSize =
|
||||
(pageAdded % baseCheckFrequency == 0)
|
||||
|| (pageIndex == totalPages - 1)
|
||||
|| (pageAdded >= 20); // Always check after 20 pages
|
||||
|
||||
if (shouldCheckSize) {
|
||||
log.debug("Performing size check after {} pages", pageAdded);
|
||||
ByteArrayOutputStream checkSizeStream = new ByteArrayOutputStream();
|
||||
currentDoc.save(checkSizeStream);
|
||||
long actualSize = checkSizeStream.size();
|
||||
log.debug("Current document size: {} bytes (max: {} bytes)", actualSize, maxBytes);
|
||||
|
||||
if (actualSize > maxBytes) {
|
||||
// We exceeded the limit - remove the last page and save
|
||||
if (currentDoc.getNumberOfPages() > 1) {
|
||||
currentDoc.removePage(currentDoc.getNumberOfPages() - 1);
|
||||
pageIndex--; // Process this page again in the next document
|
||||
log.debug("Size limit exceeded - removed last page");
|
||||
public void setDoc(PDDocument doc) {
|
||||
if (this.doc != null) {
|
||||
try {
|
||||
this.doc.close();
|
||||
} catch (IOException e) {
|
||||
log.error("Error closing document", e);
|
||||
}
|
||||
}
|
||||
this.doc = doc;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (doc != null) {
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int fileIndex = 1;
|
||||
try (DocHolder holder =
|
||||
new DocHolder(
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument))) {
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
int pageAdded = 0;
|
||||
|
||||
// Smart size check frequency - check more often with larger documents
|
||||
int baseCheckFrequency = 5;
|
||||
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
PDPage page = sourceDocument.getPage(pageIndex);
|
||||
log.debug("Processing page {} of {}", pageIndex + 1, totalPages);
|
||||
|
||||
// Add the page to current document
|
||||
PDPage newPage = new PDPage(page.getCOSObject());
|
||||
holder.getDoc().addPage(newPage);
|
||||
pageAdded++;
|
||||
|
||||
// Dynamic size checking based on document size and page count
|
||||
boolean shouldCheckSize =
|
||||
(pageAdded % baseCheckFrequency == 0)
|
||||
|| (pageIndex == totalPages - 1)
|
||||
|| (pageAdded >= 20); // Always check after 20 pages
|
||||
|
||||
if (shouldCheckSize) {
|
||||
log.debug("Performing size check after {} pages", pageAdded);
|
||||
long actualSize;
|
||||
try (ByteArrayOutputStream checkSizeStream = new ByteArrayOutputStream()) {
|
||||
holder.getDoc().save(checkSizeStream);
|
||||
actualSize = checkSizeStream.size();
|
||||
}
|
||||
log.debug(
|
||||
"Saving document with {} pages as part {}",
|
||||
currentDoc.getNumberOfPages(),
|
||||
fileIndex);
|
||||
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
|
||||
currentDoc = new PDDocument();
|
||||
pageAdded = 0;
|
||||
} else if (pageIndex < totalPages - 1) {
|
||||
// We're under the limit, calculate if we might fit more pages
|
||||
// Try to predict how many more similar pages might fit
|
||||
if (actualSize < maxBytes * 0.75 && pageAdded > 0) {
|
||||
// Rather than using a ratio, look ahead to test actual upcoming pages
|
||||
int pagesToLookAhead = Math.min(5, totalPages - pageIndex - 1);
|
||||
"Current document size: {} bytes (max: {} bytes)",
|
||||
actualSize,
|
||||
maxBytes);
|
||||
|
||||
if (pagesToLookAhead > 0) {
|
||||
log.debug(
|
||||
"Testing {} upcoming pages for potential addition",
|
||||
pagesToLookAhead);
|
||||
if (actualSize > maxBytes) {
|
||||
// We exceeded the limit - remove the last page and save
|
||||
if (holder.getDoc().getNumberOfPages() > 1) {
|
||||
holder.getDoc().removePage(holder.getDoc().getNumberOfPages() - 1);
|
||||
pageIndex--; // Process this page again in the next document
|
||||
log.debug("Size limit exceeded - removed last page");
|
||||
}
|
||||
|
||||
// Create a temp document with current pages + look-ahead pages
|
||||
PDDocument testDoc = new PDDocument();
|
||||
// First copy existing pages
|
||||
for (int i = 0; i < currentDoc.getNumberOfPages(); i++) {
|
||||
testDoc.addPage(new PDPage(currentDoc.getPage(i).getCOSObject()));
|
||||
}
|
||||
log.debug(
|
||||
"Saving document with {} pages as part {}",
|
||||
holder.getDoc().getNumberOfPages(),
|
||||
fileIndex);
|
||||
saveDocumentToZip(holder.getDoc(), zipOut, baseFilename, fileIndex++);
|
||||
holder.setDoc(new PDDocument());
|
||||
pageAdded = 0;
|
||||
} else if (pageIndex < totalPages - 1) {
|
||||
// We're under the limit, calculate if we might fit more pages
|
||||
// Try to predict how many more similar pages might fit
|
||||
if (actualSize < maxBytes * 0.75 && pageAdded > 0) {
|
||||
// Rather than using a ratio, look ahead to test actual upcoming pages
|
||||
int pagesToLookAhead = Math.min(5, totalPages - pageIndex - 1);
|
||||
|
||||
// Try adding look-ahead pages one by one
|
||||
int extraPagesAdded = 0;
|
||||
for (int i = 0; i < pagesToLookAhead; i++) {
|
||||
int testPageIndex = pageIndex + 1 + i;
|
||||
PDPage testPage = sourceDocument.getPage(testPageIndex);
|
||||
testDoc.addPage(new PDPage(testPage.getCOSObject()));
|
||||
if (pagesToLookAhead > 0) {
|
||||
log.debug(
|
||||
"Testing {} upcoming pages for potential addition",
|
||||
pagesToLookAhead);
|
||||
|
||||
// Check if we're still under size
|
||||
ByteArrayOutputStream testStream = new ByteArrayOutputStream();
|
||||
testDoc.save(testStream);
|
||||
long testSize = testStream.size();
|
||||
// Create a temp document with current pages + look-ahead pages
|
||||
try (PDDocument testDoc = new PDDocument()) {
|
||||
// First copy existing pages
|
||||
for (int i = 0; i < holder.getDoc().getNumberOfPages(); i++) {
|
||||
testDoc.addPage(
|
||||
new PDPage(
|
||||
holder.getDoc().getPage(i).getCOSObject()));
|
||||
}
|
||||
|
||||
if (testSize <= maxBytes) {
|
||||
extraPagesAdded++;
|
||||
log.debug(
|
||||
"Test: Can add page {} (size would be {})",
|
||||
testPageIndex + 1,
|
||||
testSize);
|
||||
} else {
|
||||
log.debug(
|
||||
"Test: Cannot add page {} (size would be {})",
|
||||
testPageIndex + 1,
|
||||
testSize);
|
||||
break;
|
||||
// Try adding look-ahead pages one by one
|
||||
int extraPagesAdded = 0;
|
||||
for (int i = 0; i < pagesToLookAhead; i++) {
|
||||
int testPageIndex = pageIndex + 1 + i;
|
||||
PDPage testPage = sourceDocument.getPage(testPageIndex);
|
||||
testDoc.addPage(new PDPage(testPage.getCOSObject()));
|
||||
|
||||
// Check if we're still under size
|
||||
long testSize;
|
||||
try (ByteArrayOutputStream testStream =
|
||||
new ByteArrayOutputStream()) {
|
||||
testDoc.save(testStream);
|
||||
testSize = testStream.size();
|
||||
}
|
||||
|
||||
if (testSize <= maxBytes) {
|
||||
extraPagesAdded++;
|
||||
log.debug(
|
||||
"Test: Can add page {} (size would be {})",
|
||||
testPageIndex + 1,
|
||||
testSize);
|
||||
} else {
|
||||
log.debug(
|
||||
"Test: Cannot add page {} (size would be {})",
|
||||
testPageIndex + 1,
|
||||
testSize);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Add the pages we verified would fit
|
||||
if (extraPagesAdded > 0) {
|
||||
log.debug(
|
||||
"Adding {} verified pages ahead", extraPagesAdded);
|
||||
for (int i = 0; i < extraPagesAdded; i++) {
|
||||
int extraPageIndex = pageIndex + 1 + i;
|
||||
PDPage extraPage =
|
||||
sourceDocument.getPage(extraPageIndex);
|
||||
holder.getDoc()
|
||||
.addPage(new PDPage(extraPage.getCOSObject()));
|
||||
}
|
||||
pageIndex += extraPagesAdded;
|
||||
pageAdded += extraPagesAdded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testDoc.close();
|
||||
|
||||
// Add the pages we verified would fit
|
||||
if (extraPagesAdded > 0) {
|
||||
log.debug("Adding {} verified pages ahead", extraPagesAdded);
|
||||
for (int i = 0; i < extraPagesAdded; i++) {
|
||||
int extraPageIndex = pageIndex + 1 + i;
|
||||
PDPage extraPage = sourceDocument.getPage(extraPageIndex);
|
||||
currentDoc.addPage(new PDPage(extraPage.getCOSObject()));
|
||||
}
|
||||
pageIndex += extraPagesAdded;
|
||||
pageAdded += extraPagesAdded;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save final document if it has any pages
|
||||
if (currentDoc.getNumberOfPages() > 0) {
|
||||
log.debug(
|
||||
"Saving final document with {} pages as part {}",
|
||||
currentDoc.getNumberOfPages(),
|
||||
fileIndex);
|
||||
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
|
||||
// Save final document if it has any pages
|
||||
if (holder.getDoc() != null && holder.getDoc().getNumberOfPages() > 0) {
|
||||
log.debug(
|
||||
"Saving final document with {} pages as part {}",
|
||||
holder.getDoc().getNumberOfPages(),
|
||||
fileIndex);
|
||||
saveDocumentToZip(holder.getDoc(), zipOut, baseFilename, fileIndex++);
|
||||
holder.setDoc(null);
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("Completed handleSplitBySize with {} document parts created", fileIndex - 1);
|
||||
@@ -252,96 +296,103 @@ public class SplitPdfBySizeController {
|
||||
throws IOException {
|
||||
log.debug("Starting handleSplitByPageCount with pageCount={}", pageCount);
|
||||
int currentPageCount = 0;
|
||||
log.debug("Creating initial output document");
|
||||
PDDocument currentDoc;
|
||||
try {
|
||||
currentDoc = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
log.debug("Successfully created initial output document");
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("initial output document creation", e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
|
||||
PDDocument currentDoc = null;
|
||||
int fileIndex = 1;
|
||||
int pageIndex = 0;
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
log.debug("Processing {} pages", totalPages);
|
||||
|
||||
try {
|
||||
for (PDPage page : sourceDocument.getPages()) {
|
||||
pageIndex++;
|
||||
log.debug("Processing page {} of {}", pageIndex, totalPages);
|
||||
log.debug("Creating initial output document");
|
||||
try {
|
||||
currentDoc = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
log.debug("Successfully created initial output document");
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("initial output document creation", e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("Adding page {} to current document", pageIndex);
|
||||
currentDoc.addPage(page);
|
||||
log.debug("Successfully added page {} to current document", pageIndex);
|
||||
} catch (Exception e) {
|
||||
log.error("Error adding page {} to current document", pageIndex, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
int pageIndex = 0;
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
log.debug("Processing {} pages", totalPages);
|
||||
|
||||
currentPageCount++;
|
||||
log.debug("Current page count: {}/{}", currentPageCount, pageCount);
|
||||
|
||||
if (currentPageCount == pageCount) {
|
||||
log.debug(
|
||||
"Reached target page count ({}), saving current document as part {}",
|
||||
pageCount,
|
||||
fileIndex);
|
||||
try {
|
||||
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
|
||||
log.debug("Successfully saved document part {}", fileIndex - 1);
|
||||
} catch (Exception e) {
|
||||
log.error("Error saving document part {}", fileIndex - 1, e);
|
||||
throw e;
|
||||
}
|
||||
try {
|
||||
for (PDPage page : sourceDocument.getPages()) {
|
||||
pageIndex++;
|
||||
log.debug("Processing page {} of {}", pageIndex, totalPages);
|
||||
|
||||
try {
|
||||
log.debug("Creating new document for next part");
|
||||
currentDoc = new PDDocument();
|
||||
log.debug("Successfully created new document");
|
||||
log.debug("Adding page {} to current document", pageIndex);
|
||||
currentDoc.addPage(page);
|
||||
log.debug("Successfully added page {} to current document", pageIndex);
|
||||
} catch (Exception e) {
|
||||
log.error("Error creating new document for next part", e);
|
||||
log.error("Error adding page {} to current document", pageIndex, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
|
||||
currentPageCount = 0;
|
||||
log.debug("Reset current page count to 0");
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error iterating through pages", e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
currentPageCount++;
|
||||
log.debug("Current page count: {}/{}", currentPageCount, pageCount);
|
||||
|
||||
// Add the last document if it contains any pages
|
||||
try {
|
||||
if (currentDoc.getPages().getCount() != 0) {
|
||||
log.debug(
|
||||
"Saving final document with {} pages as part {}",
|
||||
currentDoc.getPages().getCount(),
|
||||
fileIndex);
|
||||
try {
|
||||
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
|
||||
log.debug("Successfully saved final document part {}", fileIndex - 1);
|
||||
} catch (Exception e) {
|
||||
log.error("Error saving final document part {}", fileIndex - 1, e);
|
||||
throw e;
|
||||
if (currentPageCount == pageCount) {
|
||||
log.debug(
|
||||
"Reached target page count ({}), saving current document as part {}",
|
||||
pageCount,
|
||||
fileIndex);
|
||||
try {
|
||||
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
|
||||
currentDoc = null; // Document is closed by saveDocumentToZip
|
||||
log.debug("Successfully saved document part {}", fileIndex - 1);
|
||||
} catch (Exception e) {
|
||||
log.error("Error saving document part {}", fileIndex - 1, e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("Creating new document for next part");
|
||||
currentDoc = new PDDocument();
|
||||
log.debug("Successfully created new document");
|
||||
} catch (Exception e) {
|
||||
log.error("Error creating new document for next part", e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
|
||||
currentPageCount = 0;
|
||||
log.debug("Reset current page count to 0");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.debug("Final document has no pages, skipping");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error checking or saving final document", e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
} finally {
|
||||
try {
|
||||
log.debug("Closing final document");
|
||||
currentDoc.close();
|
||||
log.debug("Successfully closed final document");
|
||||
} catch (Exception e) {
|
||||
log.error("Error closing final document", e);
|
||||
log.error("Error iterating through pages", e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
|
||||
// Add the last document if it contains any pages
|
||||
try {
|
||||
if (currentDoc != null && currentDoc.getPages().getCount() != 0) {
|
||||
log.debug(
|
||||
"Saving final document with {} pages as part {}",
|
||||
currentDoc.getPages().getCount(),
|
||||
fileIndex);
|
||||
try {
|
||||
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
|
||||
currentDoc = null; // Document is closed by saveDocumentToZip
|
||||
log.debug("Successfully saved final document part {}", fileIndex - 1);
|
||||
} catch (Exception e) {
|
||||
log.error("Error saving final document part {}", fileIndex - 1, e);
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
log.debug("Final document has no pages, skipping");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error checking or saving final document", e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
} finally {
|
||||
if (currentDoc != null) {
|
||||
try {
|
||||
log.debug("Closing remaining document");
|
||||
currentDoc.close();
|
||||
log.debug("Successfully closed remaining document");
|
||||
} catch (Exception e) {
|
||||
log.error("Error closing remaining document", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,42 +418,52 @@ public class SplitPdfBySizeController {
|
||||
|
||||
for (int i = 0; i < documentCount; i++) {
|
||||
log.debug("Creating document {} of {}", i + 1, documentCount);
|
||||
PDDocument currentDoc;
|
||||
PDDocument currentDoc = null;
|
||||
try {
|
||||
currentDoc = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
log.debug("Successfully created document {} of {}", i + 1, documentCount);
|
||||
|
||||
int pagesToAdd = pagesPerDocument + (i < extraPages ? 1 : 0);
|
||||
log.debug("Adding {} pages to document {}", pagesToAdd, i + 1);
|
||||
|
||||
for (int j = 0; j < pagesToAdd; j++) {
|
||||
try {
|
||||
log.debug(
|
||||
"Adding page {} (index {}) to document {}",
|
||||
j + 1,
|
||||
currentPageIndex,
|
||||
i + 1);
|
||||
currentDoc.addPage(sourceDocument.getPage(currentPageIndex));
|
||||
log.debug("Successfully added page {} to document {}", j + 1, i + 1);
|
||||
currentPageIndex++;
|
||||
} catch (Exception e) {
|
||||
log.error("Error adding page {} to document {}", j + 1, i + 1, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("Saving document {} with {} pages", i + 1, pagesToAdd);
|
||||
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
|
||||
// saveDocumentToZip closes the document
|
||||
currentDoc = null;
|
||||
log.debug("Successfully saved document {}", i + 1);
|
||||
} catch (Exception e) {
|
||||
log.error("Error saving document {}", i + 1, e);
|
||||
throw e;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error creating document {} of {}", i + 1, documentCount, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
|
||||
int pagesToAdd = pagesPerDocument + (i < extraPages ? 1 : 0);
|
||||
log.debug("Adding {} pages to document {}", pagesToAdd, i + 1);
|
||||
|
||||
for (int j = 0; j < pagesToAdd; j++) {
|
||||
try {
|
||||
log.debug(
|
||||
"Adding page {} (index {}) to document {}",
|
||||
j + 1,
|
||||
currentPageIndex,
|
||||
i + 1);
|
||||
currentDoc.addPage(sourceDocument.getPage(currentPageIndex));
|
||||
log.debug("Successfully added page {} to document {}", j + 1, i + 1);
|
||||
currentPageIndex++;
|
||||
} catch (Exception e) {
|
||||
log.error("Error adding page {} to document {}", j + 1, i + 1, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
} finally {
|
||||
if (currentDoc != null) {
|
||||
try {
|
||||
currentDoc.close();
|
||||
} catch (IOException e) {
|
||||
log.error("Error closing document {} of {}", i + 1, documentCount, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("Saving document {} with {} pages", i + 1, pagesToAdd);
|
||||
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
|
||||
log.debug("Successfully saved document {}", i + 1);
|
||||
} catch (Exception e) {
|
||||
log.error("Error saving document {}", i + 1, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("Completed handleSplitByDocCount with {} documents created", documentCount);
|
||||
@@ -412,43 +473,36 @@ public class SplitPdfBySizeController {
|
||||
PDDocument document, ZipOutputStream zipOut, String baseFilename, int index)
|
||||
throws IOException {
|
||||
log.debug("Starting saveDocumentToZip for document part {}", index);
|
||||
ByteArrayOutputStream outStream = new ByteArrayOutputStream();
|
||||
try (ByteArrayOutputStream outStream = new ByteArrayOutputStream()) {
|
||||
|
||||
try {
|
||||
log.debug("Saving document part {} to byte array", index);
|
||||
document.save(outStream);
|
||||
log.debug("Successfully saved document part {} ({} bytes)", index, outStream.size());
|
||||
} catch (Exception e) {
|
||||
log.error("Error saving document part {} to byte array", index, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
try (PDDocument doc = document) {
|
||||
log.debug("Saving document part {} to byte array", index);
|
||||
doc.save(outStream);
|
||||
log.debug(
|
||||
"Successfully saved document part {} ({} bytes)", index, outStream.size());
|
||||
} catch (Exception e) {
|
||||
log.error("Error saving document part {} to byte array", index, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("Closing document part {}", index);
|
||||
document.close();
|
||||
log.debug("Successfully closed document part {}", index);
|
||||
} catch (Exception e) {
|
||||
log.error("Error closing document part {}", index, e);
|
||||
// Continue despite close error
|
||||
}
|
||||
try {
|
||||
// Create a new zip entry
|
||||
String entryName = baseFilename + "_" + index + ".pdf";
|
||||
log.debug("Creating ZIP entry: {}", entryName);
|
||||
ZipEntry zipEntry = new ZipEntry(entryName);
|
||||
zipOut.putNextEntry(zipEntry);
|
||||
|
||||
try {
|
||||
// Create a new zip entry
|
||||
String entryName = baseFilename + "_" + index + ".pdf";
|
||||
log.debug("Creating ZIP entry: {}", entryName);
|
||||
ZipEntry zipEntry = new ZipEntry(entryName);
|
||||
zipOut.putNextEntry(zipEntry);
|
||||
byte[] bytes = outStream.toByteArray();
|
||||
log.debug("Writing {} bytes to ZIP entry", bytes.length);
|
||||
zipOut.write(bytes);
|
||||
|
||||
byte[] bytes = outStream.toByteArray();
|
||||
log.debug("Writing {} bytes to ZIP entry", bytes.length);
|
||||
zipOut.write(bytes);
|
||||
|
||||
log.debug("Closing ZIP entry");
|
||||
zipOut.closeEntry();
|
||||
log.debug("Successfully added document part {} to ZIP", index);
|
||||
} catch (Exception e) {
|
||||
log.error("Error adding document part {} to ZIP", index, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
log.debug("Closing ZIP entry");
|
||||
zipOut.closeEntry();
|
||||
log.debug("Successfully added document part {} to ZIP", index);
|
||||
} catch (Exception e) {
|
||||
log.error("Error adding document part {} to ZIP", index, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+47
-47
@@ -7,7 +7,6 @@ import java.io.IOException;
|
||||
import org.apache.pdfbox.multipdf.LayerUtility;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -47,53 +46,54 @@ public class ToSinglePageController {
|
||||
throws IOException {
|
||||
|
||||
// Load the source document
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(request);
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) {
|
||||
// Calculate total height and max width
|
||||
float totalHeight = 0;
|
||||
float maxWidth = 0;
|
||||
for (PDPage page : sourceDocument.getPages()) {
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
totalHeight += pageSize.getHeight();
|
||||
maxWidth = Math.max(maxWidth, pageSize.getWidth());
|
||||
}
|
||||
|
||||
// Calculate total height and max width
|
||||
float totalHeight = 0;
|
||||
float maxWidth = 0;
|
||||
for (PDPage page : sourceDocument.getPages()) {
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
totalHeight += pageSize.getHeight();
|
||||
maxWidth = Math.max(maxWidth, pageSize.getWidth());
|
||||
// Create new document and page with calculated dimensions
|
||||
try (PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
|
||||
PDPage newPage = new PDPage(new PDRectangle(maxWidth, totalHeight));
|
||||
newDocument.addPage(newPage);
|
||||
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
float yOffset = totalHeight;
|
||||
|
||||
// For each page, copy its content to the new page at the correct offset
|
||||
try {
|
||||
layerUtility.wrapInSaveRestore(newPage);
|
||||
} catch (NullPointerException e) {
|
||||
}
|
||||
|
||||
int pageIndex = 0;
|
||||
for (PDPage page : sourceDocument.getPages()) {
|
||||
PDFormXObject form = layerUtility.importPageAsForm(sourceDocument, pageIndex);
|
||||
if (form != null) {
|
||||
AffineTransform af =
|
||||
AffineTransform.getTranslateInstance(
|
||||
0, yOffset - page.getMediaBox().getHeight());
|
||||
String defaultLayerName = "Layer" + pageIndex;
|
||||
layerUtility.appendFormAsLayer(newPage, form, af, defaultLayerName);
|
||||
}
|
||||
yOffset -= page.getMediaBox().getHeight();
|
||||
pageIndex++;
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
GeneralUtils.generateFilename(
|
||||
request.getFileInput().getOriginalFilename(), "_singlePage.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
// Create new document and page with calculated dimensions
|
||||
PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
PDPage newPage = new PDPage(new PDRectangle(maxWidth, totalHeight));
|
||||
newDocument.addPage(newPage);
|
||||
|
||||
// Initialize the content stream of the new page
|
||||
PDPageContentStream contentStream = new PDPageContentStream(newDocument, newPage);
|
||||
contentStream.close();
|
||||
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
float yOffset = totalHeight;
|
||||
|
||||
// For each page, copy its content to the new page at the correct offset
|
||||
int pageIndex = 0;
|
||||
for (PDPage page : sourceDocument.getPages()) {
|
||||
PDFormXObject form = layerUtility.importPageAsForm(sourceDocument, pageIndex);
|
||||
AffineTransform af =
|
||||
AffineTransform.getTranslateInstance(
|
||||
0, yOffset - page.getMediaBox().getHeight());
|
||||
layerUtility.wrapInSaveRestore(newPage);
|
||||
String defaultLayerName = "Layer" + pageIndex;
|
||||
layerUtility.appendFormAsLayer(newPage, form, af, defaultLayerName);
|
||||
yOffset -= page.getMediaBox().getHeight();
|
||||
pageIndex++;
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
newDocument.close();
|
||||
sourceDocument.close();
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
GeneralUtils.generateFilename(
|
||||
request.getFileInput().getOriginalFilename(), "_singlePage.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +91,7 @@ public class UIDataController {
|
||||
LicensesData data = new LicensesData();
|
||||
Resource resource = new ClassPathResource("static/3rdPartyLicenses.json");
|
||||
|
||||
try {
|
||||
InputStream is = resource.getInputStream();
|
||||
try (InputStream is = resource.getInputStream()) {
|
||||
String json = new String(is.readAllBytes(), StandardCharsets.UTF_8);
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
Map<String, List<Dependency>> licenseData =
|
||||
@@ -192,7 +191,7 @@ public class UIDataController {
|
||||
}
|
||||
|
||||
private List<String> getAvailableTesseractLanguages() {
|
||||
String tessdataDir = applicationProperties.getSystem().getTessdataDir();
|
||||
String tessdataDir = runtimePathConfig.getTessDataPath();
|
||||
java.io.File[] files = new java.io.File(tessdataDir).listFiles();
|
||||
if (files == null) {
|
||||
return Collections.emptyList();
|
||||
|
||||
+24
-28
@@ -188,15 +188,15 @@ public class ConvertImgPDFController {
|
||||
bodyBytes = Files.readAllBytes(webpFilePath);
|
||||
} else {
|
||||
// Create a ZIP file containing all WebP images
|
||||
ByteArrayOutputStream zipOutputStream = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(zipOutputStream)) {
|
||||
try (ByteArrayOutputStream zipOutputStream = new ByteArrayOutputStream();
|
||||
ZipOutputStream zos = new ZipOutputStream(zipOutputStream)) {
|
||||
for (Path webpFile : webpFiles) {
|
||||
zos.putNextEntry(new ZipEntry(webpFile.getFileName().toString()));
|
||||
Files.copy(webpFile, zos);
|
||||
zos.closeEntry();
|
||||
}
|
||||
bodyBytes = zipOutputStream.toByteArray();
|
||||
}
|
||||
bodyBytes = zipOutputStream.toByteArray();
|
||||
}
|
||||
// Clean up the temporary files
|
||||
Files.deleteIfExists(tempFile);
|
||||
@@ -380,7 +380,7 @@ public class ConvertImgPDFController {
|
||||
/**
|
||||
* Rearranges the pages of the given PDF document based on the specified page order.
|
||||
*
|
||||
* @param pdfBytes The byte array of the original PDF file.
|
||||
* @param pdfFile The MultipartFile of the original PDF file.
|
||||
* @param pageOrderArr An array of page numbers indicating the new order.
|
||||
* @return A byte array of the rearranged PDF.
|
||||
* @throws IOException If an error occurs while processing the PDF.
|
||||
@@ -388,35 +388,31 @@ public class ConvertImgPDFController {
|
||||
private byte[] rearrangePdfPages(MultipartFile pdfFile, String[] pageOrderArr)
|
||||
throws IOException {
|
||||
// Load the input PDF
|
||||
PDDocument document = pdfDocumentFactory.load(pdfFile);
|
||||
int totalPages = document.getNumberOfPages();
|
||||
List<Integer> newPageOrder = GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
int totalPages = document.getNumberOfPages();
|
||||
List<Integer> newPageOrder =
|
||||
GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
|
||||
|
||||
// Create a new list to hold the pages in the new order
|
||||
List<PDPage> newPages = new ArrayList<>();
|
||||
for (int pageIndex : newPageOrder) {
|
||||
newPages.add(document.getPage(pageIndex));
|
||||
}
|
||||
// Create a new list to hold the pages in the new order
|
||||
List<PDPage> newPages = new ArrayList<>();
|
||||
for (int pageIndex : newPageOrder) {
|
||||
newPages.add(document.getPage(pageIndex));
|
||||
}
|
||||
|
||||
// Remove all the pages from the original document
|
||||
for (int i = document.getNumberOfPages() - 1; i >= 0; i--) {
|
||||
document.removePage(i);
|
||||
}
|
||||
// Remove all the pages from the original document
|
||||
for (int i = document.getNumberOfPages() - 1; i >= 0; i--) {
|
||||
document.removePage(i);
|
||||
}
|
||||
|
||||
// Add the pages in the new order
|
||||
for (PDPage page : newPages) {
|
||||
document.addPage(page);
|
||||
}
|
||||
// Add the pages in the new order
|
||||
for (PDPage page : newPages) {
|
||||
document.addPage(page);
|
||||
}
|
||||
|
||||
// Convert PDDocument to byte array
|
||||
byte[] newPdfBytes;
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
// Convert PDDocument to byte array
|
||||
document.save(baos);
|
||||
newPdfBytes = baos.toByteArray();
|
||||
} finally {
|
||||
document.close();
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
return newPdfBytes;
|
||||
}
|
||||
}
|
||||
|
||||
+6
-5
@@ -196,11 +196,12 @@ public class ConvertOfficeController {
|
||||
try {
|
||||
file = convertToPdf(inputFile);
|
||||
|
||||
PDDocument doc = pdfDocumentFactory.load(file);
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
doc,
|
||||
GeneralUtils.generateFilename(
|
||||
inputFile.getOriginalFilename(), "_convertedToPDF.pdf"));
|
||||
try (PDDocument doc = pdfDocumentFactory.load(file)) {
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
doc,
|
||||
GeneralUtils.generateFilename(
|
||||
inputFile.getOriginalFilename(), "_convertedToPDF.pdf"));
|
||||
}
|
||||
} finally {
|
||||
if (file != null && file.getParent() != null) {
|
||||
FileUtils.deleteDirectory(file.getParentFile());
|
||||
|
||||
+1110
-342
File diff suppressed because it is too large
Load Diff
+7
-11
@@ -1,5 +1,6 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
@@ -106,7 +107,6 @@ public class ConvertWebsiteToPDF {
|
||||
|
||||
Path tempOutputFile = null;
|
||||
Path tempHtmlInput = null;
|
||||
PDDocument doc = null;
|
||||
try {
|
||||
// Download the remote content first to ensure we don't allow dangerous schemes
|
||||
String htmlContent = fetchRemoteHtml(URL);
|
||||
@@ -140,18 +140,14 @@ public class ConvertWebsiteToPDF {
|
||||
.runCommandWithOutputHandling(command);
|
||||
|
||||
// Load the PDF using pdfDocumentFactory
|
||||
doc = pdfDocumentFactory.load(tempOutputFile.toFile());
|
||||
try (PDDocument doc = pdfDocumentFactory.load(tempOutputFile.toFile());
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
// Convert URL to a safe filename
|
||||
String outputFilename = convertURLToFileName(URL);
|
||||
|
||||
// Convert URL to a safe filename
|
||||
String outputFilename = convertURLToFileName(URL);
|
||||
|
||||
ResponseEntity<byte[]> response =
|
||||
WebResponseUtils.pdfDocToWebResponse(doc, outputFilename);
|
||||
if (response == null) {
|
||||
// Defensive fallback - should not happen but avoids null returns breaking tests
|
||||
return ResponseEntity.ok(new byte[0]);
|
||||
doc.save(baos);
|
||||
return WebResponseUtils.baosToWebResponse(baos, outputFilename);
|
||||
}
|
||||
return response;
|
||||
} finally {
|
||||
if (tempHtmlInput != null) {
|
||||
try {
|
||||
|
||||
+162
-9
@@ -1,5 +1,6 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -17,8 +18,12 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.controller.api.converters.ConvertPDFToPDFA;
|
||||
import stirling.software.SPDF.model.api.misc.AddAttachmentRequest;
|
||||
import stirling.software.SPDF.model.api.misc.DeleteAttachmentRequest;
|
||||
import stirling.software.SPDF.model.api.misc.ExtractAttachmentsRequest;
|
||||
import stirling.software.SPDF.model.api.misc.ListAttachmentsRequest;
|
||||
import stirling.software.SPDF.model.api.misc.RenameAttachmentRequest;
|
||||
import stirling.software.SPDF.service.AttachmentServiceInterface;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.MiscApi;
|
||||
@@ -36,6 +41,8 @@ public class AttachmentController {
|
||||
|
||||
private final AttachmentServiceInterface pdfAttachmentService;
|
||||
|
||||
private final ConvertPDFToPDFA convertPDFToPDFA;
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/add-attachments")
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
@@ -43,19 +50,87 @@ public class AttachmentController {
|
||||
description =
|
||||
"This endpoint adds attachments to a PDF. Input:PDF, Output:PDF Type:MISO")
|
||||
public ResponseEntity<byte[]> addAttachments(@ModelAttribute AddAttachmentRequest request)
|
||||
throws IOException {
|
||||
throws Exception {
|
||||
MultipartFile fileInput = request.getFileInput();
|
||||
List<MultipartFile> attachments = request.getAttachments();
|
||||
boolean convertToPdfA3b = request.isConvertToPdfA3b();
|
||||
|
||||
PDDocument document =
|
||||
pdfAttachmentService.addAttachment(
|
||||
pdfDocumentFactory.load(fileInput, false), attachments);
|
||||
validateAttachmentRequest(attachments);
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
Filenames.toSimpleFileName(fileInput.getOriginalFilename()),
|
||||
"_with_attachments.pdf"));
|
||||
String originalFileName = Filenames.toSimpleFileName(fileInput.getOriginalFilename());
|
||||
if (originalFileName == null || originalFileName.isEmpty()) {
|
||||
originalFileName = "document";
|
||||
}
|
||||
String baseFileName =
|
||||
originalFileName.contains(".")
|
||||
? originalFileName.substring(0, originalFileName.lastIndexOf('.'))
|
||||
: originalFileName;
|
||||
|
||||
if (convertToPdfA3b) {
|
||||
byte[] pdfaBytes;
|
||||
try (PDDocument document = pdfDocumentFactory.load(request, false)) {
|
||||
pdfaBytes = convertPDFToPDFA.convertPDDocumentToPDFA(document, "pdfa-3b");
|
||||
}
|
||||
|
||||
try (PDDocument pdfaDocument = org.apache.pdfbox.Loader.loadPDF(pdfaBytes)) {
|
||||
pdfAttachmentService.addAttachment(pdfaDocument, attachments);
|
||||
|
||||
convertPDFToPDFA.ensureEmbeddedFileCompliance(pdfaDocument);
|
||||
|
||||
ConvertPDFToPDFA.fixType1FontCharSet(pdfaDocument);
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
pdfaDocument.save(baos);
|
||||
byte[] resultBytes = baos.toByteArray();
|
||||
|
||||
String outputFilename = baseFileName + "_with_attachments_PDFA-3b.pdf";
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
resultBytes, outputFilename, MediaType.APPLICATION_PDF);
|
||||
}
|
||||
} else {
|
||||
try (PDDocument document = pdfDocumentFactory.load(request, false)) {
|
||||
pdfAttachmentService.addAttachment(document, attachments);
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
Filenames.toSimpleFileName(fileInput.getOriginalFilename()),
|
||||
"_with_attachments.pdf"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateAttachmentRequest(List<MultipartFile> attachments) {
|
||||
if (attachments == null || attachments.isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.attachmentsRequired", "At least one attachment is required");
|
||||
}
|
||||
|
||||
final long maxAttachmentSize = 50L * 1024 * 1024; // 50 MB per attachment
|
||||
final long maxTotalSize = 200L * 1024 * 1024; // 200 MB total
|
||||
|
||||
long totalSize = 0;
|
||||
for (MultipartFile attachment : attachments) {
|
||||
if (attachment == null || attachment.isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.attachmentEmpty", "Attachment files cannot be null or empty");
|
||||
}
|
||||
if (attachment.getSize() > maxAttachmentSize) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.attachmentTooLarge",
|
||||
"Attachment ''{0}'' exceeds maximum size of {1} bytes",
|
||||
attachment.getOriginalFilename(),
|
||||
maxAttachmentSize);
|
||||
}
|
||||
totalSize += attachment.getSize();
|
||||
}
|
||||
|
||||
if (totalSize > maxTotalSize) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.totalAttachmentsTooLarge",
|
||||
"Total attachment size {0} exceeds maximum of {1} bytes",
|
||||
totalSize,
|
||||
maxTotalSize);
|
||||
}
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(
|
||||
@@ -88,4 +163,82 @@ public class AttachmentController {
|
||||
extracted.get(), outputName, MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/list-attachments")
|
||||
@Operation(
|
||||
summary = "List attachments in PDF",
|
||||
description =
|
||||
"This endpoint lists all embedded attachments in a PDF. Input:PDF Output:JSON Type:SISO")
|
||||
public ResponseEntity<List<stirling.software.SPDF.model.api.misc.AttachmentInfo>>
|
||||
listAttachments(@ModelAttribute ListAttachmentsRequest request) throws IOException {
|
||||
try (PDDocument document = pdfDocumentFactory.load(request, true)) {
|
||||
List<stirling.software.SPDF.model.api.misc.AttachmentInfo> attachments =
|
||||
pdfAttachmentService.listAttachments(document);
|
||||
|
||||
return ResponseEntity.ok(attachments);
|
||||
}
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/rename-attachment")
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Rename attachment in PDF",
|
||||
description =
|
||||
"This endpoint renames an embedded attachment in a PDF. Input:PDF Output:PDF Type:MISO")
|
||||
public ResponseEntity<byte[]> renameAttachment(@ModelAttribute RenameAttachmentRequest request)
|
||||
throws Exception {
|
||||
MultipartFile fileInput = request.getFileInput();
|
||||
String attachmentName = request.getAttachmentName();
|
||||
String newName = request.getNewName();
|
||||
|
||||
if (attachmentName == null || attachmentName.isBlank()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.attachmentNameRequired", "Attachment name cannot be null or empty");
|
||||
}
|
||||
if (newName == null || newName.isBlank()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.newNameRequired", "New attachment name cannot be null or empty");
|
||||
}
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(request, false)) {
|
||||
pdfAttachmentService.renameAttachment(document, attachmentName, newName);
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
Filenames.toSimpleFileName(fileInput.getOriginalFilename()),
|
||||
"_attachment_renamed.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/delete-attachment")
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Delete attachment from PDF",
|
||||
description =
|
||||
"This endpoint deletes an embedded attachment from a PDF. Input:PDF Output:PDF Type:MISO")
|
||||
public ResponseEntity<byte[]> deleteAttachment(@ModelAttribute DeleteAttachmentRequest request)
|
||||
throws Exception {
|
||||
MultipartFile fileInput = request.getFileInput();
|
||||
String attachmentName = request.getAttachmentName();
|
||||
|
||||
if (attachmentName == null || attachmentName.isBlank()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.attachmentNameRequired", "Attachment name cannot be null or empty");
|
||||
}
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(request, false)) {
|
||||
pdfAttachmentService.deleteAttachment(document, attachmentName);
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
Filenames.toSimpleFileName(fileInput.getOriginalFilename()),
|
||||
"_attachment_deleted.pdf"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+85
-82
@@ -47,102 +47,105 @@ public class AutoRenameController {
|
||||
MultipartFile file = request.getFileInput();
|
||||
boolean useFirstTextAsFallback = Boolean.TRUE.equals(request.getUseFirstTextAsFallback());
|
||||
|
||||
PDDocument document = pdfDocumentFactory.load(file);
|
||||
PDFTextStripper reader =
|
||||
new PDFTextStripper() {
|
||||
List<LineInfo> lineInfos = new ArrayList<>();
|
||||
StringBuilder lineBuilder = new StringBuilder();
|
||||
float lastY = -1;
|
||||
float maxFontSizeInLine = 0.0f;
|
||||
int lineCount = 0;
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
PDFTextStripper reader =
|
||||
new PDFTextStripper() {
|
||||
List<LineInfo> lineInfos = new ArrayList<>();
|
||||
StringBuilder lineBuilder = new StringBuilder();
|
||||
float lastY = -1;
|
||||
float maxFontSizeInLine = 0.0f;
|
||||
int lineCount = 0;
|
||||
|
||||
@Override
|
||||
protected void processTextPosition(TextPosition text) {
|
||||
if (lastY != text.getY() && lineCount < LINE_LIMIT) {
|
||||
processLine();
|
||||
lineBuilder = new StringBuilder(text.getUnicode());
|
||||
maxFontSizeInLine = text.getFontSizeInPt();
|
||||
lastY = text.getY();
|
||||
lineCount++;
|
||||
} else if (lineCount < LINE_LIMIT) {
|
||||
lineBuilder.append(text.getUnicode());
|
||||
if (text.getFontSizeInPt() > maxFontSizeInLine) {
|
||||
@Override
|
||||
protected void processTextPosition(TextPosition text) {
|
||||
if (lastY != text.getY() && lineCount < LINE_LIMIT) {
|
||||
processLine();
|
||||
lineBuilder = new StringBuilder(text.getUnicode());
|
||||
maxFontSizeInLine = text.getFontSizeInPt();
|
||||
lastY = text.getY();
|
||||
lineCount++;
|
||||
} else if (lineCount < LINE_LIMIT) {
|
||||
lineBuilder.append(text.getUnicode());
|
||||
if (text.getFontSizeInPt() > maxFontSizeInLine) {
|
||||
maxFontSizeInLine = text.getFontSizeInPt();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void processLine() {
|
||||
if (!lineBuilder.isEmpty() && lineCount < LINE_LIMIT) {
|
||||
lineInfos.add(new LineInfo(lineBuilder.toString(), maxFontSizeInLine));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getText(PDDocument doc) throws IOException {
|
||||
this.lineInfos.clear();
|
||||
this.lineBuilder = new StringBuilder();
|
||||
this.lastY = -1;
|
||||
this.maxFontSizeInLine = 0.0f;
|
||||
this.lineCount = 0;
|
||||
super.getText(doc);
|
||||
processLine(); // Process the last line
|
||||
|
||||
// Merge lines with same font size
|
||||
List<LineInfo> mergedLineInfos = new ArrayList<>();
|
||||
for (int i = 0; i < lineInfos.size(); i++) {
|
||||
StringBuilder mergedText = new StringBuilder(lineInfos.get(i).text);
|
||||
float fontSize = lineInfos.get(i).fontSize;
|
||||
while (i + 1 < lineInfos.size()
|
||||
&& lineInfos.get(i + 1).fontSize == fontSize) {
|
||||
mergedText.append(" ").append(lineInfos.get(i + 1).text);
|
||||
i++;
|
||||
private void processLine() {
|
||||
if (!lineBuilder.isEmpty() && lineCount < LINE_LIMIT) {
|
||||
lineInfos.add(
|
||||
new LineInfo(lineBuilder.toString(), maxFontSizeInLine));
|
||||
}
|
||||
mergedLineInfos.add(new LineInfo(mergedText.toString(), fontSize));
|
||||
}
|
||||
|
||||
// Sort lines by font size in descending order and get the first one
|
||||
mergedLineInfos.sort(
|
||||
Comparator.comparing((LineInfo li) -> li.fontSize).reversed());
|
||||
String title =
|
||||
mergedLineInfos.isEmpty() ? null : mergedLineInfos.get(0).text;
|
||||
@Override
|
||||
public String getText(PDDocument doc) throws IOException {
|
||||
this.lineInfos.clear();
|
||||
this.lineBuilder = new StringBuilder();
|
||||
this.lastY = -1;
|
||||
this.maxFontSizeInLine = 0.0f;
|
||||
this.lineCount = 0;
|
||||
super.getText(doc);
|
||||
processLine(); // Process the last line
|
||||
|
||||
return title != null
|
||||
? title
|
||||
: (useFirstTextAsFallback
|
||||
? (mergedLineInfos.isEmpty()
|
||||
? null
|
||||
: mergedLineInfos.get(mergedLineInfos.size() - 1)
|
||||
.text)
|
||||
: null);
|
||||
}
|
||||
// Merge lines with same font size
|
||||
List<LineInfo> mergedLineInfos = new ArrayList<>();
|
||||
for (int i = 0; i < lineInfos.size(); i++) {
|
||||
StringBuilder mergedText = new StringBuilder(lineInfos.get(i).text);
|
||||
float fontSize = lineInfos.get(i).fontSize;
|
||||
while (i + 1 < lineInfos.size()
|
||||
&& lineInfos.get(i + 1).fontSize == fontSize) {
|
||||
mergedText.append(" ").append(lineInfos.get(i + 1).text);
|
||||
i++;
|
||||
}
|
||||
mergedLineInfos.add(new LineInfo(mergedText.toString(), fontSize));
|
||||
}
|
||||
|
||||
class LineInfo {
|
||||
String text;
|
||||
float fontSize;
|
||||
// Sort lines by font size in descending order and get the first one
|
||||
mergedLineInfos.sort(
|
||||
Comparator.comparing((LineInfo li) -> li.fontSize).reversed());
|
||||
String title =
|
||||
mergedLineInfos.isEmpty() ? null : mergedLineInfos.get(0).text;
|
||||
|
||||
LineInfo(String text, float fontSize) {
|
||||
this.text = text;
|
||||
this.fontSize = fontSize;
|
||||
return title != null
|
||||
? title
|
||||
: (useFirstTextAsFallback
|
||||
? (mergedLineInfos.isEmpty()
|
||||
? null
|
||||
: mergedLineInfos.get(
|
||||
mergedLineInfos.size() - 1)
|
||||
.text)
|
||||
: null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
String header = reader.getText(document);
|
||||
class LineInfo {
|
||||
String text;
|
||||
float fontSize;
|
||||
|
||||
// Sanitize the header string by removing characters not allowed in a filename.
|
||||
if (header != null && header.length() < 255) {
|
||||
header =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getSafeFilenamePattern()
|
||||
.matcher(header)
|
||||
.replaceAll("")
|
||||
.trim();
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, header + ".pdf");
|
||||
} else {
|
||||
log.info("File has no good title to be found");
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document, Filenames.toSimpleFileName(file.getOriginalFilename()));
|
||||
LineInfo(String text, float fontSize) {
|
||||
this.text = text;
|
||||
this.fontSize = fontSize;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
String header = reader.getText(document);
|
||||
|
||||
// Sanitize the header string by removing characters not allowed in a filename.
|
||||
if (header != null && header.length() < 255) {
|
||||
header =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getSafeFilenamePattern()
|
||||
.matcher(header)
|
||||
.replaceAll("")
|
||||
.trim();
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, header + ".pdf");
|
||||
} else {
|
||||
log.info("File has no good title to be found");
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document, Filenames.toSimpleFileName(file.getOriginalFilename()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-19
@@ -119,13 +119,9 @@ public class AutoSplitPdfController {
|
||||
MultipartFile file = request.getFileInput();
|
||||
boolean duplexMode = Boolean.TRUE.equals(request.getDuplexMode());
|
||||
|
||||
PDDocument document = null;
|
||||
List<PDDocument> splitDocuments = new ArrayList<>();
|
||||
TempFile outputTempFile = null;
|
||||
|
||||
try {
|
||||
outputTempFile = new TempFile(tempFileManager, ".zip");
|
||||
document = pdfDocumentFactory.load(file.getInputStream());
|
||||
try (TempFile outputTempFile = new TempFile(tempFileManager, ".zip");
|
||||
PDDocument document = pdfDocumentFactory.load(file.getInputStream())) {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true);
|
||||
|
||||
@@ -201,15 +197,7 @@ public class AutoSplitPdfController {
|
||||
log.error("Error in auto split", e);
|
||||
throw e;
|
||||
} finally {
|
||||
// Clean up resources
|
||||
if (document != null) {
|
||||
try {
|
||||
document.close();
|
||||
} catch (IOException e) {
|
||||
log.error("Error closing main PDDocument", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up split documents
|
||||
for (PDDocument splitDoc : splitDocuments) {
|
||||
try {
|
||||
splitDoc.close();
|
||||
@@ -217,10 +205,6 @@ public class AutoSplitPdfController {
|
||||
log.error("Error closing split PDDocument", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (outputTempFile != null) {
|
||||
outputTempFile.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-8
@@ -53,18 +53,20 @@ public class BlankPageController {
|
||||
|
||||
// Convert to binary image based on the threshold
|
||||
int whitePixels = 0;
|
||||
int totalPixels = image.getWidth() * image.getHeight();
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
int[] pixels = new int[width * height];
|
||||
|
||||
for (int i = 0; i < image.getHeight(); i++) {
|
||||
for (int j = 0; j < image.getWidth(); j++) {
|
||||
int color = image.getRGB(j, i) & 0xFF;
|
||||
if (color >= 255 - threshold) {
|
||||
whitePixels++;
|
||||
}
|
||||
image.getRGB(0, 0, width, height, pixels, 0, width);
|
||||
|
||||
for (int pixel : pixels) {
|
||||
int blue = pixel & 0xFF;
|
||||
if (blue >= 255 - threshold) {
|
||||
whitePixels++;
|
||||
}
|
||||
}
|
||||
|
||||
double whitePixelPercentage = (whitePixels / (double) totalPixels) * 100;
|
||||
double whitePixelPercentage = (whitePixels / (double) (width * height)) * 100;
|
||||
log.info(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
|
||||
+3
-2
@@ -1100,8 +1100,9 @@ public class CompressController {
|
||||
inputFile.getOriginalFilename(), "_Optimized.pdf");
|
||||
|
||||
try {
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
pdfDocumentFactory.load(currentFile.toFile()), outputFilename);
|
||||
try (PDDocument document = pdfDocumentFactory.load(currentFile.toFile())) {
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, outputFilename);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw ExceptionUtils.handlePdfException(e, "PDF optimization");
|
||||
}
|
||||
|
||||
+27
@@ -71,6 +71,30 @@ public class ConfigController {
|
||||
configData.put("contextPath", appConfig.getContextPath());
|
||||
configData.put("serverPort", appConfig.getServerPort());
|
||||
|
||||
// Add frontendUrl for mobile scanner QR codes
|
||||
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
||||
configData.put("frontendUrl", frontendUrl != null ? frontendUrl : "");
|
||||
|
||||
// Add mobile scanner settings
|
||||
configData.put(
|
||||
"enableMobileScanner",
|
||||
applicationProperties.getSystem().isEnableMobileScanner());
|
||||
configData.put(
|
||||
"mobileScannerConvertToPdf",
|
||||
applicationProperties.getSystem().getMobileScannerSettings().isConvertToPdf());
|
||||
configData.put(
|
||||
"mobileScannerImageResolution",
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.getMobileScannerSettings()
|
||||
.getImageResolution());
|
||||
configData.put(
|
||||
"mobileScannerPageFormat",
|
||||
applicationProperties.getSystem().getMobileScannerSettings().getPageFormat());
|
||||
configData.put(
|
||||
"mobileScannerStretchToFit",
|
||||
applicationProperties.getSystem().getMobileScannerSettings().isStretchToFit());
|
||||
|
||||
// Extract values from ApplicationProperties
|
||||
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
|
||||
configData.put("languages", applicationProperties.getUi().getLanguages());
|
||||
@@ -84,6 +108,9 @@ public class ConfigController {
|
||||
boolean enableLogin =
|
||||
applicationProperties.getSecurity().isEnableLogin() && userService != null;
|
||||
configData.put("enableLogin", enableLogin);
|
||||
configData.put(
|
||||
"showSettingsWhenNoLogin",
|
||||
applicationProperties.getSystem().isShowSettingsWhenNoLogin());
|
||||
|
||||
// Mail settings - check both SMTP enabled AND invites enabled
|
||||
boolean smtpEnabled = applicationProperties.getMail().isEnabled();
|
||||
|
||||
+91
-81
@@ -49,93 +49,103 @@ public class FlattenController {
|
||||
public ResponseEntity<byte[]> flatten(@ModelAttribute FlattenRequest request) throws Exception {
|
||||
MultipartFile file = request.getFileInput();
|
||||
|
||||
PDDocument document = pdfDocumentFactory.load(file);
|
||||
Boolean flattenOnlyForms = request.getFlattenOnlyForms();
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
Boolean flattenOnlyForms = request.getFlattenOnlyForms();
|
||||
|
||||
if (Boolean.TRUE.equals(flattenOnlyForms)) {
|
||||
PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm();
|
||||
if (acroForm != null) {
|
||||
acroForm.flatten();
|
||||
}
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document, Filenames.toSimpleFileName(file.getOriginalFilename()));
|
||||
} else {
|
||||
// flatten whole page aka convert each page to image and re-add it (making text
|
||||
// unselectable)
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document);
|
||||
if (Boolean.TRUE.equals(flattenOnlyForms)) {
|
||||
PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm();
|
||||
if (acroForm != null) {
|
||||
acroForm.flatten();
|
||||
}
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document, Filenames.toSimpleFileName(file.getOriginalFilename()));
|
||||
} else {
|
||||
// flatten whole page aka convert each page to image and re-add it (making text
|
||||
// unselectable)
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(
|
||||
true); // Enable subsampling to reduce memory usage
|
||||
|
||||
int defaultRenderDpi = 100; // Default fallback
|
||||
ApplicationProperties properties =
|
||||
ApplicationContextProvider.getBean(ApplicationProperties.class);
|
||||
Integer configuredMaxDpi = null;
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
configuredMaxDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
try (PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document)) {
|
||||
|
||||
int maxDpi =
|
||||
(configuredMaxDpi != null && configuredMaxDpi > 0)
|
||||
? configuredMaxDpi
|
||||
: defaultRenderDpi;
|
||||
|
||||
Integer requestedDpi = request.getRenderDpi();
|
||||
int renderDpiTemp = maxDpi;
|
||||
if (requestedDpi != null) {
|
||||
renderDpiTemp = Math.min(requestedDpi, maxDpi);
|
||||
renderDpiTemp = Math.max(renderDpiTemp, 72);
|
||||
}
|
||||
final int renderDpi = renderDpiTemp;
|
||||
|
||||
int numPages = document.getNumberOfPages();
|
||||
for (int i = 0; i < numPages; i++) {
|
||||
final int pageIndex = i;
|
||||
BufferedImage image = null;
|
||||
try {
|
||||
// Validate dimensions BEFORE rendering to prevent OOM
|
||||
ExceptionUtils.validateRenderingDimensions(
|
||||
document.getPage(pageIndex), pageIndex + 1, renderDpi);
|
||||
|
||||
// Wrap entire rendering operation to catch OutOfMemoryError from any depth
|
||||
image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
renderDpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, renderDpi, ImageType.RGB));
|
||||
|
||||
PDPage page = new PDPage();
|
||||
page.setMediaBox(document.getPage(i).getMediaBox());
|
||||
newDocument.addPage(page);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(newDocument, page)) {
|
||||
PDImageXObject pdImage = JPEGFactory.createFromImage(newDocument, image);
|
||||
float pageWidth = page.getMediaBox().getWidth();
|
||||
float pageHeight = page.getMediaBox().getHeight();
|
||||
|
||||
contentStream.drawImage(pdImage, 0, 0, pageWidth, pageHeight);
|
||||
int defaultRenderDpi = 100; // Default fallback
|
||||
ApplicationProperties properties =
|
||||
ApplicationContextProvider.getBean(ApplicationProperties.class);
|
||||
Integer configuredMaxDpi = null;
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
configuredMaxDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
|
||||
// Re-throw OutOfMemoryDpiException to be handled by GlobalExceptionHandler
|
||||
newDocument.close();
|
||||
document.close();
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
log.error("IOException during page processing: ", e);
|
||||
// Continue processing other pages
|
||||
} catch (OutOfMemoryError e) {
|
||||
// Catch any OutOfMemoryError that escaped the inner try block
|
||||
newDocument.close();
|
||||
document.close();
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e);
|
||||
} finally {
|
||||
// Help GC by clearing the image reference
|
||||
image = null;
|
||||
|
||||
int maxDpi =
|
||||
(configuredMaxDpi != null && configuredMaxDpi > 0)
|
||||
? configuredMaxDpi
|
||||
: defaultRenderDpi;
|
||||
|
||||
Integer requestedDpi = request.getRenderDpi();
|
||||
int renderDpiTemp = maxDpi;
|
||||
if (requestedDpi != null) {
|
||||
renderDpiTemp = Math.min(requestedDpi, maxDpi);
|
||||
renderDpiTemp = Math.max(renderDpiTemp, 72);
|
||||
}
|
||||
final int renderDpi = renderDpiTemp;
|
||||
|
||||
int numPages = document.getNumberOfPages();
|
||||
for (int i = 0; i < numPages; i++) {
|
||||
final int pageIndex = i;
|
||||
BufferedImage image = null;
|
||||
try {
|
||||
// Validate dimensions BEFORE rendering to prevent OOM
|
||||
ExceptionUtils.validateRenderingDimensions(
|
||||
document.getPage(pageIndex), pageIndex + 1, renderDpi);
|
||||
|
||||
// Wrap entire rendering operation to catch OutOfMemoryError from any
|
||||
// depth
|
||||
image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
renderDpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, renderDpi, ImageType.RGB));
|
||||
|
||||
PDPage page = new PDPage();
|
||||
page.setMediaBox(document.getPage(i).getMediaBox());
|
||||
newDocument.addPage(page);
|
||||
// resetContext=true: Ensure clean graphics state when overwriting.
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
newDocument,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true,
|
||||
true)) {
|
||||
PDImageXObject pdImage =
|
||||
JPEGFactory.createFromImage(newDocument, image);
|
||||
float pageWidth = page.getMediaBox().getWidth();
|
||||
float pageHeight = page.getMediaBox().getHeight();
|
||||
|
||||
contentStream.drawImage(pdImage, 0, 0, pageWidth, pageHeight);
|
||||
}
|
||||
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
|
||||
// Re-throw OutOfMemoryDpiException to be handled by
|
||||
// GlobalExceptionHandler
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
log.error("IOException during page processing: ", e);
|
||||
// Continue processing other pages
|
||||
} catch (OutOfMemoryError e) {
|
||||
// Catch any OutOfMemoryError that escaped the inner try block
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e);
|
||||
} finally {
|
||||
// Help GC by clearing the image reference
|
||||
image = null;
|
||||
}
|
||||
}
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
newDocument, Filenames.toSimpleFileName(file.getOriginalFilename()));
|
||||
}
|
||||
}
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
newDocument, Filenames.toSimpleFileName(file.getOriginalFilename()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+91
-82
@@ -84,93 +84,102 @@ public class MetadataController {
|
||||
if (allRequestParams == null) {
|
||||
allRequestParams = new java.util.HashMap<String, String>();
|
||||
}
|
||||
// Load the PDF file into a PDDocument
|
||||
PDDocument document = pdfDocumentFactory.load(pdfFile, true);
|
||||
// Load the PDF file into a PDDocument with proper resource management
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile, true)) {
|
||||
|
||||
// Get the document information from the PDF
|
||||
PDDocumentInformation info = document.getDocumentInformation();
|
||||
// Get the document information from the PDF
|
||||
PDDocumentInformation info = document.getDocumentInformation();
|
||||
|
||||
// Check if each metadata value is "undefined" and set it to null if it is
|
||||
author = checkUndefined(author);
|
||||
creationDate = checkUndefined(creationDate);
|
||||
creator = checkUndefined(creator);
|
||||
keywords = checkUndefined(keywords);
|
||||
modificationDate = checkUndefined(modificationDate);
|
||||
producer = checkUndefined(producer);
|
||||
subject = checkUndefined(subject);
|
||||
title = checkUndefined(title);
|
||||
trapped = checkUndefined(trapped);
|
||||
// Check if each metadata value is "undefined" and set it to null if it is
|
||||
author = checkUndefined(author);
|
||||
creationDate = checkUndefined(creationDate);
|
||||
creator = checkUndefined(creator);
|
||||
keywords = checkUndefined(keywords);
|
||||
modificationDate = checkUndefined(modificationDate);
|
||||
producer = checkUndefined(producer);
|
||||
subject = checkUndefined(subject);
|
||||
title = checkUndefined(title);
|
||||
trapped = checkUndefined(trapped);
|
||||
|
||||
// If the "deleteAll" flag is set, remove all metadata from the document
|
||||
// information
|
||||
if (deleteAll) {
|
||||
for (String key : info.getMetadataKeys()) {
|
||||
info.setCustomMetadataValue(key, null);
|
||||
}
|
||||
// Remove metadata from the PDF history
|
||||
document.getDocumentCatalog().getCOSObject().removeItem(COSName.getPDFName("Metadata"));
|
||||
document.getDocumentCatalog()
|
||||
.getCOSObject()
|
||||
.removeItem(COSName.getPDFName("PieceInfo"));
|
||||
author = null;
|
||||
creationDate = null;
|
||||
creator = null;
|
||||
keywords = null;
|
||||
modificationDate = null;
|
||||
producer = null;
|
||||
subject = null;
|
||||
title = null;
|
||||
trapped = null;
|
||||
} else {
|
||||
// Iterate through the request parameters and set the metadata values
|
||||
for (Entry<String, String> entry : allRequestParams.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
// Check if the key is a standard metadata key
|
||||
if (!"Author".equalsIgnoreCase(key)
|
||||
&& !"CreationDate".equalsIgnoreCase(key)
|
||||
&& !"Creator".equalsIgnoreCase(key)
|
||||
&& !"Keywords".equalsIgnoreCase(key)
|
||||
&& !"modificationDate".equalsIgnoreCase(key)
|
||||
&& !"Producer".equalsIgnoreCase(key)
|
||||
&& !"Subject".equalsIgnoreCase(key)
|
||||
&& !"Title".equalsIgnoreCase(key)
|
||||
&& !"Trapped".equalsIgnoreCase(key)
|
||||
&& !key.contains("customKey")
|
||||
&& !key.contains("customValue")) {
|
||||
info.setCustomMetadataValue(key, entry.getValue());
|
||||
} else if (key.contains("customKey")) {
|
||||
int number =
|
||||
Integer.parseInt(
|
||||
RegexPatternUtils.getInstance()
|
||||
.getNumericExtractionPattern()
|
||||
.matcher(key)
|
||||
.replaceAll(""));
|
||||
String customKey = entry.getValue();
|
||||
String customValue = allRequestParams.get("customValue" + number);
|
||||
info.setCustomMetadataValue(customKey, customValue);
|
||||
// If the "deleteAll" flag is set, remove all metadata from the document
|
||||
// information
|
||||
if (deleteAll) {
|
||||
for (String key : info.getMetadataKeys()) {
|
||||
info.setCustomMetadataValue(key, null);
|
||||
}
|
||||
// Remove metadata from the PDF history
|
||||
document.getDocumentCatalog()
|
||||
.getCOSObject()
|
||||
.removeItem(COSName.getPDFName("Metadata"));
|
||||
document.getDocumentCatalog()
|
||||
.getCOSObject()
|
||||
.removeItem(COSName.getPDFName("PieceInfo"));
|
||||
author = null;
|
||||
creationDate = null;
|
||||
creator = null;
|
||||
keywords = null;
|
||||
modificationDate = null;
|
||||
producer = null;
|
||||
subject = null;
|
||||
title = null;
|
||||
trapped = null;
|
||||
} else {
|
||||
// Iterate through the request parameters and set the metadata values
|
||||
for (Entry<String, String> entry : allRequestParams.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
// Check if the key is a standard metadata key
|
||||
if (!"Author".equalsIgnoreCase(key)
|
||||
&& !"CreationDate".equalsIgnoreCase(key)
|
||||
&& !"Creator".equalsIgnoreCase(key)
|
||||
&& !"Keywords".equalsIgnoreCase(key)
|
||||
&& !"modificationDate".equalsIgnoreCase(key)
|
||||
&& !"Producer".equalsIgnoreCase(key)
|
||||
&& !"Subject".equalsIgnoreCase(key)
|
||||
&& !"Title".equalsIgnoreCase(key)
|
||||
&& !"Trapped".equalsIgnoreCase(key)
|
||||
&& !key.contains("customKey")
|
||||
&& !key.contains("customValue")) {
|
||||
info.setCustomMetadataValue(key, entry.getValue());
|
||||
} else if (key.contains("customKey")) {
|
||||
try {
|
||||
int number =
|
||||
Integer.parseInt(
|
||||
RegexPatternUtils.getInstance()
|
||||
.getNumericExtractionPattern()
|
||||
.matcher(key)
|
||||
.replaceAll(""));
|
||||
String customKey = entry.getValue();
|
||||
String customValue = allRequestParams.get("customValue" + number);
|
||||
info.setCustomMetadataValue(customKey, customValue);
|
||||
} catch (NumberFormatException e) {
|
||||
// Skip invalid custom key entries that don't have valid numeric
|
||||
// suffixes
|
||||
log.warn("Skipping invalid custom key '{}': {}", key, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Set creation date using utility method
|
||||
Calendar creationDateCal = PdfMetadataService.parseToCalendar(creationDate);
|
||||
info.setCreationDate(creationDateCal);
|
||||
|
||||
// Set modification date using utility method
|
||||
Calendar modificationDateCal = PdfMetadataService.parseToCalendar(modificationDate);
|
||||
info.setModificationDate(modificationDateCal);
|
||||
info.setCreator(creator);
|
||||
info.setKeywords(keywords);
|
||||
info.setAuthor(author);
|
||||
info.setProducer(producer);
|
||||
info.setSubject(subject);
|
||||
info.setTitle(title);
|
||||
info.setTrapped(trapped);
|
||||
|
||||
document.setDocumentInformation(info);
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.removeExtension(
|
||||
Filenames.toSimpleFileName(pdfFile.getOriginalFilename()))
|
||||
+ "_metadata.pdf");
|
||||
}
|
||||
// Set creation date using utility method
|
||||
Calendar creationDateCal = PdfMetadataService.parseToCalendar(creationDate);
|
||||
info.setCreationDate(creationDateCal);
|
||||
|
||||
// Set modification date using utility method
|
||||
Calendar modificationDateCal = PdfMetadataService.parseToCalendar(modificationDate);
|
||||
info.setModificationDate(modificationDateCal);
|
||||
info.setCreator(creator);
|
||||
info.setKeywords(keywords);
|
||||
info.setAuthor(author);
|
||||
info.setProducer(producer);
|
||||
info.setSubject(subject);
|
||||
info.setTitle(title);
|
||||
info.setTrapped(trapped);
|
||||
|
||||
document.setDocumentInformation(info);
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.removeExtension(
|
||||
Filenames.toSimpleFileName(pdfFile.getOriginalFilename()))
|
||||
+ "_metadata.pdf");
|
||||
}
|
||||
}
|
||||
|
||||
+360
@@ -0,0 +1,360 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.MobileScannerService;
|
||||
import stirling.software.common.service.MobileScannerService.FileMetadata;
|
||||
|
||||
/**
|
||||
* REST controller for mobile scanner functionality. Allows mobile devices to upload scanned images
|
||||
* that can be retrieved by desktop clients via a session-based system. No authentication required
|
||||
* for peer-to-peer scanning workflow.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/mobile-scanner")
|
||||
@Tag(
|
||||
name = "Mobile Scanner",
|
||||
description =
|
||||
"Endpoints for mobile-to-desktop file transfer via QR code scanning. "
|
||||
+ "Files are temporarily stored and automatically cleaned up after 10 minutes.")
|
||||
@Hidden
|
||||
@Slf4j
|
||||
public class MobileScannerController {
|
||||
|
||||
private final MobileScannerService mobileScannerService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public MobileScannerController(
|
||||
MobileScannerService mobileScannerService,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.mobileScannerService = mobileScannerService;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if mobile scanner feature is enabled
|
||||
*
|
||||
* @return Error response if disabled, null if enabled
|
||||
*/
|
||||
private ResponseEntity<Map<String, Object>> checkFeatureEnabled() {
|
||||
if (!applicationProperties.getSystem().isEnableMobileScanner()) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Mobile scanner feature is not enabled",
|
||||
"enabled",
|
||||
false));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session (called by desktop when QR code is generated)
|
||||
*
|
||||
* @param sessionId Unique session identifier
|
||||
* @return Session information with expiry time
|
||||
*/
|
||||
@PostMapping("/create-session/{sessionId}")
|
||||
@Operation(
|
||||
summary = "Create a new mobile scanner session",
|
||||
description = "Desktop clients call this when generating a QR code")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Session created successfully",
|
||||
content = @Content(schema = @Schema(implementation = SessionInfoResponse.class)))
|
||||
@ApiResponse(responseCode = "400", description = "Invalid session ID")
|
||||
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||
public ResponseEntity<Map<String, Object>> createSession(
|
||||
@Parameter(description = "Session ID for QR code", required = true) @PathVariable
|
||||
String sessionId) {
|
||||
|
||||
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
|
||||
if (featureCheck != null) {
|
||||
return featureCheck;
|
||||
}
|
||||
|
||||
try {
|
||||
MobileScannerService.SessionInfo sessionInfo =
|
||||
mobileScannerService.createSession(sessionId);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("sessionId", sessionInfo.getSessionId());
|
||||
response.put("createdAt", sessionInfo.getCreatedAt());
|
||||
response.put("expiresAt", sessionInfo.getExpiresAt());
|
||||
response.put("timeoutMs", sessionInfo.getTimeoutMs());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Invalid session creation request: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a session exists and is not expired
|
||||
*
|
||||
* @param sessionId Session identifier to validate
|
||||
* @return Session information if valid, error if invalid/expired
|
||||
*/
|
||||
@GetMapping("/validate-session/{sessionId}")
|
||||
@Operation(
|
||||
summary = "Validate a mobile scanner session",
|
||||
description = "Check if session exists and is not expired")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Session is valid",
|
||||
content = @Content(schema = @Schema(implementation = SessionInfoResponse.class)))
|
||||
@ApiResponse(responseCode = "404", description = "Session not found or expired")
|
||||
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||
public ResponseEntity<Map<String, Object>> validateSession(
|
||||
@Parameter(description = "Session ID to validate", required = true) @PathVariable
|
||||
String sessionId) {
|
||||
|
||||
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
|
||||
if (featureCheck != null) {
|
||||
return featureCheck;
|
||||
}
|
||||
|
||||
MobileScannerService.SessionInfo sessionInfo =
|
||||
mobileScannerService.validateSession(sessionId);
|
||||
|
||||
if (sessionInfo == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("valid", false, "error", "Session not found or expired"));
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("valid", true);
|
||||
response.put("sessionId", sessionInfo.getSessionId());
|
||||
response.put("createdAt", sessionInfo.getCreatedAt());
|
||||
response.put("expiresAt", sessionInfo.getExpiresAt());
|
||||
response.put("timeoutMs", sessionInfo.getTimeoutMs());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload files from mobile device
|
||||
*
|
||||
* @param sessionId Unique session identifier from QR code
|
||||
* @param files Files to upload
|
||||
* @return Upload status
|
||||
*/
|
||||
@PostMapping("/upload/{sessionId}")
|
||||
@Operation(
|
||||
summary = "Upload scanned files from mobile device",
|
||||
description = "Mobile devices upload scanned images to a temporary session")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Files uploaded successfully",
|
||||
content = @Content(schema = @Schema(implementation = UploadResponse.class)))
|
||||
@ApiResponse(responseCode = "400", description = "Invalid session ID or files")
|
||||
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||
@ApiResponse(responseCode = "500", description = "Upload failed")
|
||||
public ResponseEntity<Map<String, Object>> uploadFiles(
|
||||
@Parameter(description = "Session ID from QR code", required = true) @PathVariable
|
||||
String sessionId,
|
||||
@Parameter(description = "Files to upload", required = true) @RequestParam("files")
|
||||
List<MultipartFile> files) {
|
||||
|
||||
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
|
||||
if (featureCheck != null) {
|
||||
return featureCheck;
|
||||
}
|
||||
|
||||
try {
|
||||
if (files == null || files.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "No files provided"));
|
||||
}
|
||||
|
||||
mobileScannerService.uploadFiles(sessionId, files);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("sessionId", sessionId);
|
||||
response.put("filesUploaded", files.size());
|
||||
response.put("message", "Files uploaded successfully");
|
||||
|
||||
log.info("Mobile scanner upload: session={}, files={}", sessionId, files.size());
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Invalid mobile scanner upload request: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to upload files for session: {}", sessionId, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to save files"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of uploaded files for a session
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @return List of file metadata
|
||||
*/
|
||||
@GetMapping("/files/{sessionId}")
|
||||
@Operation(
|
||||
summary = "Get uploaded files for a session",
|
||||
description = "Desktop clients poll this endpoint to check for new uploads")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "File list retrieved",
|
||||
content = @Content(schema = @Schema(implementation = FileListResponse.class)))
|
||||
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||
public ResponseEntity<Map<String, Object>> getSessionFiles(
|
||||
@Parameter(description = "Session ID", required = true) @PathVariable
|
||||
String sessionId) {
|
||||
|
||||
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
|
||||
if (featureCheck != null) {
|
||||
return featureCheck;
|
||||
}
|
||||
|
||||
List<FileMetadata> files = mobileScannerService.getSessionFiles(sessionId);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("sessionId", sessionId);
|
||||
response.put("files", files);
|
||||
response.put("count", files.size());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a specific file from a session
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @param filename Filename to download
|
||||
* @return File content
|
||||
*/
|
||||
@GetMapping("/download/{sessionId}/{filename}")
|
||||
@Operation(
|
||||
summary = "Download a specific file",
|
||||
description =
|
||||
"Download a file that was uploaded to a session. File is automatically deleted after download.")
|
||||
@ApiResponse(responseCode = "200", description = "File downloaded successfully")
|
||||
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||
@ApiResponse(responseCode = "404", description = "File or session not found")
|
||||
public ResponseEntity<Resource> downloadFile(
|
||||
@Parameter(description = "Session ID", required = true) @PathVariable String sessionId,
|
||||
@Parameter(description = "Filename to download", required = true) @PathVariable
|
||||
String filename) {
|
||||
|
||||
if (!applicationProperties.getSystem().isEnableMobileScanner()) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
|
||||
try {
|
||||
Path filePath = mobileScannerService.getFile(sessionId, filename);
|
||||
|
||||
// Read file into memory first, so we can delete it before sending
|
||||
byte[] fileBytes = Files.readAllBytes(filePath);
|
||||
|
||||
String contentType = Files.probeContentType(filePath);
|
||||
if (contentType == null) {
|
||||
contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
|
||||
}
|
||||
|
||||
// Delete file immediately after reading into memory (server-side cleanup)
|
||||
mobileScannerService.deleteFileAfterDownload(sessionId, filename);
|
||||
|
||||
// Serve from memory
|
||||
Resource resource = new org.springframework.core.io.ByteArrayResource(fileBytes);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(contentType))
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + filename + "\"")
|
||||
.body(resource);
|
||||
|
||||
} catch (IOException e) {
|
||||
log.warn("File not found: session={}, file={}", sessionId, filename);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session and all its files
|
||||
*
|
||||
* @param sessionId Session to delete
|
||||
* @return Deletion status
|
||||
*/
|
||||
@DeleteMapping("/session/{sessionId}")
|
||||
@Operation(
|
||||
summary = "Delete a session",
|
||||
description = "Manually delete a session and all its uploaded files")
|
||||
@ApiResponse(responseCode = "200", description = "Session deleted successfully")
|
||||
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||
public ResponseEntity<Map<String, Object>> deleteSession(
|
||||
@Parameter(description = "Session ID to delete", required = true) @PathVariable
|
||||
String sessionId) {
|
||||
|
||||
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
|
||||
if (featureCheck != null) {
|
||||
return featureCheck;
|
||||
}
|
||||
|
||||
mobileScannerService.deleteSession(sessionId);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of("success", true, "sessionId", sessionId, "message", "Session deleted"));
|
||||
}
|
||||
|
||||
// Response schemas for OpenAPI documentation
|
||||
private static class SessionInfoResponse {
|
||||
public boolean success;
|
||||
public String sessionId;
|
||||
public long createdAt;
|
||||
public long expiresAt;
|
||||
public long timeoutMs;
|
||||
}
|
||||
|
||||
private static class UploadResponse {
|
||||
public boolean success;
|
||||
public String sessionId;
|
||||
public int filesUploaded;
|
||||
public String message;
|
||||
}
|
||||
|
||||
private static class FileListResponse {
|
||||
public String sessionId;
|
||||
public List<FileMetadata> files;
|
||||
public int count;
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -15,6 +15,7 @@ import java.util.zip.ZipOutputStream;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.apache.pdfbox.io.IOUtils;
|
||||
import org.apache.pdfbox.multipdf.PDFMergerUtility;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
@@ -255,6 +256,7 @@ public class OCRController {
|
||||
command.add("--force-ocr");
|
||||
}
|
||||
}
|
||||
command.add("--invalidate-digital-signatures");
|
||||
|
||||
command.addAll(
|
||||
Arrays.asList(
|
||||
@@ -325,6 +327,8 @@ public class OCRController {
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(tempInputFile.toFile())) {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(
|
||||
true); // Enable subsampling to reduce memory usage
|
||||
int pageCount = document.getNumberOfPages();
|
||||
|
||||
for (int pageNum = 0; pageNum < pageCount; pageNum++) {
|
||||
@@ -414,7 +418,7 @@ public class OCRController {
|
||||
}
|
||||
|
||||
// Merge all pages into final PDF
|
||||
merger.mergeDocuments(null);
|
||||
merger.mergeDocuments(IOUtils.createTempFileOnlyStreamCache());
|
||||
|
||||
// Copy final output to the expected location
|
||||
Files.copy(
|
||||
|
||||
+104
-103
@@ -65,113 +65,114 @@ public class PageNumbersController {
|
||||
}
|
||||
}
|
||||
|
||||
PDDocument document = pdfDocumentFactory.load(file);
|
||||
|
||||
float marginFactor =
|
||||
switch (customMargin == null ? "" : customMargin.toLowerCase(Locale.ROOT)) {
|
||||
case "small" -> 0.02f;
|
||||
case "large" -> 0.05f;
|
||||
case "x-large" -> 0.075f;
|
||||
case "medium" -> 0.035f;
|
||||
default -> 0.035f;
|
||||
};
|
||||
|
||||
if (pagesToNumber == null || pagesToNumber.isEmpty()) {
|
||||
pagesToNumber = "all";
|
||||
}
|
||||
if (customText == null || customText.isEmpty()) {
|
||||
customText = "{n}";
|
||||
}
|
||||
|
||||
final String baseFilename =
|
||||
Filenames.toSimpleFileName(file.getOriginalFilename())
|
||||
.replaceFirst("[.][^.]+$", "");
|
||||
|
||||
List<Integer> pagesToNumberList =
|
||||
GeneralUtils.parsePageList(pagesToNumber.split(","), document.getNumberOfPages());
|
||||
|
||||
// Clamp position to 1..9 (1 = top-left, 9 = bottom-right)
|
||||
int pos = Math.max(1, Math.min(9, position));
|
||||
|
||||
for (int i : pagesToNumberList) {
|
||||
PDPage page = document.getPage(i);
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
|
||||
String text =
|
||||
customText
|
||||
.replace("{n}", String.valueOf(pageNumber))
|
||||
.replace("{total}", String.valueOf(document.getNumberOfPages()))
|
||||
.replace(
|
||||
"{filename}",
|
||||
GeneralUtils.removeExtension(
|
||||
Filenames.toSimpleFileName(
|
||||
file.getOriginalFilename())));
|
||||
|
||||
PDType1Font currentFont =
|
||||
switch (fontType == null ? "" : fontType.toLowerCase(Locale.ROOT)) {
|
||||
case "courier" -> new PDType1Font(Standard14Fonts.FontName.COURIER);
|
||||
case "times" -> new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN);
|
||||
default -> new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
float marginFactor =
|
||||
switch (customMargin == null ? "" : customMargin.toLowerCase(Locale.ROOT)) {
|
||||
case "small" -> 0.02f;
|
||||
case "large" -> 0.05f;
|
||||
case "x-large" -> 0.075f;
|
||||
case "medium" -> 0.035f;
|
||||
default -> 0.035f;
|
||||
};
|
||||
|
||||
// Text dimensions and font metrics
|
||||
float textWidth = currentFont.getStringWidth(text) / 1000f * fontSize;
|
||||
float ascent = currentFont.getFontDescriptor().getAscent() / 1000f * fontSize;
|
||||
float descent = currentFont.getFontDescriptor().getDescent() / 1000f * fontSize;
|
||||
|
||||
// Derive column/row in range 1..3 (1 = left/top, 2 = center/middle, 3 = right/bottom)
|
||||
int col = ((pos - 1) % 3) + 1; // 1 = left, 2 = center, 3 = right
|
||||
int row = ((pos - 1) / 3) + 1; // 1 = top, 2 = middle, 3 = bottom
|
||||
|
||||
// Anchor coordinates with margin
|
||||
float leftX = pageSize.getLowerLeftX() + marginFactor * pageSize.getWidth();
|
||||
float midX = pageSize.getLowerLeftX() + pageSize.getWidth() / 2f;
|
||||
float rightX = pageSize.getUpperRightX() - marginFactor * pageSize.getWidth();
|
||||
|
||||
float botY = pageSize.getLowerLeftY() + marginFactor * pageSize.getHeight();
|
||||
float midY = pageSize.getLowerLeftY() + pageSize.getHeight() / 2f;
|
||||
float topY = pageSize.getUpperRightY() - marginFactor * pageSize.getHeight();
|
||||
|
||||
// Horizontal alignment: left = anchor, center = centered, right = right-aligned
|
||||
float x =
|
||||
switch (col) {
|
||||
case 1 -> leftX;
|
||||
case 2 -> midX - textWidth / 2f;
|
||||
default -> rightX - textWidth;
|
||||
};
|
||||
|
||||
// Vertical alignment (baseline!):
|
||||
// top = align text top at topY,
|
||||
// middle = optical middle using ascent/descent,
|
||||
// bottom = baseline at botY
|
||||
float y =
|
||||
switch (row) {
|
||||
case 1 -> topY - ascent;
|
||||
case 2 -> midY - (ascent + descent) / 2f;
|
||||
default -> botY;
|
||||
};
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
|
||||
contentStream.beginText();
|
||||
contentStream.setFont(currentFont, fontSize);
|
||||
contentStream.setNonStrokingColor(color);
|
||||
contentStream.newLineAtOffset(x, y);
|
||||
contentStream.showText(text);
|
||||
contentStream.endText();
|
||||
if (pagesToNumber == null || pagesToNumber.isEmpty()) {
|
||||
pagesToNumber = "all";
|
||||
}
|
||||
if (customText == null || customText.isEmpty()) {
|
||||
customText = "{n}";
|
||||
}
|
||||
|
||||
pageNumber++;
|
||||
List<Integer> pagesToNumberList =
|
||||
GeneralUtils.parsePageList(
|
||||
pagesToNumber.split(","), document.getNumberOfPages());
|
||||
|
||||
// Clamp position to 1..9 (1 = top-left, 9 = bottom-right)
|
||||
int pos = Math.max(1, Math.min(9, position));
|
||||
|
||||
for (int i : pagesToNumberList) {
|
||||
PDPage page = document.getPage(i);
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
|
||||
String text =
|
||||
customText
|
||||
.replace("{n}", String.valueOf(pageNumber))
|
||||
.replace("{total}", String.valueOf(document.getNumberOfPages()))
|
||||
.replace(
|
||||
"{filename}",
|
||||
GeneralUtils.removeExtension(
|
||||
Filenames.toSimpleFileName(
|
||||
file.getOriginalFilename())));
|
||||
|
||||
PDType1Font currentFont =
|
||||
switch (fontType == null ? "" : fontType.toLowerCase(Locale.ROOT)) {
|
||||
case "courier" -> new PDType1Font(Standard14Fonts.FontName.COURIER);
|
||||
case "times" -> new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN);
|
||||
default -> new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
};
|
||||
|
||||
// Text dimensions and font metrics
|
||||
float textWidth = currentFont.getStringWidth(text) / 1000f * fontSize;
|
||||
float ascent = currentFont.getFontDescriptor().getAscent() / 1000f * fontSize;
|
||||
float descent = currentFont.getFontDescriptor().getDescent() / 1000f * fontSize;
|
||||
|
||||
// Derive column/row in range 1..3 (1 = left/top, 2 = center/middle, 3 =
|
||||
// right/bottom)
|
||||
int col = ((pos - 1) % 3) + 1; // 1 = left, 2 = center, 3 = right
|
||||
int row = ((pos - 1) / 3) + 1; // 1 = top, 2 = middle, 3 = bottom
|
||||
|
||||
// Anchor coordinates with margin
|
||||
float leftX = pageSize.getLowerLeftX() + marginFactor * pageSize.getWidth();
|
||||
float midX = pageSize.getLowerLeftX() + pageSize.getWidth() / 2f;
|
||||
float rightX = pageSize.getUpperRightX() - marginFactor * pageSize.getWidth();
|
||||
|
||||
float botY = pageSize.getLowerLeftY() + marginFactor * pageSize.getHeight();
|
||||
float midY = pageSize.getLowerLeftY() + pageSize.getHeight() / 2f;
|
||||
float topY = pageSize.getUpperRightY() - marginFactor * pageSize.getHeight();
|
||||
|
||||
// Horizontal alignment: left = anchor, center = centered, right = right-aligned
|
||||
float x =
|
||||
switch (col) {
|
||||
case 1 -> leftX;
|
||||
case 2 -> midX - textWidth / 2f;
|
||||
default -> rightX - textWidth;
|
||||
};
|
||||
|
||||
// Vertical alignment (baseline!):
|
||||
// top = align text top at topY,
|
||||
// middle = optical middle using ascent/descent,
|
||||
// bottom = baseline at botY
|
||||
float y =
|
||||
switch (row) {
|
||||
case 1 -> topY - ascent;
|
||||
case 2 -> midY - (ascent + descent) / 2f;
|
||||
default -> botY;
|
||||
};
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true)) {
|
||||
contentStream.beginText();
|
||||
contentStream.setFont(currentFont, fontSize);
|
||||
contentStream.setNonStrokingColor(color);
|
||||
contentStream.newLineAtOffset(x, y);
|
||||
contentStream.showText(text);
|
||||
contentStream.endText();
|
||||
}
|
||||
|
||||
pageNumber++;
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
baos.toByteArray(),
|
||||
GeneralUtils.generateFilename(
|
||||
file.getOriginalFilename(), "_page_numbers_added.pdf"));
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
document.close();
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
baos.toByteArray(),
|
||||
GeneralUtils.generateFilename(
|
||||
file.getOriginalFilename(), "_page_numbers_added.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
+43
-30
@@ -7,7 +7,10 @@ import java.awt.print.Printable;
|
||||
import java.awt.print.PrinterException;
|
||||
import java.awt.print.PrinterJob;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
|
||||
@@ -77,38 +80,48 @@ public class PrintFileController {
|
||||
log.info("Selected Printer: {}", selectedService.getName());
|
||||
|
||||
if (MediaType.APPLICATION_PDF_VALUE.equals(contentType)) {
|
||||
PDDocument document = Loader.loadPDF(file.getBytes());
|
||||
PrinterJob job = PrinterJob.getPrinterJob();
|
||||
job.setPrintService(selectedService);
|
||||
job.setPageable(new PDFPageable(document));
|
||||
job.print();
|
||||
document.close();
|
||||
// Use Stream-to-File pattern: write to temp file first, then load from file
|
||||
Path tempFile = Files.createTempFile("print-", ".pdf");
|
||||
try {
|
||||
Files.copy(
|
||||
file.getInputStream(), tempFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
try (PDDocument document = Loader.loadPDF(tempFile.toFile())) {
|
||||
PrinterJob job = PrinterJob.getPrinterJob();
|
||||
job.setPrintService(selectedService);
|
||||
job.setPageable(new PDFPageable(document));
|
||||
job.print();
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(tempFile);
|
||||
}
|
||||
} else if (contentType.startsWith("image/")) {
|
||||
BufferedImage image = ImageIO.read(file.getInputStream());
|
||||
PrinterJob job = PrinterJob.getPrinterJob();
|
||||
job.setPrintService(selectedService);
|
||||
job.setPrintable(
|
||||
new Printable() {
|
||||
public int print(
|
||||
Graphics graphics, PageFormat pageFormat, int pageIndex)
|
||||
throws PrinterException {
|
||||
if (pageIndex != 0) {
|
||||
return NO_SUCH_PAGE;
|
||||
try (var inputStream = file.getInputStream()) {
|
||||
BufferedImage image = ImageIO.read(inputStream);
|
||||
PrinterJob job = PrinterJob.getPrinterJob();
|
||||
job.setPrintService(selectedService);
|
||||
job.setPrintable(
|
||||
new Printable() {
|
||||
public int print(
|
||||
Graphics graphics, PageFormat pageFormat, int pageIndex)
|
||||
throws PrinterException {
|
||||
if (pageIndex != 0) {
|
||||
return NO_SUCH_PAGE;
|
||||
}
|
||||
Graphics2D g2d = (Graphics2D) graphics;
|
||||
g2d.translate(
|
||||
pageFormat.getImageableX(), pageFormat.getImageableY());
|
||||
g2d.drawImage(
|
||||
image,
|
||||
0,
|
||||
0,
|
||||
(int) pageFormat.getImageableWidth(),
|
||||
(int) pageFormat.getImageableHeight(),
|
||||
null);
|
||||
return PAGE_EXISTS;
|
||||
}
|
||||
Graphics2D g2d = (Graphics2D) graphics;
|
||||
g2d.translate(
|
||||
pageFormat.getImageableX(), pageFormat.getImageableY());
|
||||
g2d.drawImage(
|
||||
image,
|
||||
0,
|
||||
0,
|
||||
(int) pageFormat.getImageableWidth(),
|
||||
(int) pageFormat.getImageableHeight(),
|
||||
null);
|
||||
return PAGE_EXISTS;
|
||||
}
|
||||
});
|
||||
job.print();
|
||||
});
|
||||
job.print();
|
||||
}
|
||||
}
|
||||
return new ResponseEntity<>(
|
||||
"File printed successfully to " + selectedService.getName(), HttpStatus.OK);
|
||||
|
||||
+7
-1
@@ -463,7 +463,13 @@ public class ScannerEffectController {
|
||||
PDPage newPage = new PDPage(new PDRectangle(page.origW, page.origH));
|
||||
document.addPage(newPage);
|
||||
|
||||
try (PDPageContentStream contentStream = new PDPageContentStream(document, newPage)) {
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
newPage,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true,
|
||||
true)) {
|
||||
PDImageXObject pdImage = LosslessFactory.createFromImage(document, page.image);
|
||||
contentStream.drawImage(
|
||||
pdImage, page.offsetX, page.offsetY, page.drawW, page.drawH);
|
||||
|
||||
+51
-46
@@ -134,60 +134,65 @@ public class StampController {
|
||||
};
|
||||
|
||||
// Load the input PDF
|
||||
PDDocument document = pdfDocumentFactory.load(pdfFile);
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
|
||||
|
||||
List<Integer> pageNumbers = request.getPageNumbersList(document, true);
|
||||
List<Integer> pageNumbers = request.getPageNumbersList(document, true);
|
||||
|
||||
for (int pageIndex : pageNumbers) {
|
||||
int zeroBasedIndex = pageIndex - 1;
|
||||
if (zeroBasedIndex >= 0 && zeroBasedIndex < document.getNumberOfPages()) {
|
||||
PDPage page = document.getPage(zeroBasedIndex);
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
float margin = marginFactor * (pageSize.getWidth() + pageSize.getHeight()) / 2;
|
||||
for (int pageIndex : pageNumbers) {
|
||||
int zeroBasedIndex = pageIndex - 1;
|
||||
if (zeroBasedIndex >= 0 && zeroBasedIndex < document.getNumberOfPages()) {
|
||||
PDPage page = document.getPage(zeroBasedIndex);
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
float margin = marginFactor * (pageSize.getWidth() + pageSize.getHeight()) / 2;
|
||||
|
||||
PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document, page, PDPageContentStream.AppendMode.APPEND, true, true);
|
||||
PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true);
|
||||
|
||||
PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState();
|
||||
graphicsState.setNonStrokingAlphaConstant(opacity);
|
||||
contentStream.setGraphicsStateParameters(graphicsState);
|
||||
PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState();
|
||||
graphicsState.setNonStrokingAlphaConstant(opacity);
|
||||
contentStream.setGraphicsStateParameters(graphicsState);
|
||||
|
||||
if ("text".equalsIgnoreCase(stampType)) {
|
||||
addTextStamp(
|
||||
contentStream,
|
||||
stampText,
|
||||
document,
|
||||
page,
|
||||
rotation,
|
||||
position,
|
||||
fontSize,
|
||||
alphabet,
|
||||
overrideX,
|
||||
overrideY,
|
||||
margin,
|
||||
customColor);
|
||||
} else if ("image".equalsIgnoreCase(stampType)) {
|
||||
addImageStamp(
|
||||
contentStream,
|
||||
stampImage,
|
||||
document,
|
||||
page,
|
||||
rotation,
|
||||
position,
|
||||
fontSize,
|
||||
overrideX,
|
||||
overrideY,
|
||||
margin);
|
||||
if ("text".equalsIgnoreCase(stampType)) {
|
||||
addTextStamp(
|
||||
contentStream,
|
||||
stampText,
|
||||
document,
|
||||
page,
|
||||
rotation,
|
||||
position,
|
||||
fontSize,
|
||||
alphabet,
|
||||
overrideX,
|
||||
overrideY,
|
||||
margin,
|
||||
customColor);
|
||||
} else if ("image".equalsIgnoreCase(stampType)) {
|
||||
addImageStamp(
|
||||
contentStream,
|
||||
stampImage,
|
||||
document,
|
||||
page,
|
||||
rotation,
|
||||
position,
|
||||
fontSize,
|
||||
overrideX,
|
||||
overrideY,
|
||||
margin);
|
||||
}
|
||||
|
||||
contentStream.close();
|
||||
}
|
||||
|
||||
contentStream.close();
|
||||
}
|
||||
// Return the stamped PDF as a response
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_stamped.pdf"));
|
||||
}
|
||||
// Return the stamped PDF as a response
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_stamped.pdf"));
|
||||
}
|
||||
|
||||
private void addTextStamp(
|
||||
|
||||
+8
-7
@@ -133,17 +133,18 @@ public class CertSignController {
|
||||
signature.setReason(reason);
|
||||
signature.setSignDate(Calendar.getInstance()); // PDFBox requires Calendar
|
||||
if (Boolean.TRUE.equals(showSignature)) {
|
||||
SignatureOptions signatureOptions = new SignatureOptions();
|
||||
signatureOptions.setVisualSignature(
|
||||
instance.createVisibleSignature(doc, signature, pageNumber, showLogo));
|
||||
signatureOptions.setPage(pageNumber);
|
||||
|
||||
doc.addSignature(signature, instance, signatureOptions);
|
||||
try (SignatureOptions signatureOptions = new SignatureOptions()) {
|
||||
signatureOptions.setVisualSignature(
|
||||
instance.createVisibleSignature(doc, signature, pageNumber, showLogo));
|
||||
signatureOptions.setPage(pageNumber);
|
||||
|
||||
doc.addSignature(signature, instance, signatureOptions);
|
||||
doc.saveIncremental(output);
|
||||
}
|
||||
} else {
|
||||
doc.addSignature(signature, instance);
|
||||
doc.saveIncremental(output);
|
||||
}
|
||||
doc.saveIncremental(output);
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("PDF signing", e);
|
||||
}
|
||||
|
||||
+30
-4
@@ -3,6 +3,8 @@ package stirling.software.SPDF.controller.api.security;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
@@ -14,7 +16,8 @@ import java.util.regex.Pattern;
|
||||
import org.apache.pdfbox.cos.COSInputStream;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.cos.COSString;
|
||||
import org.apache.pdfbox.io.RandomAccessReadBuffer;
|
||||
import org.apache.pdfbox.io.RandomAccessRead;
|
||||
import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
|
||||
import org.apache.pdfbox.pdmodel.*;
|
||||
import org.apache.pdfbox.pdmodel.common.PDMetadata;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
@@ -198,10 +201,20 @@ public class GetInfoOnPDF {
|
||||
return false;
|
||||
}
|
||||
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
document.save(baos);
|
||||
// Use Stream-to-File pattern: save to temp file instead of loading into memory
|
||||
// This prevents OutOfMemoryError on large PDFs
|
||||
Path tempFile = null;
|
||||
try {
|
||||
tempFile = Files.createTempFile("preflight-", ".pdf");
|
||||
|
||||
try (RandomAccessReadBuffer source = new RandomAccessReadBuffer(baos.toByteArray())) {
|
||||
// Save document to temp file (avoids loading entire document into memory)
|
||||
try (var outputStream = Files.newOutputStream(tempFile)) {
|
||||
document.save(outputStream);
|
||||
}
|
||||
|
||||
// Use RandomAccessReadBufferedFile for efficient file-based reading
|
||||
// This avoids Windows file locking issues that occur with memory-mapped files
|
||||
try (RandomAccessRead source = new RandomAccessReadBufferedFile(tempFile.toFile())) {
|
||||
PreflightParser parser = new PreflightParser(source);
|
||||
|
||||
try (PDDocument parsedDocument = parser.parse()) {
|
||||
@@ -243,6 +256,19 @@ public class GetInfoOnPDF {
|
||||
log.debug("IOException during PDF/A validation: {}", e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.debug("Unexpected error during PDF/A validation: {}", e.getMessage());
|
||||
} finally {
|
||||
// Explicitly clean up temp file to prevent disk exhaustion
|
||||
// This must be in finally block to ensure cleanup even on exceptions
|
||||
if (tempFile != null) {
|
||||
try {
|
||||
Files.deleteIfExists(tempFile);
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Failed to delete temp file during PDF/A validation cleanup: {}",
|
||||
tempFile,
|
||||
e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
+31
-34
@@ -42,25 +42,17 @@ public class PasswordController {
|
||||
MultipartFile fileInput = request.getFileInput();
|
||||
String password = request.getPassword();
|
||||
|
||||
PDDocument document;
|
||||
try {
|
||||
document = pdfDocumentFactory.load(fileInput, password);
|
||||
} catch (IOException e) {
|
||||
// Handle password errors specifically
|
||||
if (ExceptionUtils.isPasswordError(e)) {
|
||||
throw ExceptionUtils.createPdfPasswordException(e);
|
||||
}
|
||||
throw ExceptionUtils.handlePdfException(e);
|
||||
}
|
||||
|
||||
try {
|
||||
try (PDDocument document = pdfDocumentFactory.load(fileInput, password)) {
|
||||
document.setAllSecurityToBeRemoved(true);
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
fileInput.getOriginalFilename(), "_password_removed.pdf"));
|
||||
} catch (IOException e) {
|
||||
document.close();
|
||||
// Handle password errors specifically
|
||||
if (ExceptionUtils.isPasswordError(e)) {
|
||||
throw ExceptionUtils.createPdfPasswordException(e);
|
||||
}
|
||||
ExceptionUtils.logException("password removal", e);
|
||||
throw ExceptionUtils.handlePdfException(e);
|
||||
}
|
||||
@@ -91,31 +83,36 @@ public class PasswordController {
|
||||
boolean preventPrinting = Boolean.TRUE.equals(request.getPreventPrinting());
|
||||
boolean preventPrintingFaithful = Boolean.TRUE.equals(request.getPreventPrintingFaithful());
|
||||
|
||||
PDDocument document = pdfDocumentFactory.load(fileInput);
|
||||
AccessPermission ap = new AccessPermission();
|
||||
ap.setCanAssembleDocument(!preventAssembly);
|
||||
ap.setCanExtractContent(!preventExtractContent);
|
||||
ap.setCanExtractForAccessibility(!preventExtractForAccessibility);
|
||||
ap.setCanFillInForm(!preventFillInForm);
|
||||
ap.setCanModify(!preventModify);
|
||||
ap.setCanModifyAnnotations(!preventModifyAnnotations);
|
||||
ap.setCanPrint(!preventPrinting);
|
||||
ap.setCanPrintFaithful(!preventPrintingFaithful);
|
||||
StandardProtectionPolicy spp = new StandardProtectionPolicy(ownerPassword, password, ap);
|
||||
try (PDDocument document = pdfDocumentFactory.load(fileInput)) {
|
||||
AccessPermission ap = new AccessPermission();
|
||||
ap.setCanAssembleDocument(!preventAssembly);
|
||||
ap.setCanExtractContent(!preventExtractContent);
|
||||
ap.setCanExtractForAccessibility(!preventExtractForAccessibility);
|
||||
ap.setCanFillInForm(!preventFillInForm);
|
||||
ap.setCanModify(!preventModify);
|
||||
ap.setCanModifyAnnotations(!preventModifyAnnotations);
|
||||
ap.setCanPrint(!preventPrinting);
|
||||
ap.setCanPrintFaithful(!preventPrintingFaithful);
|
||||
StandardProtectionPolicy spp =
|
||||
new StandardProtectionPolicy(ownerPassword, password, ap);
|
||||
|
||||
if (!"".equals(ownerPassword) || !"".equals(password)) {
|
||||
spp.setEncryptionKeyLength(keyLength);
|
||||
}
|
||||
spp.setPermissions(ap);
|
||||
document.protect(spp);
|
||||
if ((ownerPassword != null && ownerPassword.length() > 0)
|
||||
|| (password != null && password.length() > 0)) {
|
||||
spp.setEncryptionKeyLength(keyLength);
|
||||
}
|
||||
spp.setPermissions(ap);
|
||||
document.protect(spp);
|
||||
|
||||
if ("".equals(ownerPassword) && "".equals(password))
|
||||
if ((ownerPassword == null || ownerPassword.length() == 0)
|
||||
&& (password == null || password.length() == 0))
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
fileInput.getOriginalFilename(), "_permissions.pdf"));
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
fileInput.getOriginalFilename(), "_permissions.pdf"));
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(fileInput.getOriginalFilename(), "_passworded.pdf"));
|
||||
fileInput.getOriginalFilename(), "_passworded.pdf"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-18
@@ -41,28 +41,29 @@ public class RemoveCertSignController {
|
||||
throws Exception {
|
||||
MultipartFile pdf = request.getFileInput();
|
||||
|
||||
// Load the PDF document
|
||||
PDDocument document = pdfDocumentFactory.load(pdf);
|
||||
// Load the PDF document with proper resource management
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdf)) {
|
||||
|
||||
// Get the document catalog
|
||||
PDDocumentCatalog catalog = document.getDocumentCatalog();
|
||||
// Get the document catalog
|
||||
PDDocumentCatalog catalog = document.getDocumentCatalog();
|
||||
|
||||
// Get the AcroForm
|
||||
PDAcroForm acroForm = catalog.getAcroForm();
|
||||
if (acroForm != null) {
|
||||
// Remove signature fields safely
|
||||
List<PDField> fieldsToRemove =
|
||||
acroForm.getFields().stream()
|
||||
.filter(field -> field instanceof PDSignatureField)
|
||||
.toList();
|
||||
// Get the AcroForm
|
||||
PDAcroForm acroForm = catalog.getAcroForm();
|
||||
if (acroForm != null) {
|
||||
// Remove signature fields safely
|
||||
List<PDField> fieldsToRemove =
|
||||
acroForm.getFields().stream()
|
||||
.filter(field -> field instanceof PDSignatureField)
|
||||
.toList();
|
||||
|
||||
if (!fieldsToRemove.isEmpty()) {
|
||||
acroForm.flatten(fieldsToRemove, false);
|
||||
if (!fieldsToRemove.isEmpty()) {
|
||||
acroForm.flatten(fieldsToRemove, false);
|
||||
}
|
||||
}
|
||||
// Return the modified PDF as a response
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(pdf.getOriginalFilename(), "_unsigned.pdf"));
|
||||
}
|
||||
// Return the modified PDF as a response
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(pdf.getOriginalFilename(), "_unsigned.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
+33
-32
@@ -67,39 +67,40 @@ public class SanitizeController {
|
||||
boolean removeLinks = Boolean.TRUE.equals(request.getRemoveLinks());
|
||||
boolean removeFonts = Boolean.TRUE.equals(request.getRemoveFonts());
|
||||
|
||||
PDDocument document = pdfDocumentFactory.load(inputFile, true);
|
||||
if (removeJavaScript) {
|
||||
sanitizeJavaScript(document);
|
||||
try (PDDocument document = pdfDocumentFactory.load(inputFile, true)) {
|
||||
if (removeJavaScript) {
|
||||
sanitizeJavaScript(document);
|
||||
}
|
||||
|
||||
if (removeEmbeddedFiles) {
|
||||
sanitizeEmbeddedFiles(document);
|
||||
}
|
||||
|
||||
if (removeXMPMetadata) {
|
||||
sanitizeXMPMetadata(document);
|
||||
}
|
||||
|
||||
if (removeMetadata) {
|
||||
sanitizeDocumentInfoMetadata(document);
|
||||
}
|
||||
|
||||
if (removeLinks) {
|
||||
sanitizeLinks(document);
|
||||
}
|
||||
|
||||
if (removeFonts) {
|
||||
sanitizeFonts(document);
|
||||
}
|
||||
|
||||
// Save the sanitized document to output stream
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
document.save(outputStream);
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
outputStream.toByteArray(),
|
||||
GeneralUtils.generateFilename(
|
||||
inputFile.getOriginalFilename(), "_sanitized.pdf"));
|
||||
}
|
||||
|
||||
if (removeEmbeddedFiles) {
|
||||
sanitizeEmbeddedFiles(document);
|
||||
}
|
||||
|
||||
if (removeXMPMetadata) {
|
||||
sanitizeXMPMetadata(document);
|
||||
}
|
||||
|
||||
if (removeMetadata) {
|
||||
sanitizeDocumentInfoMetadata(document);
|
||||
}
|
||||
|
||||
if (removeLinks) {
|
||||
sanitizeLinks(document);
|
||||
}
|
||||
|
||||
if (removeFonts) {
|
||||
sanitizeFonts(document);
|
||||
}
|
||||
|
||||
// Save the sanitized document to output stream
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
document.save(outputStream);
|
||||
document.close();
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
outputStream.toByteArray(),
|
||||
GeneralUtils.generateFilename(inputFile.getOriginalFilename(), "_sanitized.pdf"));
|
||||
}
|
||||
|
||||
private static void sanitizeJavaScript(PDDocument document) throws IOException {
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package stirling.software.SPDF.controller.api.security;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
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 org.verapdf.core.EncryptedPdfException;
|
||||
import org.verapdf.core.ModelParsingException;
|
||||
import org.verapdf.core.ValidationException;
|
||||
|
||||
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.security.PDFVerificationRequest;
|
||||
import stirling.software.SPDF.model.api.security.PDFVerificationResult;
|
||||
import stirling.software.SPDF.service.VeraPDFService;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/security")
|
||||
@Tag(name = "Security", description = "Security APIs")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class VerifyPDFController {
|
||||
|
||||
private final VeraPDFService veraPDFService;
|
||||
|
||||
@Operation(
|
||||
summary = "Verify PDF Standards Compliance",
|
||||
description =
|
||||
"Validates PDF files against the standards declared in their metadata. "
|
||||
+ "Automatically detects PDF/A, PDF/UA-1, PDF/UA-2, and WTPDF standards "
|
||||
+ "from the document's XMP metadata and validates compliance. "
|
||||
+ "Input:PDF Output:JSON Type:SISO")
|
||||
@PostMapping(value = "/verify-pdf", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<List<PDFVerificationResult>> verifyPDF(
|
||||
@ModelAttribute PDFVerificationRequest request) {
|
||||
|
||||
MultipartFile file = request.getFileInput();
|
||||
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw ExceptionUtils.createRuntimeException(
|
||||
"error.pdfRequired", "PDF file is required", null);
|
||||
}
|
||||
|
||||
try {
|
||||
log.info("Detecting and verifying standards in PDF '{}'", file.getOriginalFilename());
|
||||
|
||||
List<PDFVerificationResult> results = veraPDFService.validatePDF(file.getInputStream());
|
||||
|
||||
log.info(
|
||||
"Verification complete for '{}': {} standard(s) checked",
|
||||
file.getOriginalFilename(),
|
||||
results.size());
|
||||
|
||||
return ResponseEntity.ok(results);
|
||||
|
||||
} catch (ValidationException e) {
|
||||
log.error("Validation exception for file: {}", file.getOriginalFilename(), e);
|
||||
throw ExceptionUtils.createRuntimeException(
|
||||
"error.validationFailed", "PDF validation failed: {0}", e, e.getMessage());
|
||||
} catch (ModelParsingException e) {
|
||||
log.error("Model parsing exception for file: {}", file.getOriginalFilename(), e);
|
||||
throw ExceptionUtils.createRuntimeException(
|
||||
"error.modelParsingFailed", "PDF model parsing failed: {0}", e, e.getMessage());
|
||||
} catch (EncryptedPdfException e) {
|
||||
log.error("Encrypted PDF exception for file: {}", file.getOriginalFilename(), e);
|
||||
throw ExceptionUtils.createRuntimeException(
|
||||
"error.encryptedPdf",
|
||||
"Cannot verify encrypted PDF. Please remove password first: {0}",
|
||||
e,
|
||||
e.getMessage());
|
||||
} catch (IOException e) {
|
||||
log.error("IO exception for file: {}", file.getOriginalFilename(), e);
|
||||
throw ExceptionUtils.createRuntimeException(
|
||||
"error.ioException",
|
||||
"IO error during PDF verification: {0}",
|
||||
e,
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
-48
@@ -98,60 +98,67 @@ public class WatermarkController {
|
||||
String customColor = request.getCustomColor();
|
||||
boolean convertPdfToImage = Boolean.TRUE.equals(request.getConvertPDFToImage());
|
||||
|
||||
// Load the input PDF
|
||||
PDDocument document = pdfDocumentFactory.load(pdfFile);
|
||||
// Load the input PDF with proper resource management
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
|
||||
|
||||
// Create a page in the document
|
||||
for (PDPage page : document.getPages()) {
|
||||
// Create a page in the document
|
||||
for (PDPage page : document.getPages()) {
|
||||
// Get the page's content stream
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true)) {
|
||||
|
||||
// Get the page's content stream
|
||||
PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document, page, PDPageContentStream.AppendMode.APPEND, true, true);
|
||||
// Set transparency
|
||||
PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState();
|
||||
graphicsState.setNonStrokingAlphaConstant(opacity);
|
||||
contentStream.setGraphicsStateParameters(graphicsState);
|
||||
|
||||
// Set transparency
|
||||
PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState();
|
||||
graphicsState.setNonStrokingAlphaConstant(opacity);
|
||||
contentStream.setGraphicsStateParameters(graphicsState);
|
||||
|
||||
if ("text".equalsIgnoreCase(watermarkType)) {
|
||||
addTextWatermark(
|
||||
contentStream,
|
||||
watermarkText,
|
||||
document,
|
||||
page,
|
||||
rotation,
|
||||
widthSpacer,
|
||||
heightSpacer,
|
||||
fontSize,
|
||||
alphabet,
|
||||
customColor);
|
||||
} else if ("image".equalsIgnoreCase(watermarkType)) {
|
||||
addImageWatermark(
|
||||
contentStream,
|
||||
watermarkImage,
|
||||
document,
|
||||
page,
|
||||
rotation,
|
||||
widthSpacer,
|
||||
heightSpacer,
|
||||
fontSize);
|
||||
if ("text".equalsIgnoreCase(watermarkType)) {
|
||||
addTextWatermark(
|
||||
contentStream,
|
||||
watermarkText,
|
||||
document,
|
||||
page,
|
||||
rotation,
|
||||
widthSpacer,
|
||||
heightSpacer,
|
||||
fontSize,
|
||||
alphabet,
|
||||
customColor);
|
||||
} else if ("image".equalsIgnoreCase(watermarkType)) {
|
||||
addImageWatermark(
|
||||
contentStream,
|
||||
watermarkImage,
|
||||
document,
|
||||
page,
|
||||
rotation,
|
||||
widthSpacer,
|
||||
heightSpacer,
|
||||
fontSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close the content stream
|
||||
contentStream.close();
|
||||
if (convertPdfToImage) {
|
||||
try (PDDocument convertedPdf = PdfUtils.convertPdfToPdfImage(document)) {
|
||||
// Return the watermarked PDF as a response
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
convertedPdf,
|
||||
GeneralUtils.generateFilename(
|
||||
pdfFile.getOriginalFilename(), "_watermarked.pdf"));
|
||||
}
|
||||
} else {
|
||||
// Return the watermarked PDF as a response
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
pdfFile.getOriginalFilename(), "_watermarked.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
if (convertPdfToImage) {
|
||||
PDDocument convertedPdf = PdfUtils.convertPdfToPdfImage(document);
|
||||
document.close();
|
||||
document = convertedPdf;
|
||||
}
|
||||
|
||||
// Return the watermarked PDF as a response
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_watermarked.pdf"));
|
||||
}
|
||||
|
||||
private void addTextWatermark(
|
||||
|
||||
@@ -45,8 +45,7 @@ public class HomeWebController {
|
||||
public String licensesForm(Model model) {
|
||||
model.addAttribute("currentPage", "licenses");
|
||||
Resource resource = new ClassPathResource("static/3rdPartyLicenses.json");
|
||||
try {
|
||||
InputStream is = resource.getInputStream();
|
||||
try (InputStream is = resource.getInputStream()) {
|
||||
String json = new String(is.readAllBytes(), StandardCharsets.UTF_8);
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
Map<String, List<Dependency>> data = mapper.readValue(json, new TypeReference<>() {});
|
||||
|
||||
+394
-51
@@ -15,84 +15,106 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.util.HtmlUtils;
|
||||
import org.springframework.web.util.JavaScriptUtils;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
|
||||
@Slf4j
|
||||
@Controller
|
||||
public class ReactRoutingController {
|
||||
|
||||
private static final org.slf4j.Logger log =
|
||||
org.slf4j.LoggerFactory.getLogger(ReactRoutingController.class);
|
||||
|
||||
@Value("${server.servlet.context-path:/}")
|
||||
private String contextPath;
|
||||
|
||||
private String cachedIndexHtml;
|
||||
private String cachedCallbackHtml;
|
||||
private boolean indexHtmlExists = false;
|
||||
private boolean useExternalIndexHtml = false;
|
||||
private boolean loggedMissingIndex = false;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
log.info("Static files custom path: {}", InstallationPathConfig.getStaticPath());
|
||||
|
||||
// Always initialize callback HTML (used for OAuth desktop flow)
|
||||
this.cachedCallbackHtml = buildCallbackHtml();
|
||||
|
||||
// Check for external index.html first (customFiles/static/)
|
||||
Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html");
|
||||
log.debug("Checking for custom index.html at: {}", externalIndexPath);
|
||||
if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) {
|
||||
log.info("Using custom index.html from: {}", externalIndexPath);
|
||||
try {
|
||||
this.cachedIndexHtml = processIndexHtml();
|
||||
this.indexHtmlExists = true;
|
||||
this.useExternalIndexHtml = true;
|
||||
return;
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to load custom index.html, falling back to classpath", e);
|
||||
}
|
||||
this.cachedIndexHtml = processIndexHtml();
|
||||
this.indexHtmlExists = true;
|
||||
this.useExternalIndexHtml = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to classpath index.html
|
||||
ClassPathResource resource = new ClassPathResource("static/index.html");
|
||||
if (resource.exists()) {
|
||||
try {
|
||||
this.cachedIndexHtml = processIndexHtml();
|
||||
this.indexHtmlExists = true;
|
||||
this.useExternalIndexHtml = false;
|
||||
} catch (IOException e) {
|
||||
// Failed to cache, will process on each request
|
||||
log.warn("Failed to cache index.html", e);
|
||||
this.indexHtmlExists = false;
|
||||
this.cachedIndexHtml = processIndexHtml();
|
||||
this.indexHtmlExists = true;
|
||||
this.useExternalIndexHtml = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Neither external nor classpath index.html exists - cache fallback once
|
||||
this.cachedIndexHtml = buildFallbackHtml();
|
||||
this.indexHtmlExists = true;
|
||||
this.useExternalIndexHtml = false;
|
||||
this.loggedMissingIndex = true;
|
||||
log.warn(
|
||||
"index.html not found in classpath or custom path; using lightweight fallback page");
|
||||
}
|
||||
|
||||
private String processIndexHtml() {
|
||||
try {
|
||||
Resource resource = getIndexHtmlResource();
|
||||
|
||||
if (!resource.exists()) {
|
||||
if (!loggedMissingIndex) {
|
||||
log.warn("index.html not found, using lightweight fallback page");
|
||||
loggedMissingIndex = true;
|
||||
}
|
||||
return buildFallbackHtml();
|
||||
}
|
||||
|
||||
try (InputStream inputStream = resource.getInputStream()) {
|
||||
String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
|
||||
// Replace %BASE_URL% with the actual context path for base href
|
||||
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
|
||||
html = html.replace("%BASE_URL%", baseUrl);
|
||||
// Also rewrite any existing <base> tag (Vite may have baked one in)
|
||||
html =
|
||||
html.replaceFirst(
|
||||
"<base href=\\\"[^\\\"]*\\\"\\s*/?>",
|
||||
"<base href=\\\"" + baseUrl + "\\\" />");
|
||||
|
||||
// Inject context path as a global variable for API calls
|
||||
String contextPathScript =
|
||||
"<script>window.STIRLING_PDF_API_BASE_URL = '" + baseUrl + "';</script>";
|
||||
html = html.replace("</head>", contextPathScript + "</head>");
|
||||
|
||||
return html;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
if (!loggedMissingIndex) {
|
||||
log.warn("index.html not found, using lightweight fallback page", ex);
|
||||
loggedMissingIndex = true;
|
||||
}
|
||||
return buildFallbackHtml();
|
||||
}
|
||||
}
|
||||
|
||||
private String processIndexHtml() throws IOException {
|
||||
Resource resource = getIndexHtmlResource();
|
||||
|
||||
try (InputStream inputStream = resource.getInputStream()) {
|
||||
String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
|
||||
// Replace %BASE_URL% with the actual context path for base href
|
||||
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
|
||||
html = html.replace("%BASE_URL%", baseUrl);
|
||||
// Also rewrite any existing <base> tag (Vite may have baked one in)
|
||||
html =
|
||||
html.replaceFirst(
|
||||
"<base href=\\\"[^\\\"]*\\\"\\s*/?>",
|
||||
"<base href=\\\"" + baseUrl + "\\\" />");
|
||||
|
||||
// Inject context path as a global variable for API calls
|
||||
String contextPathScript =
|
||||
"<script>window.STIRLING_PDF_API_BASE_URL = '" + baseUrl + "';</script>";
|
||||
html = html.replace("</head>", contextPathScript + "</head>");
|
||||
|
||||
return html;
|
||||
}
|
||||
}
|
||||
|
||||
private Resource getIndexHtmlResource() throws IOException {
|
||||
private Resource getIndexHtmlResource() {
|
||||
// Check external location first
|
||||
Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html");
|
||||
if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) {
|
||||
@@ -106,24 +128,345 @@ public class ReactRoutingController {
|
||||
@GetMapping(
|
||||
value = {"/", "/index.html"},
|
||||
produces = MediaType.TEXT_HTML_VALUE)
|
||||
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request) throws IOException {
|
||||
if (indexHtmlExists && cachedIndexHtml != null) {
|
||||
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml);
|
||||
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request) {
|
||||
try {
|
||||
if (indexHtmlExists && cachedIndexHtml != null) {
|
||||
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml);
|
||||
}
|
||||
// Fallback: process on each request (dev mode or cache failed)
|
||||
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(processIndexHtml());
|
||||
} catch (Exception ex) {
|
||||
log.error("Failed to serve index.html, returning fallback", ex);
|
||||
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(buildFallbackHtml());
|
||||
}
|
||||
// Fallback: process on each request (dev mode or cache failed)
|
||||
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(processIndexHtml());
|
||||
}
|
||||
|
||||
@GetMapping(value = "/auth/callback", produces = MediaType.TEXT_HTML_VALUE)
|
||||
public ResponseEntity<String> serveAuthCallback(HttpServletRequest request) {
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/auth/callback/tauri", produces = MediaType.TEXT_HTML_VALUE)
|
||||
public ResponseEntity<String> serveTauriAuthCallback(HttpServletRequest request) {
|
||||
// cachedCallbackHtml is always initialized in @PostConstruct
|
||||
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedCallbackHtml);
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
|
||||
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
|
||||
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request) throws IOException {
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
|
||||
throws IOException {
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
|
||||
private String buildFallbackHtml() {
|
||||
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
|
||||
|
||||
// Escape for HTML attribute context
|
||||
String escapedBaseUrlHtml = HtmlUtils.htmlEscape(baseUrl);
|
||||
|
||||
// Escape for JavaScript string context
|
||||
String escapedBaseUrlJs = JavaScriptUtils.javaScriptEscape(baseUrl);
|
||||
|
||||
String serverUrl = "(window.location.origin + '" + escapedBaseUrlJs + "')";
|
||||
return """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<base href="%s" />
|
||||
<title>Stirling PDF</title>
|
||||
<script>
|
||||
// Minimal handler for SSO callback when index.html is missing (desktop fallback)
|
||||
(function() {
|
||||
const baseUrl = '%s';
|
||||
window.STIRLING_PDF_API_BASE_URL = baseUrl;
|
||||
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const token = hashParams.get('access_token') || hashParams.get('token') || searchParams.get('access_token');
|
||||
const serverUrl = %s;
|
||||
|
||||
if (token) {
|
||||
// Extract nonce from URL to send back to desktop app for validation
|
||||
const nonceFromUrl = hashParams.get('nonce') || searchParams.get('nonce');
|
||||
|
||||
console.log('[Fallback Auth] Token received, sending to desktop app via deep link');
|
||||
|
||||
// Send token + nonce via deep link to desktop app
|
||||
// Desktop app will validate nonce before accepting token
|
||||
try {
|
||||
const encodedToken = encodeURIComponent(token);
|
||||
const encodedServer = encodeURIComponent(serverUrl);
|
||||
const encodedNonce = nonceFromUrl ? encodeURIComponent(nonceFromUrl) : '';
|
||||
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#access_token=${encodedToken}&nonce=${encodedNonce}&type=sso-selfhosted`;
|
||||
window.location.href = deepLink;
|
||||
return;
|
||||
} catch (_) {
|
||||
// ignore deep link errors
|
||||
}
|
||||
}
|
||||
|
||||
// No redirect to avoid loops when index.html is missing
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<p>Stirling PDF is running.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
.formatted(escapedBaseUrlHtml, escapedBaseUrlJs, serverUrl);
|
||||
}
|
||||
|
||||
private String buildCallbackHtml() {
|
||||
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
|
||||
|
||||
// Escape for HTML attribute context
|
||||
String escapedBaseUrlHtml = HtmlUtils.htmlEscape(baseUrl);
|
||||
|
||||
// Escape for JavaScript string context
|
||||
String escapedBaseUrlJs = JavaScriptUtils.javaScriptEscape(baseUrl);
|
||||
|
||||
String serverUrl = "(window.location.origin + '" + escapedBaseUrlJs + "')";
|
||||
return """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<base href="%s" />
|
||||
<title>Authentication Complete</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
text-align: center;
|
||||
padding: 50px 20px;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
max-width: 420px;
|
||||
width: 100%%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e5e7eb;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.icon.error {
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.error-details {
|
||||
background: #ffebee;
|
||||
border: 1px solid #ffcdd2;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
color: #c62828;
|
||||
word-break: break-word;
|
||||
text-align: left;
|
||||
line-height: 1.5;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: #2d2d2d;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border-color: #374151;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: #66bb6a;
|
||||
}
|
||||
|
||||
.icon.error {
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
.error-details {
|
||||
background: #3d2020;
|
||||
border: 1px solid #5d3030;
|
||||
color: #ef9a9a;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
body {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 40px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function() {
|
||||
const run = () => {
|
||||
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const token = hashParams.get('access_token') || hashParams.get('token') || searchParams.get('access_token');
|
||||
const errorCode = searchParams.get('errorOAuth')
|
||||
|| searchParams.get('error')
|
||||
|| hashParams.get('error')
|
||||
|| searchParams.get('error_description')
|
||||
|| hashParams.get('error_description');
|
||||
const serverUrl = %s;
|
||||
const iconEl = document.getElementById('auth-icon');
|
||||
const titleEl = document.getElementById('auth-title');
|
||||
const messageEl = document.getElementById('auth-message');
|
||||
const detailsEl = document.getElementById('auth-error-details');
|
||||
|
||||
const sendDeepLink = (type, value, key) => {
|
||||
try {
|
||||
const encodedValue = encodeURIComponent(value || '');
|
||||
const encodedServer = encodeURIComponent(serverUrl);
|
||||
const hashKey = key || 'access_token';
|
||||
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#${hashKey}=${encodedValue}&type=${type}`;
|
||||
window.location.href = deepLink;
|
||||
} catch (_) {
|
||||
// ignore deep link errors
|
||||
}
|
||||
};
|
||||
|
||||
const showError = (message, details) => {
|
||||
if (iconEl) {
|
||||
iconEl.textContent = '✗';
|
||||
iconEl.classList.add('error');
|
||||
}
|
||||
if (titleEl) {
|
||||
titleEl.textContent = 'Authentication failed';
|
||||
}
|
||||
if (messageEl) {
|
||||
messageEl.textContent = message;
|
||||
}
|
||||
if (detailsEl && details) {
|
||||
detailsEl.textContent = details;
|
||||
detailsEl.style.display = 'block';
|
||||
}
|
||||
};
|
||||
|
||||
if (token) {
|
||||
// Extract nonce from URL to send back to desktop app for validation
|
||||
// (System browser doesn't have access to desktop app's sessionStorage)
|
||||
const nonceFromUrl = hashParams.get('nonce') || searchParams.get('nonce');
|
||||
|
||||
console.log('[Auth Callback] Token received, sending to desktop app via deep link');
|
||||
|
||||
// Send token + nonce via deep link to desktop app
|
||||
// Desktop app will validate nonce before accepting token
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const encodedToken = encodeURIComponent(token);
|
||||
const encodedServer = encodeURIComponent(serverUrl);
|
||||
const encodedNonce = nonceFromUrl ? encodeURIComponent(nonceFromUrl) : '';
|
||||
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#access_token=${encodedToken}&nonce=${encodedNonce}&type=sso-selfhosted`;
|
||||
window.location.href = deepLink;
|
||||
} catch (err) {
|
||||
console.error('[Auth Callback] Failed to trigger deep link:', err);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorCode) {
|
||||
const isCancelled = errorCode === 'access_denied';
|
||||
sendDeepLink('sso-error', errorCode, 'error');
|
||||
showError(
|
||||
isCancelled
|
||||
? 'Authentication was cancelled. You can close this window and return to the app.'
|
||||
: 'Authentication was not successful. You can close this window and return to the app.',
|
||||
errorCode
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showError(
|
||||
'Authentication did not complete. You can close this window and try again.',
|
||||
'missing_token'
|
||||
);
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', run);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon" id="auth-icon">✓</div>
|
||||
<h1 id="auth-title">Authentication complete</h1>
|
||||
<p id="auth-message">You can close this window and return to Stirling PDF.</p>
|
||||
<div class="error-details" id="auth-error-details"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
.formatted(escapedBaseUrlHtml, serverUrl);
|
||||
}
|
||||
}
|
||||
|
||||
+97
-6
@@ -12,6 +12,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.web.HttpMediaTypeNotAcceptableException;
|
||||
import org.springframework.web.HttpMediaTypeNotSupportedException;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
@@ -23,6 +24,7 @@ import org.springframework.web.multipart.support.MissingServletRequestPartExcept
|
||||
import org.springframework.web.servlet.NoHandlerFoundException;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -78,7 +80,7 @@ import stirling.software.common.util.RegexPatternUtils;
|
||||
* <pre>{@code
|
||||
* // In controllers/services - use ExceptionUtils to create typed exceptions:
|
||||
* try {
|
||||
* PDDocument doc = PDDocument.load(file);
|
||||
* PDDocument doc = Loader.loadPDF(file);
|
||||
* } catch (IOException e) {
|
||||
* throw ExceptionUtils.createPdfCorruptedException("during load", e);
|
||||
* }
|
||||
@@ -808,6 +810,56 @@ public class GlobalExceptionHandler {
|
||||
.body(problemDetail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle 406 Not Acceptable errors when error responses cannot match client Accept header.
|
||||
*
|
||||
* <p>When thrown: When the client sends Accept: application/pdf but the server needs to return
|
||||
* a JSON error response (e.g., when an attachment is not found).
|
||||
*
|
||||
* <p>This handler writes directly to HttpServletResponse to bypass Spring's content negotiation
|
||||
* and ensure error responses are always delivered as JSON.
|
||||
*
|
||||
* @param ex the HttpMediaTypeNotAcceptableException
|
||||
* @param request the HTTP servlet request
|
||||
* @param response the HTTP servlet response
|
||||
*/
|
||||
@ExceptionHandler(HttpMediaTypeNotAcceptableException.class)
|
||||
public void handleMediaTypeNotAcceptable(
|
||||
HttpMediaTypeNotAcceptableException ex,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response)
|
||||
throws IOException {
|
||||
|
||||
log.warn(
|
||||
"Media type not acceptable at {}: client accepts {}, server supports {}",
|
||||
request.getRequestURI(),
|
||||
request.getHeader("Accept"),
|
||||
ex.getSupportedMediaTypes());
|
||||
|
||||
// Write JSON error response directly, bypassing content negotiation
|
||||
response.setStatus(HttpStatus.NOT_ACCEPTABLE.value());
|
||||
response.setContentType("application/problem+json");
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
|
||||
String errorJson =
|
||||
String.format(
|
||||
"""
|
||||
{
|
||||
"type": "about:blank",
|
||||
"title": "Not Acceptable",
|
||||
"status": 406,
|
||||
"detail": "The requested resource could not be returned in an acceptable format. Error responses are returned as JSON.",
|
||||
"instance": "%s",
|
||||
"timestamp": "%s",
|
||||
"hints": ["Error responses are always returned as application/json or application/problem+json", "Set Accept header to include application/json for proper error handling"]
|
||||
}
|
||||
""",
|
||||
request.getRequestURI(), Instant.now().toString());
|
||||
|
||||
response.getWriter().write(errorJson);
|
||||
response.getWriter().flush();
|
||||
}
|
||||
|
||||
// ===========================================================================================
|
||||
// JAVA STANDARD EXCEPTIONS
|
||||
// ===========================================================================================
|
||||
@@ -963,9 +1015,8 @@ public class GlobalExceptionHandler {
|
||||
|
||||
// Check if this RuntimeException wraps a typed exception from job execution
|
||||
Throwable cause = ex.getCause();
|
||||
if (cause instanceof BaseAppException) {
|
||||
if (cause instanceof BaseAppException appEx) {
|
||||
// Delegate to specific BaseAppException handlers
|
||||
BaseAppException appEx = (BaseAppException) cause;
|
||||
if (appEx instanceof PdfPasswordException) {
|
||||
return handlePdfPassword((PdfPasswordException) appEx, request);
|
||||
} else if (appEx instanceof PdfCorruptedException
|
||||
@@ -979,9 +1030,8 @@ public class GlobalExceptionHandler {
|
||||
} else {
|
||||
return handleBaseApp(appEx, request);
|
||||
}
|
||||
} else if (cause instanceof BaseValidationException) {
|
||||
} else if (cause instanceof BaseValidationException valEx) {
|
||||
// Delegate to validation exception handlers
|
||||
BaseValidationException valEx = (BaseValidationException) cause;
|
||||
if (valEx instanceof CbrFormatException
|
||||
|| valEx instanceof CbzFormatException
|
||||
|| valEx instanceof EmlFormatException) {
|
||||
@@ -992,6 +1042,9 @@ public class GlobalExceptionHandler {
|
||||
} else if (cause instanceof IOException) {
|
||||
// Unwrap and handle IOException (may contain PDF-specific errors)
|
||||
return handleIOException((IOException) cause, request);
|
||||
} else if (cause instanceof IllegalArgumentException) {
|
||||
// Unwrap and handle IllegalArgumentException (business logic validation errors)
|
||||
return handleIllegalArgument((IllegalArgumentException) cause, request);
|
||||
}
|
||||
|
||||
// Not a wrapped exception - treat as unexpected error
|
||||
@@ -1064,6 +1117,34 @@ public class GlobalExceptionHandler {
|
||||
return handleBaseApp((BaseAppException) processedException, request);
|
||||
}
|
||||
|
||||
// Check if this is a NoSuchFileException (temp file was deleted prematurely)
|
||||
if (ex instanceof java.nio.file.NoSuchFileException) {
|
||||
log.error(
|
||||
"Temporary file not found at {}: {}",
|
||||
request.getRequestURI(),
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
|
||||
String message =
|
||||
getLocalizedMessage(
|
||||
"error.tempFileNotFound.detail",
|
||||
"The temporary file was not found. This may indicate a processing error or cleanup issue. Please try again.");
|
||||
String title =
|
||||
getLocalizedMessage("error.tempFileNotFound.title", "Temporary File Not Found");
|
||||
|
||||
ProblemDetail problemDetail =
|
||||
createBaseProblemDetail(HttpStatus.INTERNAL_SERVER_ERROR, message, request);
|
||||
problemDetail.setType(URI.create("https://stirlingpdf.com/errors/temp-file-not-found"));
|
||||
problemDetail.setTitle(title);
|
||||
problemDetail.setProperty("title", title);
|
||||
problemDetail.setProperty("errorCode", "E999");
|
||||
problemDetail.setProperty(
|
||||
"hint.1",
|
||||
"This error usually occurs when temporary files are cleaned up before processing completes.");
|
||||
problemDetail.setProperty("hint.2", "Try submitting your request again.");
|
||||
return new ResponseEntity<>(problemDetail, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
log.error("IO error at {}: {}", request.getRequestURI(), ex.getMessage(), ex);
|
||||
|
||||
String message =
|
||||
@@ -1108,9 +1189,19 @@ public class GlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ProblemDetail> handleGenericException(
|
||||
Exception ex, HttpServletRequest request) {
|
||||
Exception ex, HttpServletRequest request, HttpServletResponse response) {
|
||||
log.error("Unexpected error at {}: {}", request.getRequestURI(), ex.getMessage(), ex);
|
||||
|
||||
// If response is already committed (e.g., during streaming), we can't send an error
|
||||
// response
|
||||
// Log the error and return null to let Spring handle it gracefully
|
||||
if (response.isCommitted()) {
|
||||
log.warn(
|
||||
"Cannot send error response because response is already committed for URI: {}",
|
||||
request.getRequestURI());
|
||||
return null; // Spring will handle gracefully
|
||||
}
|
||||
|
||||
String userMessage =
|
||||
getLocalizedMessage(
|
||||
"error.unexpected",
|
||||
|
||||
@@ -20,4 +20,10 @@ public class AddAttachmentRequest extends PDFFile {
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
format = "binary")
|
||||
private List<MultipartFile> attachments;
|
||||
|
||||
@Schema(
|
||||
description = "Convert the resulting PDF to PDF/A-3b format after adding attachments",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "false")
|
||||
private boolean convertToPdfA3b = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package stirling.software.SPDF.model.api.misc;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AttachmentInfo {
|
||||
private String filename;
|
||||
private Long size;
|
||||
private String contentType;
|
||||
private String description;
|
||||
private String creationDate;
|
||||
private String modificationDate;
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package stirling.software.SPDF.model.api.misc;
|
||||
|
||||
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 DeleteAttachmentRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description = "The name of the attachment to delete",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String attachmentName;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package stirling.software.SPDF.model.api.misc;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ListAttachmentsRequest extends PDFFile {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user