mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5598e9aa09 | ||
|
|
86958786bc | ||
|
|
4c0767a90f | ||
|
|
3deee325a9 | ||
|
|
b82c95ac61 | ||
|
|
1e9af08c00 | ||
|
|
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 |
@@ -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
|
||||
@@ -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'
|
||||
@@ -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,7 +374,7 @@ jobs:
|
||||
console.log(`Could not delete deployment started comment: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${v2Port}`;
|
||||
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -96,7 +96,6 @@ 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: |
|
||||
|
||||
@@ -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
|
||||
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,7 +151,7 @@ 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 }}
|
||||
@@ -166,10 +163,10 @@ jobs:
|
||||
- backend
|
||||
restart: on-failure:5
|
||||
EOF
|
||||
|
||||
|
||||
# Copy to remote with unique name
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/$UNIQUE_NAME
|
||||
|
||||
|
||||
# SSH and rename/move atomically to avoid interference
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
|
||||
mkdir -p /stirling/V2/{data,config,logs}
|
||||
@@ -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"
|
||||
|
||||
@@ -5,7 +5,6 @@ on:
|
||||
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
|
||||
@@ -83,7 +82,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for latest (V2-master branch - production)
|
||||
id: meta
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
@@ -97,7 +96,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for latest (V1_V2_merge branch - test)
|
||||
id: meta-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref == 'refs/heads/V1_V2_merge'
|
||||
with:
|
||||
images: |
|
||||
@@ -139,7 +138,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for latest-fat (V2-master branch - production)
|
||||
id: meta-fat
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
@@ -153,7 +152,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for latest-fat (V1_V2_merge branch - test)
|
||||
id: meta-fat-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref == 'refs/heads/V1_V2_merge'
|
||||
with:
|
||||
images: |
|
||||
@@ -193,7 +192,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for ultra-lite (V2-master branch - production)
|
||||
id: meta-lite
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
@@ -207,7 +206,7 @@ jobs:
|
||||
|
||||
- name: Generate tags for ultra-lite (V1_V2_merge branch - test)
|
||||
id: meta-lite-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref == 'refs/heads/V1_V2_merge'
|
||||
with:
|
||||
images: |
|
||||
|
||||
@@ -37,10 +37,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
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
|
||||
- name: Generate tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
images: |
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
|
||||
- name: Generate tags ultra-lite
|
||||
id: meta2
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
images: |
|
||||
@@ -166,7 +166,7 @@ jobs:
|
||||
|
||||
- name: Generate tags fat
|
||||
id: meta3
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
|
||||
@@ -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"
|
||||
@@ -579,7 +579,7 @@ jobs:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
retention-days: 7
|
||||
|
||||
|
||||
- name: Verify build artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,7 @@ 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
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
+90
-76
@@ -32,83 +32,76 @@ 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);
|
||||
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);
|
||||
|
||||
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)) {
|
||||
contentStream.drawImage(
|
||||
pdImage,
|
||||
0,
|
||||
0,
|
||||
pdPage.getMediaBox().getWidth(),
|
||||
pdPage.getMediaBox().getHeight());
|
||||
}
|
||||
} finally {
|
||||
if (tempImageFile != null && tempImageFile.exists()) {
|
||||
Files.delete(tempImageFile.toPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,18 +110,20 @@ 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
|
||||
@@ -137,4 +132,23 @@ public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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'
|
||||
|
||||
@@ -32,7 +32,8 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
|
||||
"principal",
|
||||
"startDate",
|
||||
"endDate",
|
||||
"async");
|
||||
"async",
|
||||
"session");
|
||||
|
||||
@Override
|
||||
public boolean preHandle(
|
||||
|
||||
@@ -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.
|
||||
@@ -363,9 +371,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 {
|
||||
|
||||
+201
-162
@@ -2,7 +2,6 @@ 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.zip.ZipEntry;
|
||||
@@ -23,7 +22,6 @@ 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;
|
||||
@@ -35,7 +33,6 @@ import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
|
||||
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;
|
||||
@@ -48,7 +45,6 @@ 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)
|
||||
@Operation(
|
||||
@@ -58,10 +54,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<?> splitPdf(@ModelAttribute SplitPdfBySectionsRequest request)
|
||||
throws Exception {
|
||||
MultipartFile file = request.getFileInput();
|
||||
String pageNumbers = request.getPageNumbers();
|
||||
SplitTypes splitMode =
|
||||
@@ -69,169 +63,214 @@ public class SplitPdfBySectionsController {
|
||||
.map(SplitTypes::valueOf)
|
||||
.orElse(SplitTypes.SPLIT_ALL);
|
||||
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file, true)) {
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
Set<Integer> customPagesToSplit =
|
||||
getCustomPagesToSplit(pageNumbers, splitMode, totalPages);
|
||||
|
||||
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();
|
||||
}
|
||||
return WebResponseUtils.pdfFileToWebResponse(tempFile, filename + "_split.pdf");
|
||||
}
|
||||
for (PDDocument doc : splitDocuments) {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
doc.close();
|
||||
splitDocumentsBoas.add(baos);
|
||||
}
|
||||
|
||||
sourceDocument.close();
|
||||
|
||||
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();
|
||||
|
||||
if (sectionNum == horiz * verti) pageNum++;
|
||||
if (merge) {
|
||||
try (PDDocument mergedDoc =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(
|
||||
sourceDocument);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
LayerUtility layerUtility = new LayerUtility(mergedDoc);
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
if (shouldSplitPage(pageIndex, totalPages, splitMode, customPagesToSplit)) {
|
||||
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 < totalPages; pageIndex++) {
|
||||
int pageNum = pageIndex + 1;
|
||||
if (shouldSplitPage(pageIndex, totalPages, splitMode, customPagesToSplit)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + ".zip");
|
||||
}
|
||||
}
|
||||
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + "_split.zip");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
PDFormXObject form = layerUtility.importPageAsForm(sourceDoc, pageIndex);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
private Set<Integer> getPagesToSplit(String pageNumbers, SplitTypes splitMode, int totalPages) {
|
||||
Set<Integer> pagesToSplit = new HashSet<>();
|
||||
|
||||
switch (splitMode) {
|
||||
case CUSTOM:
|
||||
if (pageNumbers == null || pageNumbers.isBlank()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.argumentRequired",
|
||||
"{0} is required for {1} mode",
|
||||
"page numbers",
|
||||
"custom");
|
||||
}
|
||||
String[] pageOrderArr = pageNumbers.split(",");
|
||||
List<Integer> pageListToSplit =
|
||||
GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
|
||||
pagesToSplit.addAll(pageListToSplit);
|
||||
break;
|
||||
|
||||
case SPLIT_ALL:
|
||||
for (int i = 0; i < totalPages; i++) {
|
||||
pagesToSplit.add(i);
|
||||
}
|
||||
break;
|
||||
|
||||
case SPLIT_ALL_EXCEPT_FIRST:
|
||||
for (int i = 1; i < totalPages; i++) {
|
||||
pagesToSplit.add(i);
|
||||
}
|
||||
break;
|
||||
|
||||
case SPLIT_ALL_EXCEPT_LAST:
|
||||
for (int i = 0; i < totalPages - 1; i++) {
|
||||
pagesToSplit.add(i);
|
||||
}
|
||||
break;
|
||||
|
||||
case SPLIT_ALL_EXCEPT_FIRST_AND_LAST:
|
||||
for (int i = 1; i < totalPages - 1; i++) {
|
||||
pagesToSplit.add(i);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat", "Invalid {0} format: {1}", "split mode", splitMode);
|
||||
private Set<Integer> getCustomPagesToSplit(
|
||||
String pageNumbers, SplitTypes splitMode, int totalPages) {
|
||||
if (splitMode != SplitTypes.CUSTOM) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
|
||||
return pagesToSplit;
|
||||
if (pageNumbers == null || pageNumbers.isBlank()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.argumentRequired",
|
||||
"{0} is required for {1} mode",
|
||||
"page numbers",
|
||||
"custom");
|
||||
}
|
||||
String[] pageOrderArr = pageNumbers.split(",");
|
||||
List<Integer> pageListToSplit = GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
|
||||
return new HashSet<>(pageListToSplit);
|
||||
}
|
||||
|
||||
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;
|
||||
private boolean shouldSplitPage(
|
||||
int pageIndex, int totalPages, SplitTypes splitMode, Set<Integer> customPagesToSplit) {
|
||||
return switch (splitMode) {
|
||||
case CUSTOM -> customPagesToSplit.contains(pageIndex);
|
||||
case SPLIT_ALL -> true;
|
||||
case SPLIT_ALL_EXCEPT_FIRST -> pageIndex > 0;
|
||||
case SPLIT_ALL_EXCEPT_LAST -> pageIndex < totalPages - 1;
|
||||
case SPLIT_ALL_EXCEPT_FIRST_AND_LAST -> pageIndex > 0 && pageIndex < totalPages - 1;
|
||||
default ->
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid {0} format: {1}",
|
||||
"split mode",
|
||||
splitMode);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+267
-215
@@ -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);
|
||||
@@ -414,24 +475,15 @@ public class SplitPdfBySizeController {
|
||||
log.debug("Starting saveDocumentToZip for document part {}", index);
|
||||
ByteArrayOutputStream outStream = new ByteArrayOutputStream();
|
||||
|
||||
try {
|
||||
try (PDDocument doc = document) {
|
||||
log.debug("Saving document part {} to byte array", index);
|
||||
document.save(outStream);
|
||||
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";
|
||||
|
||||
+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 =
|
||||
|
||||
+21
-25
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+870
-341
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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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,
|
||||
|
||||
+12
@@ -71,6 +71,15 @@ 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 setting
|
||||
configData.put(
|
||||
"enableMobileScanner",
|
||||
applicationProperties.getSystem().isEnableMobileScanner());
|
||||
|
||||
// Extract values from ApplicationProperties
|
||||
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
|
||||
configData.put("languages", applicationProperties.getUi().getLanguages());
|
||||
@@ -84,6 +93,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();
|
||||
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
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.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;
|
||||
|
||||
public MobileScannerController(MobileScannerService mobileScannerService) {
|
||||
this.mobileScannerService = mobileScannerService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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")
|
||||
public ResponseEntity<Map<String, Object>> createSession(
|
||||
@Parameter(description = "Session ID for QR code", required = true) @PathVariable
|
||||
String sessionId) {
|
||||
|
||||
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")
|
||||
public ResponseEntity<Map<String, Object>> validateSession(
|
||||
@Parameter(description = "Session ID to validate", required = true) @PathVariable
|
||||
String sessionId) {
|
||||
|
||||
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 = "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) {
|
||||
|
||||
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)))
|
||||
public ResponseEntity<Map<String, Object>> getSessionFiles(
|
||||
@Parameter(description = "Session ID", required = true) @PathVariable
|
||||
String sessionId) {
|
||||
|
||||
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 = "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) {
|
||||
|
||||
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")
|
||||
public ResponseEntity<Map<String, Object>> deleteSession(
|
||||
@Parameter(description = "Session ID to delete", required = true) @PathVariable
|
||||
String sessionId) {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -255,6 +255,7 @@ public class OCRController {
|
||||
command.add("--force-ocr");
|
||||
}
|
||||
}
|
||||
command.add("--invalidate-digital-signatures");
|
||||
|
||||
command.addAll(
|
||||
Arrays.asList(
|
||||
|
||||
+32
-30
@@ -77,38 +77,40 @@ 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();
|
||||
try (PDDocument document = Loader.loadPDF(file.getBytes())) {
|
||||
PrinterJob job = PrinterJob.getPrinterJob();
|
||||
job.setPrintService(selectedService);
|
||||
job.setPageable(new PDFPageable(document));
|
||||
job.print();
|
||||
}
|
||||
} 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);
|
||||
|
||||
+6
-6
@@ -133,13 +133,13 @@ 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);
|
||||
}
|
||||
} else {
|
||||
doc.addSignature(signature, instance);
|
||||
}
|
||||
|
||||
+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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<>() {});
|
||||
|
||||
+2
-2
@@ -115,13 +115,13 @@ public class ReactRoutingController {
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
+57
-4
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
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 RenameAttachmentRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description = "The current name of the attachment to rename",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String attachmentName;
|
||||
|
||||
@Schema(
|
||||
description = "The new name for the attachment",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String newName;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package stirling.software.SPDF.model.api.security;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class PDFVerificationRequest extends PDFFile {}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package stirling.software.SPDF.model.api.security;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PDFVerificationResult {
|
||||
|
||||
private String standard;
|
||||
private String standardName;
|
||||
private String validationProfile;
|
||||
private String validationProfileName;
|
||||
private String complianceSummary;
|
||||
private boolean declaredPdfa;
|
||||
private boolean compliant;
|
||||
private int totalFailures;
|
||||
private int totalWarnings;
|
||||
private List<ValidationIssue> failures = new ArrayList<>();
|
||||
private List<ValidationIssue> warnings = new ArrayList<>();
|
||||
|
||||
public void addFailure(ValidationIssue failure) {
|
||||
this.failures.add(failure);
|
||||
this.totalFailures = this.failures.size();
|
||||
}
|
||||
|
||||
public void addWarning(ValidationIssue warning) {
|
||||
this.warnings.add(warning);
|
||||
this.totalWarnings = this.warnings.size();
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class ValidationIssue {
|
||||
private String ruleId;
|
||||
private String message;
|
||||
private String location;
|
||||
private String specification;
|
||||
private String clause;
|
||||
private String testNumber;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import java.nio.file.attribute.FileTime;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.GregorianCalendar;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
@@ -36,6 +37,9 @@ import io.github.pixee.security.Filenames;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.AttachmentInfo;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class AttachmentService implements AttachmentServiceInterface {
|
||||
@@ -216,6 +220,142 @@ public class AttachmentService implements AttachmentServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AttachmentInfo> listAttachments(PDDocument document) throws IOException {
|
||||
List<AttachmentInfo> attachments = new ArrayList<>();
|
||||
|
||||
PDDocumentCatalog catalog = document.getDocumentCatalog();
|
||||
if (catalog == null) {
|
||||
return attachments;
|
||||
}
|
||||
|
||||
PDDocumentNameDictionary documentNames = catalog.getNames();
|
||||
if (documentNames == null) {
|
||||
return attachments;
|
||||
}
|
||||
|
||||
PDEmbeddedFilesNameTreeNode embeddedFilesTree = documentNames.getEmbeddedFiles();
|
||||
if (embeddedFilesTree == null) {
|
||||
return attachments;
|
||||
}
|
||||
|
||||
Map<String, PDComplexFileSpecification> embeddedFiles = new LinkedHashMap<>();
|
||||
collectEmbeddedFiles(embeddedFilesTree, embeddedFiles);
|
||||
|
||||
for (Map.Entry<String, PDComplexFileSpecification> entry : embeddedFiles.entrySet()) {
|
||||
PDComplexFileSpecification fileSpecification = entry.getValue();
|
||||
PDEmbeddedFile embeddedFile = getEmbeddedFile(fileSpecification);
|
||||
|
||||
if (embeddedFile != null) {
|
||||
String filename = determineFilename(entry.getKey(), fileSpecification);
|
||||
String description = fileSpecification.getFileDescription();
|
||||
String contentType = embeddedFile.getSubtype();
|
||||
Long size = (long) embeddedFile.getSize();
|
||||
|
||||
String creationDate = null;
|
||||
if (embeddedFile.getCreationDate() != null) {
|
||||
creationDate = embeddedFile.getCreationDate().getTime().toString();
|
||||
}
|
||||
|
||||
String modificationDate = null;
|
||||
if (embeddedFile.getModDate() != null) {
|
||||
modificationDate = embeddedFile.getModDate().getTime().toString();
|
||||
}
|
||||
|
||||
AttachmentInfo attachmentInfo =
|
||||
new AttachmentInfo(
|
||||
filename,
|
||||
size,
|
||||
contentType,
|
||||
description,
|
||||
creationDate,
|
||||
modificationDate);
|
||||
|
||||
attachments.add(attachmentInfo);
|
||||
}
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PDDocument renameAttachment(PDDocument document, String attachmentName, String newName)
|
||||
throws IOException {
|
||||
PDEmbeddedFilesNameTreeNode embeddedFilesTree = getEmbeddedFilesTree(document);
|
||||
|
||||
Map<String, PDComplexFileSpecification> allEmbeddedFiles = new LinkedHashMap<>();
|
||||
collectEmbeddedFiles(embeddedFilesTree, allEmbeddedFiles);
|
||||
|
||||
PDComplexFileSpecification fileToRename = null;
|
||||
String keyToRename = null;
|
||||
|
||||
for (Map.Entry<String, PDComplexFileSpecification> entry : allEmbeddedFiles.entrySet()) {
|
||||
String currentName = determineFilename(entry.getKey(), entry.getValue());
|
||||
if (currentName.equals(attachmentName)) {
|
||||
fileToRename = entry.getValue();
|
||||
keyToRename = entry.getKey();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (fileToRename == null || keyToRename == null) {
|
||||
log.warn("Attachment '{}' not found for renaming", attachmentName);
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.attachmentNotFound",
|
||||
"Attachment ''{0}'' not found for renaming",
|
||||
attachmentName);
|
||||
}
|
||||
|
||||
fileToRename.setFile(newName);
|
||||
fileToRename.setFileUnicode(newName);
|
||||
|
||||
allEmbeddedFiles.remove(keyToRename);
|
||||
allEmbeddedFiles.put(newName, fileToRename);
|
||||
|
||||
embeddedFilesTree.setKids(null);
|
||||
|
||||
embeddedFilesTree.setNames(allEmbeddedFiles);
|
||||
log.info("Renamed attachment from '{}' to '{}'", attachmentName, newName);
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PDDocument deleteAttachment(PDDocument document, String attachmentName)
|
||||
throws IOException {
|
||||
PDEmbeddedFilesNameTreeNode embeddedFilesTree = getEmbeddedFilesTree(document);
|
||||
|
||||
Map<String, PDComplexFileSpecification> allEmbeddedFiles = new LinkedHashMap<>();
|
||||
collectEmbeddedFiles(embeddedFilesTree, allEmbeddedFiles);
|
||||
|
||||
String keyToRemove = null;
|
||||
|
||||
for (Map.Entry<String, PDComplexFileSpecification> entry : allEmbeddedFiles.entrySet()) {
|
||||
String currentName = determineFilename(entry.getKey(), entry.getValue());
|
||||
if (currentName.equals(attachmentName)) {
|
||||
keyToRemove = entry.getKey();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (keyToRemove == null) {
|
||||
log.warn("Attachment '{}' not found for deletion", attachmentName);
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.attachmentNotFound",
|
||||
"Attachment ''{0}'' not found for deletion",
|
||||
attachmentName);
|
||||
}
|
||||
|
||||
allEmbeddedFiles.remove(keyToRemove);
|
||||
|
||||
embeddedFilesTree.setKids(null);
|
||||
|
||||
embeddedFilesTree.setNames(allEmbeddedFiles);
|
||||
log.info("Deleted attachment: '{}'", attachmentName);
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
private String sanitizeFilename(String candidate) {
|
||||
String sanitized = Filenames.toSimpleFileName(candidate);
|
||||
if (StringUtils.isBlank(sanitized)) {
|
||||
|
||||
@@ -7,10 +7,19 @@ import java.util.Optional;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.AttachmentInfo;
|
||||
|
||||
public interface AttachmentServiceInterface {
|
||||
|
||||
PDDocument addAttachment(PDDocument document, List<MultipartFile> attachments)
|
||||
throws IOException;
|
||||
|
||||
Optional<byte[]> extractAttachments(PDDocument document) throws IOException;
|
||||
|
||||
List<AttachmentInfo> listAttachments(PDDocument document) throws IOException;
|
||||
|
||||
PDDocument renameAttachment(PDDocument document, String attachmentName, String newName)
|
||||
throws IOException;
|
||||
|
||||
PDDocument deleteAttachment(PDDocument document, String attachmentName) throws IOException;
|
||||
}
|
||||
|
||||
+26
-26
@@ -423,38 +423,38 @@ public class CertificateValidationService {
|
||||
private void loadBundledMozillaCACerts() {
|
||||
try {
|
||||
log.info("Loading bundled Mozilla CA certificates from resources");
|
||||
InputStream certStream =
|
||||
getClass().getClassLoader().getResourceAsStream("certs/cacert.pem");
|
||||
if (certStream == null) {
|
||||
log.warn("Bundled Mozilla CA certificate file not found in resources");
|
||||
return;
|
||||
}
|
||||
try (InputStream certStream =
|
||||
getClass().getClassLoader().getResourceAsStream("certs/cacert.pem")) {
|
||||
if (certStream == null) {
|
||||
log.warn("Bundled Mozilla CA certificate file not found in resources");
|
||||
return;
|
||||
}
|
||||
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
Collection<? extends Certificate> certs = cf.generateCertificates(certStream);
|
||||
certStream.close();
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
Collection<? extends Certificate> certs = cf.generateCertificates(certStream);
|
||||
|
||||
int loadedCount = 0;
|
||||
int skippedCount = 0;
|
||||
int loadedCount = 0;
|
||||
int skippedCount = 0;
|
||||
|
||||
for (Certificate cert : certs) {
|
||||
if (cert instanceof X509Certificate x509) {
|
||||
// Only add CA certificates to trust anchors
|
||||
if (isCA(x509)) {
|
||||
String fingerprint = sha256Fingerprint(x509);
|
||||
String alias = "mozilla-" + fingerprint;
|
||||
signingTrustAnchors.setCertificateEntry(alias, x509);
|
||||
loadedCount++;
|
||||
} else {
|
||||
skippedCount++;
|
||||
for (Certificate cert : certs) {
|
||||
if (cert instanceof X509Certificate x509) {
|
||||
// Only add CA certificates to trust anchors
|
||||
if (isCA(x509)) {
|
||||
String fingerprint = sha256Fingerprint(x509);
|
||||
String alias = "mozilla-" + fingerprint;
|
||||
signingTrustAnchors.setCertificateEntry(alias, x509);
|
||||
loadedCount++;
|
||||
} else {
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Loaded {} Mozilla CA certificates as trust anchors (skipped {} non-CA certs)",
|
||||
loadedCount,
|
||||
skippedCount);
|
||||
log.info(
|
||||
"Loaded {} Mozilla CA certificates as trust anchors (skipped {} non-CA certs)",
|
||||
loadedCount,
|
||||
skippedCount);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load bundled Mozilla CA certificates: {}", e.getMessage(), e);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.verapdf.core.EncryptedPdfException;
|
||||
import org.verapdf.core.ModelParsingException;
|
||||
import org.verapdf.core.ValidationException;
|
||||
import org.verapdf.gf.foundry.VeraGreenfieldFoundryProvider;
|
||||
import org.verapdf.pdfa.Foundries;
|
||||
import org.verapdf.pdfa.PDFAParser;
|
||||
import org.verapdf.pdfa.PDFAValidator;
|
||||
import org.verapdf.pdfa.flavours.PDFAFlavour;
|
||||
import org.verapdf.pdfa.flavours.PDFFlavours;
|
||||
import org.verapdf.pdfa.results.TestAssertion;
|
||||
import org.verapdf.pdfa.results.ValidationResult;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.security.PDFVerificationResult;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class VeraPDFService {
|
||||
|
||||
private static final String NOT_PDFA_STANDARD_ID = "not-pdfa";
|
||||
private static final String NOT_PDFA_STANDARD_NAME =
|
||||
"Not PDF/A (no PDF/A identification metadata)";
|
||||
|
||||
private static PDFVerificationResult convertToVerificationResult(
|
||||
ValidationResult result, PDFAFlavour declaredFlavour, PDFAFlavour validationFlavour) {
|
||||
PDFVerificationResult verificationResult = new PDFVerificationResult();
|
||||
|
||||
PDFAFlavour validationProfile =
|
||||
validationFlavour != null ? validationFlavour : result.getPDFAFlavour();
|
||||
boolean validationIsPdfa = isPdfaFlavour(validationProfile);
|
||||
|
||||
if (validationProfile != null) {
|
||||
verificationResult.setValidationProfile(validationProfile.getId());
|
||||
verificationResult.setValidationProfileName(getStandardName(validationProfile));
|
||||
}
|
||||
|
||||
if (declaredFlavour != null) {
|
||||
verificationResult.setStandard(declaredFlavour.getId());
|
||||
verificationResult.setDeclaredPdfa(isPdfaFlavour(declaredFlavour));
|
||||
} else if (validationProfile != null && !validationIsPdfa) {
|
||||
verificationResult.setStandard(validationProfile.getId());
|
||||
verificationResult.setDeclaredPdfa(false);
|
||||
} else {
|
||||
verificationResult.setStandard(NOT_PDFA_STANDARD_ID);
|
||||
verificationResult.setDeclaredPdfa(false);
|
||||
}
|
||||
|
||||
for (TestAssertion assertion : result.getTestAssertions()) {
|
||||
if (assertion.getStatus() == TestAssertion.Status.FAILED) {
|
||||
PDFVerificationResult.ValidationIssue issue = createValidationIssue(assertion);
|
||||
verificationResult.addFailure(issue);
|
||||
}
|
||||
}
|
||||
|
||||
verificationResult.setCompliant(result.isCompliant());
|
||||
|
||||
String baseName;
|
||||
if (declaredFlavour != null) {
|
||||
baseName = getStandardName(declaredFlavour);
|
||||
} else if (validationIsPdfa) {
|
||||
baseName = NOT_PDFA_STANDARD_NAME;
|
||||
} else if (validationProfile != null) {
|
||||
baseName = getStandardName(validationProfile);
|
||||
} else {
|
||||
baseName = "Unknown standard";
|
||||
}
|
||||
|
||||
String standardDisplay =
|
||||
formatStandardDisplay(
|
||||
baseName,
|
||||
verificationResult.getTotalFailures(),
|
||||
isPdfaFlavour(declaredFlavour),
|
||||
validationIsPdfa && declaredFlavour == null);
|
||||
verificationResult.setStandardName(standardDisplay);
|
||||
verificationResult.setComplianceSummary(standardDisplay);
|
||||
|
||||
log.debug(
|
||||
"Validation complete for profile {} (declared: {}): {} failures",
|
||||
validationProfile != null ? validationProfile.getId() : "unknown",
|
||||
declaredFlavour != null ? declaredFlavour.getId() : NOT_PDFA_STANDARD_ID,
|
||||
verificationResult.getTotalFailures());
|
||||
|
||||
return verificationResult;
|
||||
}
|
||||
|
||||
private static PDFVerificationResult.ValidationIssue createValidationIssue(
|
||||
TestAssertion assertion) {
|
||||
PDFVerificationResult.ValidationIssue issue = new PDFVerificationResult.ValidationIssue();
|
||||
|
||||
if (assertion.getRuleId() != null) {
|
||||
issue.setRuleId(assertion.getRuleId().toString());
|
||||
issue.setClause(assertion.getRuleId().getClause());
|
||||
|
||||
if (assertion.getRuleId().getSpecification() != null) {
|
||||
issue.setSpecification(assertion.getRuleId().getSpecification().toString());
|
||||
}
|
||||
|
||||
int testNumber = assertion.getRuleId().getTestNumber();
|
||||
if (testNumber > 0) {
|
||||
issue.setTestNumber(String.valueOf(testNumber));
|
||||
}
|
||||
}
|
||||
|
||||
issue.setMessage(assertion.getMessage());
|
||||
issue.setLocation(
|
||||
assertion.getLocation() != null ? assertion.getLocation().toString() : "Unknown");
|
||||
|
||||
return issue;
|
||||
}
|
||||
|
||||
private static PDFVerificationResult createNoPdfaDeclarationResult() {
|
||||
PDFVerificationResult result = new PDFVerificationResult();
|
||||
result.setStandard(NOT_PDFA_STANDARD_ID);
|
||||
result.setStandardName(NOT_PDFA_STANDARD_NAME);
|
||||
result.setComplianceSummary(NOT_PDFA_STANDARD_NAME);
|
||||
result.setCompliant(false);
|
||||
result.setDeclaredPdfa(false);
|
||||
|
||||
PDFVerificationResult.ValidationIssue issue = new PDFVerificationResult.ValidationIssue();
|
||||
issue.setMessage("Document does not declare PDF/A compliance in its XMP metadata.");
|
||||
issue.setSpecification("XMP pdfaid");
|
||||
result.addFailure(issue);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static PDFVerificationResult buildErrorResult(
|
||||
PDFAFlavour declaredFlavour, PDFAFlavour validationFlavour, String errorMessage) {
|
||||
|
||||
PDFVerificationResult errorResult = new PDFVerificationResult();
|
||||
|
||||
PDFAFlavour declaredForResult =
|
||||
isPdfaFlavour(validationFlavour) ? declaredFlavour : validationFlavour;
|
||||
|
||||
if (declaredForResult != null) {
|
||||
errorResult.setStandard(declaredForResult.getId());
|
||||
errorResult.setStandardName(getStandardName(declaredForResult) + " with errors");
|
||||
errorResult.setDeclaredPdfa(isPdfaFlavour(declaredForResult));
|
||||
} else if (isPdfaFlavour(validationFlavour)) {
|
||||
errorResult.setStandard(NOT_PDFA_STANDARD_ID);
|
||||
errorResult.setStandardName(NOT_PDFA_STANDARD_NAME);
|
||||
errorResult.setDeclaredPdfa(false);
|
||||
} else {
|
||||
errorResult.setStandard(
|
||||
validationFlavour != null ? validationFlavour.getId() : NOT_PDFA_STANDARD_ID);
|
||||
errorResult.setStandardName(
|
||||
(validationFlavour != null
|
||||
? getStandardName(validationFlavour)
|
||||
: "Unknown standard")
|
||||
+ " with errors");
|
||||
errorResult.setDeclaredPdfa(false);
|
||||
}
|
||||
|
||||
errorResult.setValidationProfile(
|
||||
validationFlavour != null ? validationFlavour.getId() : NOT_PDFA_STANDARD_ID);
|
||||
errorResult.setValidationProfileName(
|
||||
validationFlavour != null
|
||||
? getStandardName(validationFlavour)
|
||||
: "Unknown standard");
|
||||
errorResult.setComplianceSummary(errorResult.getStandardName());
|
||||
errorResult.setCompliant(false);
|
||||
|
||||
PDFVerificationResult.ValidationIssue failure = new PDFVerificationResult.ValidationIssue();
|
||||
failure.setMessage(errorMessage);
|
||||
errorResult.addFailure(failure);
|
||||
|
||||
return errorResult;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void initialize() {
|
||||
try {
|
||||
VeraGreenfieldFoundryProvider.initialise();
|
||||
log.info("VeraPDF Greenfield initialized successfully");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to initialize VeraPDF", e);
|
||||
}
|
||||
}
|
||||
|
||||
public List<PDFVerificationResult> validatePDF(InputStream pdfStream)
|
||||
throws IOException, ValidationException, ModelParsingException, EncryptedPdfException {
|
||||
|
||||
byte[] pdfBytes = pdfStream.readAllBytes();
|
||||
List<PDFVerificationResult> results = new ArrayList<>();
|
||||
|
||||
PDFAFlavour declaredFlavour;
|
||||
List<PDFAFlavour> detectedFlavours;
|
||||
|
||||
try (PDFAParser detectionParser =
|
||||
Foundries.defaultInstance().createParser(new ByteArrayInputStream(pdfBytes))) {
|
||||
declaredFlavour = detectionParser.getFlavour();
|
||||
detectedFlavours = detectionParser.getFlavours();
|
||||
}
|
||||
|
||||
List<PDFAFlavour> flavoursToValidate = new ArrayList<>();
|
||||
boolean hasPdfaDeclaration = isPdfaFlavour(declaredFlavour);
|
||||
|
||||
if (declaredFlavour != null) {
|
||||
flavoursToValidate.add(declaredFlavour);
|
||||
}
|
||||
|
||||
for (PDFAFlavour flavour : detectedFlavours) {
|
||||
if (flavour.equals(declaredFlavour)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_A)) {
|
||||
if (hasPdfaDeclaration) {
|
||||
flavoursToValidate.add(flavour);
|
||||
} else {
|
||||
log.debug(
|
||||
"Ignoring detected PDF/A flavour {} because no PDF/A declaration exists in XMP",
|
||||
flavour.getId());
|
||||
}
|
||||
} else if (PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_UA)
|
||||
|| PDFFlavours.isFlavourFamily(
|
||||
flavour, PDFAFlavour.SpecificationFamily.WTPDF)) {
|
||||
flavoursToValidate.add(flavour);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasPdfaDeclaration) {
|
||||
results.add(createNoPdfaDeclarationResult());
|
||||
}
|
||||
|
||||
if (flavoursToValidate.isEmpty()) {
|
||||
log.info("No verifiable PDF/A, PDF/UA, or WTPDF standards declared via XMP metadata");
|
||||
return results;
|
||||
}
|
||||
|
||||
for (PDFAFlavour flavour : flavoursToValidate) {
|
||||
try (PDFAParser parser =
|
||||
Foundries.defaultInstance()
|
||||
.createParser(new ByteArrayInputStream(pdfBytes), flavour)) {
|
||||
|
||||
PDFAFlavour parserDeclared = parser.getFlavour();
|
||||
PDFAValidator validator =
|
||||
Foundries.defaultInstance().createValidator(flavour, false);
|
||||
ValidationResult result = validator.validate(parser);
|
||||
|
||||
PDFAFlavour declaredForResult =
|
||||
PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_A)
|
||||
? parserDeclared
|
||||
: flavour;
|
||||
|
||||
results.add(convertToVerificationResult(result, declaredForResult, flavour));
|
||||
} catch (Exception e) {
|
||||
log.error("Error validating standard {}: {}", flavour.getId(), e.getMessage());
|
||||
results.add(
|
||||
buildErrorResult(
|
||||
declaredFlavour, flavour, "Validation error: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static boolean isPdfaFlavour(PDFAFlavour flavour) {
|
||||
return PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_A);
|
||||
}
|
||||
|
||||
private static String formatStandardDisplay(
|
||||
String baseName,
|
||||
int errorCount,
|
||||
boolean declaredPdfa,
|
||||
boolean inferredPdfaWithoutDeclaration) {
|
||||
|
||||
if (inferredPdfaWithoutDeclaration) {
|
||||
return NOT_PDFA_STANDARD_NAME;
|
||||
}
|
||||
|
||||
if (!declaredPdfa && NOT_PDFA_STANDARD_NAME.equals(baseName)) {
|
||||
return NOT_PDFA_STANDARD_NAME;
|
||||
}
|
||||
|
||||
if (errorCount > 0) {
|
||||
return baseName + " with errors";
|
||||
}
|
||||
|
||||
return baseName + " compliant";
|
||||
}
|
||||
|
||||
private static String getStandardName(PDFAFlavour flavour) {
|
||||
String id = flavour.getId();
|
||||
String part = flavour.getPart().toString();
|
||||
String level = flavour.getLevel().toString();
|
||||
|
||||
// PDF/A standards - Fixed: proper length check and parentheses
|
||||
if (!id.isEmpty()
|
||||
&& (id.charAt(0) == '1'
|
||||
|| id.charAt(0) == '2'
|
||||
|| id.charAt(0) == '3'
|
||||
|| id.charAt(0) == '4')) {
|
||||
return "PDF/A-" + part + (level.isEmpty() ? "" : level);
|
||||
}
|
||||
// PDF/UA standards
|
||||
else if (id.contains("ua")) {
|
||||
return "PDF/UA-" + part;
|
||||
}
|
||||
// WTPDF standards
|
||||
else if (id.contains("wtpdf")) {
|
||||
return "WTPDF " + part;
|
||||
}
|
||||
|
||||
return flavour.toString();
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,7 @@ system:
|
||||
enableAlphaFunctionality: false # set to enable functionality which might need more testing before it fully goes live (this feature might make no changes)
|
||||
showUpdate: false # see when a new update is available
|
||||
showUpdateOnlyAdmin: false # only admins can see when a new update is available, depending on showUpdate it must be set to 'true'
|
||||
showSettingsWhenNoLogin: true # set to 'false' to hide settings button when login is disabled (enableLogin: false). Only applies when login is disabled.
|
||||
customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files
|
||||
tessdataDir: "" # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored.
|
||||
enableAnalytics: null # Master toggle for analytics: set to 'true' to enable all analytics, 'false' to disable all analytics, or leave as 'null' to prompt admin on first launch
|
||||
@@ -144,6 +145,7 @@ system:
|
||||
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173'
|
||||
backendUrl: '' # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
|
||||
frontendUrl: '' # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
|
||||
enableMobileScanner: false # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
|
||||
serverCertificate:
|
||||
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
|
||||
organizationName: Stirling-PDF # Organization name for generated certificates
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="description" content="Stirling-PDF API Server">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Stirling-PDF - API Server</title>
|
||||
|
||||
<!-- Icons -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
|
||||
<link rel="shortcut icon" href="favicon.ico">
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background-color: #f3f4f6;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem 1.5rem 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: min(45rem, 96vw);
|
||||
background-color: #ffffff;
|
||||
border-radius: 1.25rem;
|
||||
box-shadow: 0 1.25rem 3.75rem rgba(0, 0, 0, 0.12);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.auth-content {
|
||||
max-width: 26.25rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
margin-bottom: 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.login-header-logos {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.login-logo-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.login-logo-text {
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
color: #111827;
|
||||
margin: 0 0 0.375rem;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin: 1.5rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.section-text {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.section-list {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
margin-left: 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.section-list li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.section-list a,
|
||||
.section-text a {
|
||||
color: #AF3434;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.section-list a:hover,
|
||||
.section-text a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.code-inline {
|
||||
background-color: #f3f4f6;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.8125rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: #AF3434;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
transition: background-color 0.2s;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
background-color: #9a2e2e;
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 1.5rem 0;
|
||||
border: 0;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
color: #374151;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
background-color: transparent;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #AF3434;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="auth-card">
|
||||
<div class="auth-content">
|
||||
<div class="login-header">
|
||||
<div class="login-header-logos">
|
||||
<img src="favicon.svg" alt="Stirling PDF" class="login-logo-icon">
|
||||
<img src="api-wordmark.svg" alt="Stirling PDF" class="login-logo-text">
|
||||
</div>
|
||||
<h1 class="login-title">API Server</h1>
|
||||
<p class="login-subtitle">Backend-only mode without web UI</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<p class="section-text">
|
||||
This Stirling-PDF instance is running in <strong>API-only mode</strong>. The web interface has not been included in this build.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a href="swagger-ui/index.html" class="cta-button">Open API Documentation</a>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Looking for the Web UI?</h2>
|
||||
|
||||
<p class="section-text"><strong>If you're using Docker:</strong></p>
|
||||
<ul class="section-list">
|
||||
<li>You may have pulled an <strong>API-only image</strong> or there was a build configuration issue</li>
|
||||
<li>Use the standard Docker image: <code class="code-inline">stirlingtools/stirling-pdf:latest</code></li>
|
||||
</ul>
|
||||
|
||||
<p class="section-text"><strong>If you're using a JAR file:</strong></p>
|
||||
<ul class="section-list">
|
||||
<li>You downloaded <code class="code-inline">Stirling-PDF-server.jar</code> which is the <strong>API-only version without UI</strong></li>
|
||||
<li>Download the full version from <a href="https://github.com/Stirling-Tools/Stirling-PDF/releases" target="_blank">GitHub Releases</a>:</li>
|
||||
<li style="margin-left: 1.5rem;"><code class="code-inline">Stirling-PDF.jar</code> - Standard version with UI</li>
|
||||
<li style="margin-left: 1.5rem;"><code class="code-inline">Stirling-PDF-with-login.jar</code> - Version with authentication features</li>
|
||||
</ul>
|
||||
|
||||
<p class="section-text"><strong>If you built from source:</strong></p>
|
||||
<ul class="section-list">
|
||||
<li>Rebuild with: <code class="code-inline">./gradlew build -PbuildWithFrontend=true</code></li>
|
||||
<li>Or deploy the frontend separately from the <code class="code-inline">/frontend</code> directory</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<hr class="divider">
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Need Help?</h2>
|
||||
<p class="section-text">Join our community for support:</p>
|
||||
<ul class="section-list">
|
||||
<li><a href="https://discord.gg/Cn8pWhQRxZ" target="_blank">Discord Community</a> - Get help from the community</li>
|
||||
<li><a href="https://github.com/Stirling-Tools/Stirling-PDF/issues" target="_blank">GitHub Issues</a> - Report bugs or request features</li>
|
||||
<li><a href="https://github.com/Stirling-Tools/Stirling-PDF" target="_blank">GitHub Repository</a> - View documentation and source code</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<span>Powered by </span>
|
||||
<a href="https://stirlingpdf.com">Stirling PDF</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.4 KiB |
@@ -1,53 +1,99 @@
|
||||
/* Light theme variables */
|
||||
:root {
|
||||
--cc-bg: #ffffff;
|
||||
--cc-primary-color: #1c1c1c;
|
||||
--cc-secondary-color: #666666;
|
||||
|
||||
--cc-btn-primary-bg: #007BFF;
|
||||
--cc-btn-primary-color: #ffffff;
|
||||
--cc-btn-primary-border-color: #007BFF;
|
||||
--cc-btn-primary-hover-bg: #0056b3;
|
||||
--cc-btn-primary-hover-color: #ffffff;
|
||||
--cc-btn-primary-hover-border-color: #0056b3;
|
||||
|
||||
--cc-btn-secondary-bg: #f1f3f4;
|
||||
--cc-btn-secondary-color: #1c1c1c;
|
||||
--cc-btn-secondary-border-color: #f1f3f4;
|
||||
--cc-btn-secondary-hover-bg: #007BFF;
|
||||
--cc-btn-secondary-hover-color: #ffffff;
|
||||
--cc-btn-secondary-hover-border-color: #007BFF;
|
||||
|
||||
--cc-separator-border-color: #e0e0e0;
|
||||
|
||||
--cc-toggle-on-bg: #007BFF;
|
||||
--cc-toggle-off-bg: #667481;
|
||||
--cc-toggle-on-knob-bg: #ffffff;
|
||||
--cc-toggle-off-knob-bg: #ffffff;
|
||||
|
||||
--cc-toggle-enabled-icon-color: #ffffff;
|
||||
--cc-toggle-disabled-icon-color: #ffffff;
|
||||
|
||||
--cc-toggle-readonly-bg: #f1f3f4;
|
||||
--cc-toggle-readonly-knob-bg: #79747E;
|
||||
--cc-toggle-readonly-knob-icon-color: #f1f3f4;
|
||||
|
||||
--cc-section-category-border: #e0e0e0;
|
||||
|
||||
--cc-cookie-category-block-bg: #f1f3f4;
|
||||
--cc-cookie-category-block-border: #f1f3f4;
|
||||
--cc-cookie-category-block-hover-bg: #e9eff4;
|
||||
--cc-cookie-category-block-hover-border: #e9eff4;
|
||||
|
||||
--cc-cookie-category-expanded-block-bg: #f1f3f4;
|
||||
--cc-cookie-category-expanded-block-hover-bg: #e9eff4;
|
||||
|
||||
--cc-footer-bg: #ffffff;
|
||||
--cc-footer-color: #1c1c1c;
|
||||
--cc-footer-border-color: #ffffff;
|
||||
}
|
||||
|
||||
/* Dark theme variables */
|
||||
.cc--darkmode{
|
||||
--cc-bg: var(--md-sys-color-inverse-on-surface);
|
||||
--cc-primary-color: var(--md-sys-color-on-surface);
|
||||
--cc-secondary-color: var(--md-sys-color-on-surface);
|
||||
--cc-bg: #2d2d2d;
|
||||
--cc-primary-color: #e5e5e5;
|
||||
--cc-secondary-color: #b0b0b0;
|
||||
|
||||
--cc-btn-primary-bg: var(--md-sys-color-secondary);
|
||||
--cc-btn-primary-color: var(--cc-bg);
|
||||
--cc-btn-primary-border-color: var(--cc-btn-primary-bg);
|
||||
--cc-btn-primary-hover-bg: var(--md-sys-color-surface-3);
|
||||
--cc-btn-primary-hover-color: var(--md-sys-color-on-secondary-container);
|
||||
--cc-btn-primary-hover-border-color: var(--md-sys-color-surface-3);
|
||||
--cc-btn-primary-bg: #4dabf7;
|
||||
--cc-btn-primary-color: #ffffff;
|
||||
--cc-btn-primary-border-color: #4dabf7;
|
||||
--cc-btn-primary-hover-bg: #3d3d3d;
|
||||
--cc-btn-primary-hover-color: #ffffff;
|
||||
--cc-btn-primary-hover-border-color: #3d3d3d;
|
||||
|
||||
--cc-btn-secondary-bg: var(--md-sys-color-surface-3);
|
||||
--cc-btn-secondary-color: var(--md-sys-color-on-secondary-container);
|
||||
--cc-btn-secondary-border-color: var(--md-sys-color-surface-3);
|
||||
--cc-btn-secondary-hover-bg:var(--md-sys-color-secondary);
|
||||
--cc-btn-secondary-hover-color: var(--cc-bg);
|
||||
--cc-btn-secondary-hover-border-color: var(--md-sys-color-secondary);
|
||||
--cc-btn-secondary-bg: #3d3d3d;
|
||||
--cc-btn-secondary-color: #ffffff;
|
||||
--cc-btn-secondary-border-color: #3d3d3d;
|
||||
--cc-btn-secondary-hover-bg: #4dabf7;
|
||||
--cc-btn-secondary-hover-color: #ffffff;
|
||||
--cc-btn-secondary-hover-border-color: #4dabf7;
|
||||
|
||||
--cc-separator-border-color: var(--md-sys-color-outline);
|
||||
--cc-separator-border-color: #555555;
|
||||
|
||||
--cc-toggle-on-bg: var(--cc-btn-primary-bg);
|
||||
--cc-toggle-off-bg: var(--md-sys-color-outline);
|
||||
--cc-toggle-on-knob-bg: var(--cc-btn-primary-color);
|
||||
--cc-toggle-off-knob-bg: var(--cc-btn-primary-color);
|
||||
--cc-toggle-on-bg: #4dabf7;
|
||||
--cc-toggle-off-bg: #667481;
|
||||
--cc-toggle-on-knob-bg: #2d2d2d;
|
||||
--cc-toggle-off-knob-bg: #2d2d2d;
|
||||
|
||||
--cc-toggle-enabled-icon-color: var(--cc-btn-primary-color);
|
||||
--cc-toggle-disabled-icon-color: var(--cc-btn-primary-color);
|
||||
--cc-toggle-enabled-icon-color: #2d2d2d;
|
||||
--cc-toggle-disabled-icon-color: #2d2d2d;
|
||||
|
||||
--cc-toggle-readonly-bg: var(--md-sys-color-surface);
|
||||
--cc-toggle-readonly-knob-bg: var(--md-sys-color-outline);
|
||||
--cc-toggle-readonly-knob-icon-color: var(--cc-toggle-readonly-bg);
|
||||
--cc-toggle-readonly-bg: #555555;
|
||||
--cc-toggle-readonly-knob-bg: #8e8e8e;
|
||||
--cc-toggle-readonly-knob-icon-color: #555555;
|
||||
|
||||
--cc-section-category-border: var(--md-sys-color-outline);
|
||||
--cc-section-category-border: #555555;
|
||||
|
||||
--cc-cookie-category-block-bg: var(--cc-btn-secondary-bg);
|
||||
--cc-cookie-category-block-border: var(--cc-btn-secondary-bg);
|
||||
--cc-cookie-category-block-hover-bg: var(--cc-btn-secondary-bg);
|
||||
--cc-cookie-category-block-hover-border: var(--cc-btn-secondary-bg);
|
||||
--cc-cookie-category-block-bg: #3d3d3d;
|
||||
--cc-cookie-category-block-border: #3d3d3d;
|
||||
--cc-cookie-category-block-hover-bg: #4d4d4d;
|
||||
--cc-cookie-category-block-hover-border: #4d4d4d;
|
||||
|
||||
--cc-cookie-category-expanded-block-bg: #3d3d3d;
|
||||
--cc-cookie-category-expanded-block-hover-bg: #4d4d4d;
|
||||
|
||||
--cc-cookie-category-expanded-block-bg: var(--cc-btn-secondary-bg);
|
||||
--cc-cookie-category-expanded-block-hover-bg: var(--cc-toggle-readonly-bg);
|
||||
|
||||
/* --cc-overlay-bg: rgba(0, 0, 0, 0.65);
|
||||
--cc-webkit-scrollbar-bg: var(--cc-section-category-border);
|
||||
--cc-webkit-scrollbar-hover-bg: var(--cc-btn-primary-hover-bg);
|
||||
*/
|
||||
--cc-footer-bg: var(--cc-bg);
|
||||
--cc-footer-color: var(--cc-primary-color);
|
||||
--cc-footer-border-color: var(--cc-bg);
|
||||
--cc-footer-bg: #2d2d2d;
|
||||
--cc-footer-color: #e5e5e5;
|
||||
--cc-footer-border-color: #2d2d2d;
|
||||
}
|
||||
.cm__body{
|
||||
max-width: 90% !important;
|
||||
@@ -78,7 +124,83 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Toggle visibility fixes */
|
||||
#cc-main .section__toggle {
|
||||
opacity: 0 !important; /* Keep invisible but functional */
|
||||
}
|
||||
|
||||
#cc-main .toggle__icon {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
|
||||
#cc-main .toggle__icon-circle {
|
||||
display: block !important;
|
||||
position: absolute !important;
|
||||
transition: transform 0.25s ease !important;
|
||||
}
|
||||
|
||||
#cc-main .toggle__icon-on,
|
||||
#cc-main .toggle__icon-off {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
position: absolute !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Ensure toggles are visible in both themes */
|
||||
#cc-main .toggle__icon {
|
||||
background: var(--cc-toggle-off-bg) !important;
|
||||
border: 1px solid var(--cc-toggle-off-bg) !important;
|
||||
}
|
||||
|
||||
#cc-main .section__toggle:checked ~ .toggle__icon {
|
||||
background: var(--cc-toggle-on-bg) !important;
|
||||
border: 1px solid var(--cc-toggle-on-bg) !important;
|
||||
}
|
||||
|
||||
/* Ensure toggle text is visible */
|
||||
#cc-main .pm__section-title {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .pm__section-desc {
|
||||
color: var(--cc-secondary-color) !important;
|
||||
}
|
||||
|
||||
/* Make sure the modal has proper contrast */
|
||||
#cc-main .pm {
|
||||
background: var(--cc-bg) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
/* Lower z-index so cookie banner appears behind onboarding modals */
|
||||
#cc-main {
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
||||
/* Ensure consent modal text is visible in both themes */
|
||||
#cc-main .cm {
|
||||
background: var(--cc-bg) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__title {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__desc {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__footer {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__footer-links a,
|
||||
#cc-main .cm__link {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
@@ -1,20 +1,25 @@
|
||||
{
|
||||
"name": "Stirling-PDF",
|
||||
"short_name": "Stirling-PDF",
|
||||
"short_name": "Stirling PDF",
|
||||
"name": "Stirling PDF",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
"src": "modern-logo/favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
"src": "modern-logo/logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "modern-logo/logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": "/",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#000000"
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.SplitPdfBySizeOrCountRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SplitPdfBySizeControllerTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@InjectMocks private SplitPdfBySizeController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
when(tempFileManager.createTempFile(anyString()))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
String suffix = invocation.getArgument(0);
|
||||
return Files.createTempFile(tempDir, "test", suffix).toFile();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should split by page count successfully")
|
||||
void shouldSplitByPageCount() throws Exception {
|
||||
byte[] pdfBytes;
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
Path pdfPath = tempDir.resolve("input.pdf");
|
||||
doc.save(pdfPath.toFile());
|
||||
pdfBytes = Files.readAllBytes(pdfPath);
|
||||
}
|
||||
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest();
|
||||
request.setFileInput(file);
|
||||
request.setSplitType(1); // Page count
|
||||
request.setSplitValue("2");
|
||||
|
||||
when(pdfDocumentFactory.load(any(byte[].class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((byte[]) inv.getArgument(0)));
|
||||
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
|
||||
.thenAnswer(inv -> new PDDocument());
|
||||
|
||||
ResponseEntity<byte[]> response = controller.autoSplitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isNotEmpty();
|
||||
assertThat(response.getHeaders().getContentType())
|
||||
.isEqualTo(MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should split by document count successfully")
|
||||
void shouldSplitByDocCount() throws Exception {
|
||||
byte[] pdfBytes;
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
for (int i = 0; i < 6; i++) {
|
||||
doc.addPage(new PDPage(PDRectangle.A4));
|
||||
}
|
||||
Path pdfPath = tempDir.resolve("input.pdf");
|
||||
doc.save(pdfPath.toFile());
|
||||
pdfBytes = Files.readAllBytes(pdfPath);
|
||||
}
|
||||
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest();
|
||||
request.setFileInput(file);
|
||||
request.setSplitType(2); // Document count
|
||||
request.setSplitValue("3"); // Split into 3 docs (2 pages each)
|
||||
|
||||
when(pdfDocumentFactory.load(any(byte[].class)))
|
||||
.thenAnswer(inv -> Loader.loadPDF((byte[]) inv.getArgument(0)));
|
||||
|
||||
when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class)))
|
||||
.thenAnswer(inv -> new PDDocument());
|
||||
|
||||
ResponseEntity<byte[]> response = controller.autoSplitPdf(request);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isNotEmpty();
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
@@ -189,9 +190,12 @@ public class ConvertWebsiteToPdfTest {
|
||||
when(mockExec.runCommandWithOutputHandling(cmdCaptor.capture()))
|
||||
.thenReturn(dummyResult);
|
||||
|
||||
// Mock WebResponseUtils
|
||||
ResponseEntity<byte[]> fakeResponse = ResponseEntity.ok(new byte[0]);
|
||||
wr.when(() -> WebResponseUtils.pdfDocToWebResponse(any(PDDocument.class), anyString()))
|
||||
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.baosToWebResponse(
|
||||
any(ByteArrayOutputStream.class), any()))
|
||||
.thenReturn(fakeResponse);
|
||||
|
||||
// Act
|
||||
@@ -261,7 +265,10 @@ public class ConvertWebsiteToPdfTest {
|
||||
|
||||
// WebResponseUtils
|
||||
ResponseEntity<byte[]> fakeResponse = ResponseEntity.ok(new byte[0]);
|
||||
wr.when(() -> WebResponseUtils.pdfDocToWebResponse(any(PDDocument.class), anyString()))
|
||||
wr.when(
|
||||
() ->
|
||||
WebResponseUtils.baosToWebResponse(
|
||||
any(ByteArrayOutputStream.class), any()))
|
||||
.thenReturn(fakeResponse);
|
||||
|
||||
// Act: should not throw and should return a Response
|
||||
|
||||
+15
-17
@@ -67,16 +67,16 @@ class AttachmentControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void addAttachments_Success() throws IOException {
|
||||
void addAttachments_Success() throws Exception {
|
||||
List<MultipartFile> attachments = List.of(attachment1, attachment2);
|
||||
request.setAttachments(attachments);
|
||||
request.setFileInput(pdfFile);
|
||||
ResponseEntity<byte[]> expectedResponse =
|
||||
ResponseEntity.ok("modified PDF content".getBytes());
|
||||
|
||||
when(pdfDocumentFactory.load(pdfFile, false)).thenReturn(mockDocument);
|
||||
when(pdfDocumentFactory.load(request, false)).thenReturn(mockDocument);
|
||||
when(pdfAttachmentService.addAttachment(mockDocument, attachments))
|
||||
.thenReturn(modifiedMockDocument);
|
||||
.thenReturn(mockDocument);
|
||||
|
||||
try (MockedStatic<WebResponseUtils> mockedWebResponseUtils =
|
||||
mockStatic(WebResponseUtils.class)) {
|
||||
@@ -84,8 +84,7 @@ class AttachmentControllerTest {
|
||||
.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfDocToWebResponse(
|
||||
eq(modifiedMockDocument),
|
||||
eq("test_with_attachments.pdf")))
|
||||
eq(mockDocument), eq("test_with_attachments.pdf")))
|
||||
.thenReturn(expectedResponse);
|
||||
|
||||
ResponseEntity<byte[]> response = attachmentController.addAttachments(request);
|
||||
@@ -93,22 +92,22 @@ class AttachmentControllerTest {
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
verify(pdfDocumentFactory).load(pdfFile, false);
|
||||
verify(pdfDocumentFactory).load(request, false);
|
||||
verify(pdfAttachmentService).addAttachment(mockDocument, attachments);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void addAttachments_SingleAttachment() throws IOException {
|
||||
void addAttachments_SingleAttachment() throws Exception {
|
||||
List<MultipartFile> attachments = List.of(attachment1);
|
||||
request.setAttachments(attachments);
|
||||
request.setFileInput(pdfFile);
|
||||
ResponseEntity<byte[]> expectedResponse =
|
||||
ResponseEntity.ok("modified PDF content".getBytes());
|
||||
|
||||
when(pdfDocumentFactory.load(pdfFile, false)).thenReturn(mockDocument);
|
||||
when(pdfDocumentFactory.load(request, false)).thenReturn(mockDocument);
|
||||
when(pdfAttachmentService.addAttachment(mockDocument, attachments))
|
||||
.thenReturn(modifiedMockDocument);
|
||||
.thenReturn(mockDocument);
|
||||
|
||||
try (MockedStatic<WebResponseUtils> mockedWebResponseUtils =
|
||||
mockStatic(WebResponseUtils.class)) {
|
||||
@@ -116,8 +115,7 @@ class AttachmentControllerTest {
|
||||
.when(
|
||||
() ->
|
||||
WebResponseUtils.pdfDocToWebResponse(
|
||||
eq(modifiedMockDocument),
|
||||
eq("test_with_attachments.pdf")))
|
||||
eq(mockDocument), eq("test_with_attachments.pdf")))
|
||||
.thenReturn(expectedResponse);
|
||||
|
||||
ResponseEntity<byte[]> response = attachmentController.addAttachments(request);
|
||||
@@ -125,33 +123,33 @@ class AttachmentControllerTest {
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
verify(pdfDocumentFactory).load(pdfFile, false);
|
||||
verify(pdfDocumentFactory).load(request, false);
|
||||
verify(pdfAttachmentService).addAttachment(mockDocument, attachments);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void addAttachments_IOExceptionFromPDFLoad() throws IOException {
|
||||
void addAttachments_IOExceptionFromPDFLoad() throws Exception {
|
||||
List<MultipartFile> attachments = List.of(attachment1);
|
||||
request.setAttachments(attachments);
|
||||
request.setFileInput(pdfFile);
|
||||
IOException ioException = new IOException("Failed to load PDF");
|
||||
|
||||
when(pdfDocumentFactory.load(pdfFile, false)).thenThrow(ioException);
|
||||
when(pdfDocumentFactory.load(request, false)).thenThrow(ioException);
|
||||
|
||||
assertThrows(IOException.class, () -> attachmentController.addAttachments(request));
|
||||
verify(pdfDocumentFactory).load(pdfFile, false);
|
||||
verify(pdfDocumentFactory).load(request, false);
|
||||
verifyNoInteractions(pdfAttachmentService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void addAttachments_IOExceptionFromAttachmentService() throws IOException {
|
||||
void addAttachments_IOExceptionFromAttachmentService() throws Exception {
|
||||
List<MultipartFile> attachments = List.of(attachment1);
|
||||
request.setAttachments(attachments);
|
||||
request.setFileInput(pdfFile);
|
||||
IOException ioException = new IOException("Failed to add attachment");
|
||||
|
||||
when(pdfDocumentFactory.load(pdfFile, false)).thenReturn(mockDocument);
|
||||
when(pdfDocumentFactory.load(request, false)).thenReturn(mockDocument);
|
||||
when(pdfAttachmentService.addAttachment(mockDocument, attachments)).thenThrow(ioException);
|
||||
|
||||
assertThrows(IOException.class, () -> attachmentController.addAttachments(request));
|
||||
|
||||
@@ -53,7 +53,7 @@ dependencies {
|
||||
implementation 'com.bucket4j:bucket4j_jdk17-core:8.15.0'
|
||||
|
||||
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
|
||||
implementation 'org.bouncycastle:bcprov-jdk18on:1.82'
|
||||
implementation 'org.bouncycastle:bcprov-jdk18on:1.83'
|
||||
|
||||
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.3.RELEASE'
|
||||
api 'io.micrometer:micrometer-registry-prometheus'
|
||||
|
||||
+1
-11
@@ -3,8 +3,6 @@ package stirling.software.proprietary.security.session;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -182,15 +180,7 @@ public class SessionPersistentRegistry implements SessionRegistry {
|
||||
}
|
||||
|
||||
// Sort sessions by lastRequest in descending order
|
||||
Collections.sort(
|
||||
allSessions,
|
||||
new Comparator<SessionEntity>() {
|
||||
@Override
|
||||
public int compare(SessionEntity s1, SessionEntity s2) {
|
||||
// Sort by lastRequest in descending order
|
||||
return s2.getLastRequest().compareTo(s1.getLastRequest());
|
||||
}
|
||||
});
|
||||
allSessions.sort((s1, s2) -> s2.getLastRequest().compareTo(s1.getLastRequest()));
|
||||
|
||||
// The first session in the list is the latest session for the given principal name
|
||||
return Optional.of(allSessions.get(0));
|
||||
|
||||
+7
-7
@@ -8,7 +8,7 @@ plugins {
|
||||
id "com.diffplug.spotless" version "8.1.0"
|
||||
id "com.github.jk1.dependency-license-report" version "3.0.1"
|
||||
//id "nebula.lint" version "19.0.3"
|
||||
id "org.sonarqube" version "7.1.0.6387"
|
||||
id "org.sonarqube" version "7.2.2.6593"
|
||||
}
|
||||
|
||||
import com.github.jk1.license.render.*
|
||||
@@ -18,14 +18,14 @@ import groovy.json.JsonSlurper
|
||||
ext {
|
||||
springBootVersion = "3.5.7"
|
||||
pdfboxVersion = "3.0.6"
|
||||
imageioVersion = "3.12.0"
|
||||
imageioVersion = "3.13.0"
|
||||
lombokVersion = "1.18.42"
|
||||
bouncycastleVersion = "1.82"
|
||||
bouncycastleVersion = "1.83"
|
||||
springSecuritySamlVersion = "6.5.6"
|
||||
openSamlVersion = "4.3.2"
|
||||
commonmarkVersion = "0.27.0"
|
||||
googleJavaFormatVersion = "1.28.0"
|
||||
logback = "1.5.21"
|
||||
logback = "1.5.23"
|
||||
junitPlatformVersion = "1.12.2"
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ repositories {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.1.5'
|
||||
version = '2.2.0'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
@@ -172,7 +172,7 @@ subprojects {
|
||||
testRuntimeOnly 'org.mockito:mockito-inline:5.2.0'
|
||||
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
|
||||
|
||||
testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.3.1")
|
||||
testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.3.2")
|
||||
testImplementation "com.squareup.okhttp3:mockwebserver"
|
||||
}
|
||||
|
||||
@@ -334,7 +334,7 @@ dependencies {
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testRuntimeOnly "org.junit.platform:junit-platform-launcher:$junitPlatformVersion"
|
||||
|
||||
testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.1.0")
|
||||
testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.3.2")
|
||||
testImplementation "com.squareup.okhttp3:mockwebserver"
|
||||
}
|
||||
|
||||
|
||||
Generated
+32
-5
@@ -9,7 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@stylistic/stylelint-plugin": "^4.0.0",
|
||||
"stylelint": "^16.26.0",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint-config-standard": "^39.0.1"
|
||||
}
|
||||
},
|
||||
@@ -78,6 +78,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -85,6 +86,26 @@
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.0.22",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.22.tgz",
|
||||
"integrity": "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
||||
@@ -101,6 +122,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -896,6 +918,7 @@
|
||||
"integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@keyv/serialize": "^1.1.1"
|
||||
}
|
||||
@@ -1104,6 +1127,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -1153,6 +1177,7 @@
|
||||
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@@ -1344,9 +1369,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/stylelint": {
|
||||
"version": "16.26.0",
|
||||
"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.26.0.tgz",
|
||||
"integrity": "sha512-Y/3AVBefrkqqapVYH3LBF5TSDZ1kw+0XpdKN2KchfuhMK6lQ85S4XOG4lIZLcrcS4PWBmvcY6eS2kCQFz0jukQ==",
|
||||
"version": "16.26.1",
|
||||
"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.26.1.tgz",
|
||||
"integrity": "sha512-v20V59/crfc8sVTAtge0mdafI3AdnzQ2KsWe6v523L4OA1bJO02S7MO2oyXDCS6iWb9ckIPnqAFVItqSBQr7jw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1359,8 +1384,10 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.0.19",
|
||||
"@csstools/css-tokenizer": "^3.0.4",
|
||||
"@csstools/media-query-list-parser": "^4.0.3",
|
||||
"@csstools/selector-specificity": "^5.0.0",
|
||||
@@ -1373,7 +1400,7 @@
|
||||
"debug": "^4.4.3",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
"file-entry-cache": "^11.1.0",
|
||||
"file-entry-cache": "^11.1.1",
|
||||
"global-modules": "^2.0.0",
|
||||
"globby": "^11.1.0",
|
||||
"globjoin": "^0.1.4",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stylistic/stylelint-plugin": "^4.0.0",
|
||||
"stylelint": "^16.26.0",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint-config-standard": "^39.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,4 +54,4 @@ services:
|
||||
|
||||
networks:
|
||||
stirling-network:
|
||||
driver: bridge
|
||||
driver: bridge
|
||||
|
||||
@@ -37,7 +37,7 @@ RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
|
||||
# Stage 2: Runtime image based on Debian stable-slim
|
||||
# Contains Java runtime + LibreOffice + Calibre + all PDF tools
|
||||
FROM debian:stable-slim@sha256:7cb087f19bcc175b96fbe4c2aef42ed00733a659581a80f6ebccfd8fe3185a3d
|
||||
FROM debian:stable-slim@sha256:1c25564b03942d874bf6a2b71f2062b71af8bc1475aa873c523e6f7c8fa29e60
|
||||
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -37,7 +37,7 @@ RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
|
||||
# Stage 2: Runtime image based on Debian stable-slim
|
||||
# Contains Java runtime + LibreOffice + Calibre + all PDF tools + extra fonts for air-gapped environments
|
||||
FROM debian:stable-slim@sha256:7cb087f19bcc175b96fbe4c2aef42ed00733a659581a80f6ebccfd8fe3185a3d
|
||||
FROM debian:stable-slim@sha256:1c25564b03942d874bf6a2b71f2062b71af8bc1475aa873c523e6f7c8fa29e60
|
||||
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -19,5 +19,8 @@
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="src/index.tsx"></script>
|
||||
<!-- jscanify and OpenCV for mobile scanner - loaded after React for non-blocking page load -->
|
||||
<script src="/vendor/jscanify/opencv.js" async></script>
|
||||
<script src="/vendor/jscanify/jscanify.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+1151
-2563
File diff suppressed because it is too large
Load Diff
@@ -54,7 +54,9 @@
|
||||
"license-report": "^6.8.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
"peerjs": "^1.5.5",
|
||||
"posthog-js": "^1.268.0",
|
||||
"qrcode.react": "^4.1.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-i18next": "^15.7.3",
|
||||
|
||||
@@ -963,6 +963,7 @@ desc = "Add custom text anywhere in your PDF"
|
||||
addFiles = "Add Files"
|
||||
uploadFromComputer = "Upload from computer"
|
||||
openFromComputer = "Open from computer"
|
||||
mobileUpload = "Upload from Mobile"
|
||||
|
||||
[viewPdf]
|
||||
tags = "view,read,annotate,text,image,highlight,edit"
|
||||
@@ -1249,7 +1250,7 @@ includeAllRecipients = "Include CC and BCC recipients in header"
|
||||
downloadHtml = "Download HTML intermediate file instead of PDF"
|
||||
pdfaOptions = "PDF/A Options"
|
||||
outputFormat = "Output Format"
|
||||
pdfaNote = "PDF/A-1b is more compatible, PDF/A-2b supports more features."
|
||||
pdfaNote = "PDF/A-1b is more compatible, PDF/A-2b supports more features, PDF/A-3b supports embedded files."
|
||||
pdfaDigitalSignatureWarning = "The PDF contains a digital signature. This will be removed in the next step."
|
||||
fileFormat = "File Format"
|
||||
wordDoc = "Word Document"
|
||||
@@ -1278,6 +1279,21 @@ cbzOptions = "CBZ to PDF Options"
|
||||
optimizeForEbook = "Optimize PDF for ebook readers (uses Ghostscript)"
|
||||
cbzOutputOptions = "PDF to CBZ Options"
|
||||
cbzDpi = "DPI for image rendering"
|
||||
cbrOptions = "CBR Options"
|
||||
cbrOutputOptions = "PDF to CBR Options"
|
||||
cbrDpi = "DPI for image rendering"
|
||||
|
||||
[convert.ebookOptions]
|
||||
ebookOptions = "eBook to PDF Options"
|
||||
ebookOptionsDesc = "Options for converting eBooks to PDF"
|
||||
embedAllFonts = "Embed all fonts"
|
||||
embedAllFontsDesc = "Embed all fonts from the eBook into the generated PDF"
|
||||
includeTableOfContents = "Include table of contents"
|
||||
includeTableOfContentsDesc = "Add a generated table of contents to the resulting PDF"
|
||||
includePageNumbers = "Include page numbers"
|
||||
includePageNumbersDesc = "Add page numbers to the generated PDF"
|
||||
optimizeForEbookPdf = "Optimize for ebook readers"
|
||||
optimizeForEbookPdfDesc = "Optimize the PDF for eBook reading (smaller file size, better rendering on eInk devices)"
|
||||
|
||||
[imageToPdf]
|
||||
tags = "conversion,img,jpg,picture,photo"
|
||||
@@ -1394,6 +1410,11 @@ header = "Add Attachments"
|
||||
add = "Add Attachment"
|
||||
remove = "Remove Attachment"
|
||||
embed = "Embed Attachment"
|
||||
convertToPdfA3b = "Convert to PDF/A-3b"
|
||||
convertToPdfA3bDescription = "Creates an archival PDF with embedded attachments"
|
||||
convertToPdfA3bTooltip = "PDF/A-3b is an archival format ensuring long-term preservation. It allows embedding arbitrary file formats as attachments. Conversion requires Ghostscript and may take longer for large files."
|
||||
convertToPdfA3bTooltipHeader = "About PDF/A-3b Conversion"
|
||||
convertToPdfA3bTooltipTitle = "What it does"
|
||||
submit = "Add Attachments"
|
||||
|
||||
[watermark]
|
||||
@@ -2339,6 +2360,10 @@ saved = "Saved"
|
||||
label = "Upload signature image"
|
||||
placeholder = "Select image file"
|
||||
hint = "Upload a PNG or JPG image of your signature"
|
||||
removeBackground = "Remove white background (make transparent)"
|
||||
processing = "Processing image..."
|
||||
backgroundRemovalFailedTitle = "Background removal failed"
|
||||
backgroundRemovalFailedMessage = "Could not remove the background from the image. Using original image instead."
|
||||
|
||||
[sign.instructions]
|
||||
title = "How to add signature"
|
||||
@@ -2384,6 +2409,11 @@ note = "Flattening removes interactive elements from the PDF, making them non-ed
|
||||
label = "Flatten only forms"
|
||||
desc = "Only flatten form fields, leaving other interactive elements intact"
|
||||
|
||||
[flatten.renderDpi]
|
||||
label = "Rendering DPI (optional, recommended 150 DPI)"
|
||||
help = "Leave blank to use the system default. Higher DPI sharpens output but increases processing time and file size."
|
||||
placeholder = "e.g. 150"
|
||||
|
||||
[flatten.results]
|
||||
title = "Flatten Results"
|
||||
|
||||
@@ -2958,6 +2988,7 @@ header = "Crop PDF"
|
||||
submit = "Apply Crop"
|
||||
noFileSelected = "Select a PDF file to begin cropping"
|
||||
reset = "Reset to full PDF"
|
||||
autoCrop = "Auto-crop whitespace"
|
||||
|
||||
[crop.preview]
|
||||
title = "Crop Area Selection"
|
||||
@@ -3375,6 +3406,19 @@ placeholder = "Enter number of horizontal divisions"
|
||||
label = "Vertical Divisions"
|
||||
placeholder = "Enter number of vertical divisions"
|
||||
|
||||
[split-by-sections.splitMode]
|
||||
label = "Split Mode"
|
||||
description = "Choose how to split the pages"
|
||||
splitAll = "Split all pages"
|
||||
splitAllExceptFirst = "Split all except first"
|
||||
splitAllExceptLast = "Split all except last"
|
||||
splitAllExceptFirstAndLast = "Split all except first and last"
|
||||
custom = "Custom pages"
|
||||
|
||||
[split-by-sections.customPages]
|
||||
label = "Custom Page Numbers"
|
||||
placeholder = "e.g. 2,4,6"
|
||||
|
||||
[AddStampRequest]
|
||||
tags = "Stamp, Add image, center image, Watermark, PDF, Embed, Customize,Customise"
|
||||
header = "Stamp PDF"
|
||||
@@ -3736,6 +3780,9 @@ filesize = "File Size"
|
||||
[compress.grayscale]
|
||||
label = "Apply Grayscale for Compression"
|
||||
|
||||
[compress.linearize]
|
||||
label = "Linearize PDF for fast web viewing"
|
||||
|
||||
[compress.lineArt]
|
||||
label = "Convert images to line art"
|
||||
description = "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction."
|
||||
@@ -3779,6 +3826,11 @@ failed = "An error occurred while compressing the PDF."
|
||||
_value = "Compression Settings"
|
||||
1 = "1-3 PDF compression,</br> 4-6 lite image compression,</br> 7-9 intense image compression Will dramatically reduce image quality"
|
||||
|
||||
[compress.compressionLevel]
|
||||
range1to3 = "Lower values preserve quality but result in larger files"
|
||||
range4to6 = "Medium compression with moderate quality reduction"
|
||||
range7to9 = "Higher values reduce file size significantly but may reduce image clarity"
|
||||
|
||||
[decrypt]
|
||||
passwordPrompt = "This file is password-protected. Please enter the password:"
|
||||
cancelled = "Operation cancelled for PDF: {0}"
|
||||
@@ -4309,6 +4361,13 @@ description = "Path to WeasyPrint executable for HTML to PDF conversion (leave e
|
||||
label = "Unoconvert Executable"
|
||||
description = "Path to LibreOffice unoconvert for document conversions (leave empty for default: /opt/venv/bin/unoconvert)"
|
||||
|
||||
[admin.settings.general.frontendUrl]
|
||||
label = "Frontend URL"
|
||||
description = "Base URL for frontend (e.g., https://pdf.example.com). Used for email invite links and mobile QR code uploads. Leave empty to use backend URL."
|
||||
|
||||
[admin.settings.badge]
|
||||
clickToUpgrade = "Click to view plan details"
|
||||
|
||||
[admin.settings.security]
|
||||
title = "Security"
|
||||
description = "Configure authentication, login behaviour, and security policies."
|
||||
@@ -4513,6 +4572,13 @@ description = "Automatically create user accounts on first SAML2 login"
|
||||
label = "Block Registration"
|
||||
description = "Prevent new user registration via SAML2"
|
||||
|
||||
[admin.settings.connections.mobileScanner]
|
||||
label = "Mobile Phone Upload"
|
||||
enable = "Enable QR Code Upload"
|
||||
description = "Allow users to upload files from mobile devices by scanning a QR code"
|
||||
note = "Note: Requires Frontend URL to be configured. "
|
||||
link = "Configure in System Settings"
|
||||
|
||||
[admin.settings.database]
|
||||
title = "Database"
|
||||
description = "Configure custom database connection settings for enterprise deployments."
|
||||
@@ -4694,6 +4760,10 @@ description = "Allow admins to invite users via email with auto-generated passwo
|
||||
label = "Frontend URL"
|
||||
description = "Base URL for frontend (e.g. https://pdf.example.com). Used for generating invite links in emails. Leave empty to use backend URL."
|
||||
|
||||
[admin.settings.mail.frontendUrlNote]
|
||||
note = "Note: Requires Frontend URL to be configured. "
|
||||
link = "Configure in System Settings"
|
||||
|
||||
[admin.settings.legal]
|
||||
title = "Legal Documents"
|
||||
description = "Configure links to legal documents and policies."
|
||||
@@ -4879,6 +4949,8 @@ googleDriveShort = "Drive"
|
||||
myFiles = "My Files"
|
||||
noRecentFiles = "No recent files found"
|
||||
googleDriveNotAvailable = "Google Drive integration not available"
|
||||
mobileUpload = "Mobile Upload"
|
||||
mobileShort = "Mobile"
|
||||
downloadSelected = "Download Selected"
|
||||
saveSelected = "Save Selected"
|
||||
openFiles = "Open Files"
|
||||
@@ -6160,7 +6232,7 @@ title = "Authentication Failed"
|
||||
message = "Authentication was not successful. You can close this window and try again."
|
||||
|
||||
[pdfTextEditor]
|
||||
title = "PDF JSON Editor"
|
||||
title = "PDF Text Editor"
|
||||
viewLabel = "PDF Editor"
|
||||
converting = "Converting PDF to editable format..."
|
||||
conversionFailed = "Failed to convert PDF. Please try again."
|
||||
@@ -6189,6 +6261,8 @@ reset = "Reset Changes"
|
||||
downloadJson = "Download JSON"
|
||||
generatePdf = "Generate PDF"
|
||||
saveChanges = "Save Changes"
|
||||
applyChanges = "Apply Changes"
|
||||
downloadCopy = "Download Copy"
|
||||
|
||||
[pdfTextEditor.options.autoScaleText]
|
||||
title = "Auto-scale text to fit boxes"
|
||||
@@ -6207,6 +6281,24 @@ descriptionInline = "Tip: Hold Ctrl (Cmd) or Shift to multi-select text boxes. A
|
||||
title = "Lock edited text to a single PDF element"
|
||||
description = "When enabled, the editor exports each edited text box as one PDF text element to avoid overlapping glyphs or mixed fonts."
|
||||
|
||||
[pdfTextEditor.options.advanced]
|
||||
title = "Advanced Settings"
|
||||
|
||||
[pdfTextEditor.tooltip.header]
|
||||
title = "Preview Limitations"
|
||||
|
||||
[pdfTextEditor.tooltip.textFocus]
|
||||
title = "Text and Image Focus"
|
||||
text = "This workspace focuses on editing text and repositioning embedded images. Complex page artwork, form widgets, and layered graphics are preserved for export but are not fully editable here."
|
||||
|
||||
[pdfTextEditor.tooltip.previewVariance]
|
||||
title = "Preview Variance"
|
||||
text = "Some visuals (such as table borders, shapes, or annotation appearances) may not display exactly in the preview. The exported PDF keeps the original drawing commands whenever possible."
|
||||
|
||||
[pdfTextEditor.tooltip.alpha]
|
||||
title = "Alpha Viewer"
|
||||
text = "This alpha viewer is still evolving—certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing."
|
||||
|
||||
[pdfTextEditor.manual]
|
||||
mergeTooltip = "Merge selected boxes"
|
||||
merge = "Merge selection"
|
||||
@@ -6226,8 +6318,8 @@ alpha = "This alpha viewer is still evolving—certain fonts, colours, transpare
|
||||
[pdfTextEditor.empty]
|
||||
title = "No document loaded"
|
||||
subtitle = "Load a PDF or JSON file to begin editing text content."
|
||||
dropzone = "Drag and drop a PDF or JSON file here, or click to browse"
|
||||
dropzoneWithFiles = "Select a file from the Files tab, or drag and drop a PDF or JSON file here, or click to browse"
|
||||
dropzone = "Drag and drop a PDF here, or click to browse"
|
||||
dropzoneWithFiles = "Select a file from the Files tab, or drag and drop a PDF here, or click to browse"
|
||||
|
||||
[pdfTextEditor.welcomeBanner]
|
||||
title = "Welcome to PDF Text Editor (Early Access)"
|
||||
@@ -6328,3 +6420,56 @@ title = "Add Text Results"
|
||||
|
||||
[addText.error]
|
||||
failed = "An error occurred while adding text to the PDF."
|
||||
|
||||
[mobileUpload]
|
||||
title = "Upload from Mobile"
|
||||
description = "Scan this QR code with your mobile device to upload photos directly to this page."
|
||||
error = "Connection Error"
|
||||
pollingError = "Error checking for files"
|
||||
sessionId = "Session ID"
|
||||
sessionCreateError = "Failed to create session"
|
||||
expiryWarning = "Session Expiring Soon"
|
||||
expiryWarningMessage = "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically."
|
||||
filesReceived = "{{count}} file(s) received"
|
||||
connected = "Mobile device connected"
|
||||
instructions = "Open the camera app on your phone and scan this code. Files will be transferred directly between devices."
|
||||
|
||||
[mobileScanner]
|
||||
title = "Mobile Scanner"
|
||||
noSession = "Invalid Session"
|
||||
noSessionMessage = "Please scan a valid QR code to access this page."
|
||||
validating = "Validating session..."
|
||||
sessionInvalid = "Session Error"
|
||||
sessionExpired = "This session has expired. Please refresh and try again."
|
||||
sessionNotFound = "Session not found. Please refresh and try again."
|
||||
sessionValidationError = "Unable to verify session. Please try again."
|
||||
uploadSuccess = "Upload Successful!"
|
||||
uploadSuccessMessage = "Your images have been transferred."
|
||||
httpsRequired = "Camera access requires HTTPS or localhost. Please use HTTPS or access via localhost."
|
||||
uploadFailed = "Upload failed. Please try again."
|
||||
uploading = "Uploading..."
|
||||
connected = "Connected"
|
||||
connecting = "Connecting..."
|
||||
chooseMethod = "Choose Upload Method"
|
||||
chooseMethodDescription = "Select how you want to scan and upload documents"
|
||||
camera = "Camera"
|
||||
cameraDescription = "Scan documents using your device camera with automatic edge detection"
|
||||
fileUpload = "File Upload"
|
||||
fileDescription = "Upload existing photos or documents from your device"
|
||||
cameraAccessDenied = "Camera access denied. Please enable camera access."
|
||||
back = "Back"
|
||||
settings = "Settings"
|
||||
edgeDetection = "Edge Detection"
|
||||
flashlight = "Flashlight"
|
||||
flash = "Flash"
|
||||
processing = "Processing..."
|
||||
capture = "Capture Photo"
|
||||
selectFilesPrompt = "Select files to upload"
|
||||
selectImage = "Select Image"
|
||||
preview = "Preview"
|
||||
retake = "Retake"
|
||||
addToBatch = "Add to Batch"
|
||||
upload = "Upload"
|
||||
batchImages = "Batch"
|
||||
clearBatch = "Clear"
|
||||
uploadAll = "Upload All"
|
||||
|
||||
@@ -1107,7 +1107,7 @@ title = "Dividi per conteggio"
|
||||
text = "Crea più PDF con un numero specifico di pagine o documenti ciascuno."
|
||||
bullet1 = "Conteggio pagine: numero fisso per file"
|
||||
bullet2 = "Numero documenti: numero fisso di file in output"
|
||||
bullet3 = "Utile per workflow batch"
|
||||
bullet3 = "Utile per batch di flusso di lavoro"
|
||||
|
||||
[split.tooltip.byChapters]
|
||||
title = "Dividi per capitoli"
|
||||
@@ -1696,7 +1696,7 @@ submit = "Estrai pagine"
|
||||
|
||||
[extractPages.pageNumbers]
|
||||
label = "Pagine da estrarre"
|
||||
placeholder = "es., 1,3,5-8 o odd & 1-10"
|
||||
placeholder = "es., 1,3,5-8 o dispari & 1-10"
|
||||
|
||||
[extractPages.settings]
|
||||
title = "Impostazioni"
|
||||
@@ -1711,7 +1711,7 @@ failed = "Impossibile estrarre le pagine"
|
||||
title = "Pagine estratte"
|
||||
|
||||
[pageSelection.tooltip]
|
||||
description = "Scegli quali pagine usare per l'operazione. Supporta pagine singole, intervalli, formule e la parola chiave \"all\"."
|
||||
description = "Scegli quali pagine usare per l'operazione. Supporta pagine singole, intervalli, formule e la parola chiave \"tutte\"."
|
||||
|
||||
[pageSelection.tooltip.header]
|
||||
title = "Guida Selezione Pagine"
|
||||
@@ -1721,7 +1721,7 @@ title = "Uso di base"
|
||||
text = "Seleziona pagine specifiche dal tuo PDF usando una sintassi semplice."
|
||||
bullet1 = "Pagine singole: 1,3,5"
|
||||
bullet2 = "Intervalli: 3-6 o 10-15"
|
||||
bullet3 = "Tutte le pagine: all"
|
||||
bullet3 = "Tutte le pagine: tutte"
|
||||
|
||||
[pageSelection.tooltip.advanced]
|
||||
title = "Funzionalità avanzate"
|
||||
@@ -1739,15 +1739,15 @@ text = "Usa numeri, intervalli, parole chiave e progressioni (n parte da 0). Son
|
||||
|
||||
[pageSelection.tooltip.syntax.bullets]
|
||||
numbers = "Numeri/intervalli: 5, 10-20"
|
||||
keywords = "Parole chiave: odd, even"
|
||||
keywords = "Parole chiave: dispari, pari"
|
||||
progressions = "Progressioni: 3n, 4n+1"
|
||||
|
||||
[pageSelection.tooltip.operators]
|
||||
title = "Operatori"
|
||||
text = "AND ha precedenza più alta della virgola. NOT si applica all’interno dell’intervallo del documento."
|
||||
and = "AND: & o \"and\" — richiede entrambe le condizioni (es., 1-50 & even)"
|
||||
text = "E ha precedenza più alta della virgola. NON si applica all’interno dell’intervallo del documento."
|
||||
and = "E: & o \"e\" — richiede entrambe le condizioni (es., 1-50 & pari)"
|
||||
comma = "Virgola: , o | — combina selezioni (es., 1-10, 20)"
|
||||
not = "NOT: ! o \"not\" — esclude pagine (es., 3n & not 30)"
|
||||
not = "NON: ! o \"NON\" — esclude pagine (es., 3n & non 30)"
|
||||
|
||||
[pageSelection.tooltip.examples]
|
||||
title = "Esempi"
|
||||
@@ -1795,15 +1795,15 @@ text = "Usa numeri, intervalli, parole chiave e progressioni (n parte da 0). Son
|
||||
|
||||
[bulkSelection.syntax.bullets]
|
||||
numbers = "Numeri/intervalli: 5, 10-20"
|
||||
keywords = "Parole chiave: odd, even"
|
||||
keywords = "Parole chiave: dispari, pari"
|
||||
progressions = "Progressioni: 3n, 4n+1"
|
||||
|
||||
[bulkSelection.operators]
|
||||
title = "Operatori"
|
||||
text = "AND ha precedenza più alta della virgola. NOT si applica all’interno dell’intervallo del documento."
|
||||
and = "AND: & o \"and\" — richiede entrambe le condizioni (es., 1-50 & even)"
|
||||
text = "E ha precedenza più alta della virgola. NON si applica all’interno dell’intervallo del documento."
|
||||
and = "E: & o \"e\" — richiede entrambe le condizioni (es., 1-50 & pari)"
|
||||
comma = "Virgola: , o | — combina selezioni (es., 1-10, 20)"
|
||||
not = "NOT: ! o \"not\" — esclude pagine (es., 3n & not 30)"
|
||||
not = "NON: ! o \"non\" — esclude pagine (es., 3n & non 30)"
|
||||
|
||||
[bulkSelection.examples]
|
||||
title = "Esempi"
|
||||
@@ -2163,9 +2163,9 @@ info = "Python non è installato. È necessario per l'esecuzione."
|
||||
|
||||
[ScannerImageSplit.selectText]
|
||||
1 = "Soglia angolo:"
|
||||
2 = "Imposta il minimo angolo richiesto perché l'immagine venga ruotata (default: 10)."
|
||||
2 = "Imposta il minimo angolo richiesto perché l'immagine venga ruotata (predefinito: 10)."
|
||||
3 = "Tolleranza:"
|
||||
4 = "Imposta lo spettro di colori attorno al colore di sfondo stimato (default: 30)."
|
||||
4 = "Imposta lo spettro di colori attorno al colore di sfondo stimato (predefinito: 30)."
|
||||
5 = "Area minima:"
|
||||
6 = "Imposta l'area minima di una foto (default: 10000)."
|
||||
7 = "Area di contorno minima:"
|
||||
@@ -2343,26 +2343,26 @@ placeholder = "Seleziona un file PDF nella vista principale per iniziare"
|
||||
settings = "Impostazioni"
|
||||
|
||||
[flatten.options]
|
||||
stepTitle = "Opzioni di flattening"
|
||||
title = "Opzioni di flattening"
|
||||
note = "Il flattening rimuove gli elementi interattivi dal PDF, rendendoli non modificabili."
|
||||
stepTitle = "Opzioni di appiattimento"
|
||||
title = "Opzioni di appiattimento"
|
||||
note = "L'appiattimento rimuove gli elementi interattivi dal PDF, rendendoli non modificabili."
|
||||
|
||||
[flatten.options.flattenOnlyForms]
|
||||
label = "Appiattisci solo i moduli"
|
||||
desc = "Appiattisci solo i campi modulo, lasciando intatti gli altri elementi interattivi"
|
||||
|
||||
[flatten.results]
|
||||
title = "Risultati Flatten"
|
||||
title = "Risultati di appiattimento"
|
||||
|
||||
[flatten.error]
|
||||
failed = "Si è verificato un errore durante il flattening del PDF."
|
||||
failed = "Si è verificato un errore durante l'appiattimento del PDF."
|
||||
|
||||
[flatten.tooltip.header]
|
||||
title = "Informazioni sul flattening dei PDF"
|
||||
title = "Informazioni sull'appiattimento dei PDF"
|
||||
|
||||
[flatten.tooltip.description]
|
||||
title = "Cosa fa il flattening?"
|
||||
text = "Il flattening rende il tuo PDF non modificabile trasformando moduli compilabili e pulsanti in testo e immagini normali. Il PDF avrà lo stesso aspetto, ma nessuno potrà più modificare o compilare i moduli. Perfetto per condividere moduli completati, creare documenti finali per gli archivi o garantire un aspetto uniforme ovunque."
|
||||
title = "Cosa fa l'appiattimento?"
|
||||
text = "L'appiattimento rende il tuo PDF non modificabile trasformando moduli compilabili e pulsanti in testo e immagini normali. Il PDF avrà lo stesso aspetto, ma nessuno potrà più modificare o compilare i moduli. Perfetto per condividere moduli completati, creare documenti finali per gli archivi o garantire un aspetto uniforme ovunque."
|
||||
bullet1 = "Le caselle di testo diventano testo normale (non modificabile)"
|
||||
bullet2 = "Checkbox e pulsanti diventano immagini"
|
||||
bullet3 = "Ottimo per versioni finali che non vuoi vengano modificate"
|
||||
@@ -2469,7 +2469,7 @@ title = "Compara"
|
||||
header = "Compara PDF"
|
||||
clearSelected = "Cancella selezionati"
|
||||
addFilesHint = "Aggiungi PDF nel passaggio File per abilitarne la selezione."
|
||||
noFiles = "Nessun PDF disponibile yet"
|
||||
noFiles = "Nessun PDF disponibile"
|
||||
pages = "Pagine"
|
||||
cta = "Confronta"
|
||||
loading = "Confronto in corso..."
|
||||
@@ -4168,7 +4168,7 @@ label = "Directory pipeline"
|
||||
|
||||
[admin.settings.general.customPaths.pipeline.watchedFoldersDir]
|
||||
label = "Directory cartelle monitorate"
|
||||
description = "Directory in cui la pipeline monitora i PDF in arrivo (lascia vuoto per default: /pipeline/watchedFolders)"
|
||||
description = "Directory in cui la pipeline monitora i PDF in arrivo (lascia vuoto per predefinito: /pipeline/watchedFolders)"
|
||||
|
||||
[admin.settings.general.customPaths.pipeline.finishedFoldersDir]
|
||||
label = "Directory cartelle completate"
|
||||
@@ -4179,11 +4179,11 @@ label = "Percorsi strumenti esterni"
|
||||
|
||||
[admin.settings.general.customPaths.operations.weasyprint]
|
||||
label = "Eseguibile WeasyPrint"
|
||||
description = "Percorso dell'eseguibile WeasyPrint per conversione da HTML a PDF (lascia vuoto per default: /opt/venv/bin/weasyprint)"
|
||||
description = "Percorso dell'eseguibile WeasyPrint per conversione da HTML a PDF (lascia vuoto per predefinito: /opt/venv/bin/weasyprint)"
|
||||
|
||||
[admin.settings.general.customPaths.operations.unoconvert]
|
||||
label = "Eseguibile Unoconvert"
|
||||
description = "Percorso di LibreOffice unoconvert per conversioni di documenti (lascia vuoto per default: /opt/venv/bin/unoconvert)"
|
||||
description = "Percorso di LibreOffice unoconvert per conversioni di documenti (lascia vuoto per predefinito: /opt/venv/bin/unoconvert)"
|
||||
|
||||
[admin.settings.security]
|
||||
title = "Sicurezza"
|
||||
@@ -4305,12 +4305,12 @@ label = "Blocca localhost"
|
||||
description = "Blocca localhost e indirizzi loopback (127.x.x.x, ::1)"
|
||||
|
||||
[admin.settings.security.htmlUrlSecurity.blockLinkLocal]
|
||||
label = "Blocca indirizzi link-local"
|
||||
description = "Blocca indirizzi link-local (169.254.x.x, fe80::/10)"
|
||||
label = "Blocca indirizzi link-locali"
|
||||
description = "Blocca indirizzi link-locali (169.254.x.x, fe80::/10)"
|
||||
|
||||
[admin.settings.security.htmlUrlSecurity.blockCloudMetadata]
|
||||
label = "Blocca endpoint dei metadata cloud"
|
||||
description = "Blocca gli endpoint dei metadata dei provider cloud (169.254.169.254)"
|
||||
label = "Blocca endpoint dei metadati cloud"
|
||||
description = "Blocca gli endpoint dei metadati dei provider cloud (169.254.169.254)"
|
||||
|
||||
[admin.settings.connections]
|
||||
title = "Connessioni"
|
||||
@@ -5211,7 +5211,7 @@ noSearchResults = "Nessuno strumento trovato"
|
||||
noTools = "Nessuno strumento disponibile"
|
||||
|
||||
[onboarding]
|
||||
allTools = "This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools."
|
||||
allTools = "Questo è il pannello <strong>Tutti gli Strumenti</strong>, dove puoi sfogliare e selezionare tutti gli strumenti PDF disponibili."
|
||||
selectCropTool = "Selezioniamo lo strumento <strong>Ritaglia</strong> per mostrare come usarne uno."
|
||||
toolInterface = "Questa è l'interfaccia dello strumento <strong>Ritaglia</strong>. Come vedi, non c'è molto perché non abbiamo ancora aggiunto file PDF su cui lavorare."
|
||||
filesButton = "Il pulsante <strong>File</strong> sulla barra di accesso rapido consente di caricare PDF su cui usare gli strumenti."
|
||||
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
/*! jscanify v1.4.0 | (c) ColonelParrot and other contributors | MIT License */
|
||||
|
||||
(function (global, factory) {
|
||||
typeof exports === "object" && typeof module !== "undefined"
|
||||
? (module.exports = factory())
|
||||
: typeof define === "function" && define.amd
|
||||
? define(factory)
|
||||
: (global.jscanify = factory());
|
||||
})(this, function () {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Calculates distance between two points. Each point must have `x` and `y` property
|
||||
* @param {*} p1 point 1
|
||||
* @param {*} p2 point 2
|
||||
* @returns distance between two points
|
||||
*/
|
||||
function distance(p1, p2) {
|
||||
return Math.hypot(p1.x - p2.x, p1.y - p2.y);
|
||||
}
|
||||
|
||||
class jscanify {
|
||||
constructor() { }
|
||||
|
||||
/**
|
||||
* Finds the contour of the paper within the image
|
||||
* @param {*} img image to process (cv.Mat)
|
||||
* @returns the biggest contour inside the image
|
||||
*/
|
||||
findPaperContour(img) {
|
||||
const imgGray = new cv.Mat();
|
||||
cv.Canny(img, imgGray, 50, 200);
|
||||
|
||||
const imgBlur = new cv.Mat();
|
||||
cv.GaussianBlur(
|
||||
imgGray,
|
||||
imgBlur,
|
||||
new cv.Size(3, 3),
|
||||
0,
|
||||
0,
|
||||
cv.BORDER_DEFAULT
|
||||
);
|
||||
|
||||
const imgThresh = new cv.Mat();
|
||||
cv.threshold(
|
||||
imgBlur,
|
||||
imgThresh,
|
||||
0,
|
||||
255,
|
||||
cv.THRESH_OTSU
|
||||
);
|
||||
|
||||
let contours = new cv.MatVector();
|
||||
let hierarchy = new cv.Mat();
|
||||
|
||||
cv.findContours(
|
||||
imgThresh,
|
||||
contours,
|
||||
hierarchy,
|
||||
cv.RETR_CCOMP,
|
||||
cv.CHAIN_APPROX_SIMPLE
|
||||
);
|
||||
|
||||
let maxArea = 0;
|
||||
let maxContourIndex = -1;
|
||||
for (let i = 0; i < contours.size(); ++i) {
|
||||
let contourArea = cv.contourArea(contours.get(i));
|
||||
if (contourArea > maxArea) {
|
||||
maxArea = contourArea;
|
||||
maxContourIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
const maxContour =
|
||||
maxContourIndex >= 0 ?
|
||||
contours.get(maxContourIndex) :
|
||||
null;
|
||||
|
||||
imgGray.delete();
|
||||
imgBlur.delete();
|
||||
imgThresh.delete();
|
||||
contours.delete();
|
||||
hierarchy.delete();
|
||||
return maxContour;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlights the paper detected inside the image.
|
||||
* @param {*} image image to process
|
||||
* @param {*} options options for highlighting. Accepts `color` and `thickness` parameter
|
||||
* @returns `HTMLCanvasElement` with original image and paper highlighted
|
||||
*/
|
||||
highlightPaper(image, options) {
|
||||
options = options || {};
|
||||
options.color = options.color || "orange";
|
||||
options.thickness = options.thickness || 10;
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const img = cv.imread(image);
|
||||
|
||||
const maxContour = this.findPaperContour(img);
|
||||
cv.imshow(canvas, img);
|
||||
if (maxContour) {
|
||||
const {
|
||||
topLeftCorner,
|
||||
topRightCorner,
|
||||
bottomLeftCorner,
|
||||
bottomRightCorner,
|
||||
} = this.getCornerPoints(maxContour, img);
|
||||
|
||||
if (
|
||||
topLeftCorner &&
|
||||
topRightCorner &&
|
||||
bottomLeftCorner &&
|
||||
bottomRightCorner
|
||||
) {
|
||||
ctx.strokeStyle = options.color;
|
||||
ctx.lineWidth = options.thickness;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(...Object.values(topLeftCorner));
|
||||
ctx.lineTo(...Object.values(topRightCorner));
|
||||
ctx.lineTo(...Object.values(bottomRightCorner));
|
||||
ctx.lineTo(...Object.values(bottomLeftCorner));
|
||||
ctx.lineTo(...Object.values(topLeftCorner));
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
img.delete();
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and undistorts the image detected within the frame.
|
||||
*
|
||||
* Returns `null` if no paper is detected.
|
||||
*
|
||||
* @param {*} image image to process
|
||||
* @param {*} resultWidth desired result paper width
|
||||
* @param {*} resultHeight desired result paper height
|
||||
* @param {*} cornerPoints optional custom corner points, in case automatic corner points are incorrect
|
||||
* @returns `HTMLCanvasElement` containing undistorted image
|
||||
*/
|
||||
extractPaper(image, resultWidth, resultHeight, cornerPoints) {
|
||||
const canvas = document.createElement("canvas");
|
||||
const img = cv.imread(image);
|
||||
const maxContour = cornerPoints ? null : this.findPaperContour(img);
|
||||
|
||||
if(maxContour == null && cornerPoints === undefined){
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
topLeftCorner,
|
||||
topRightCorner,
|
||||
bottomLeftCorner,
|
||||
bottomRightCorner,
|
||||
} = cornerPoints || this.getCornerPoints(maxContour, img);
|
||||
let warpedDst = new cv.Mat();
|
||||
|
||||
let dsize = new cv.Size(resultWidth, resultHeight);
|
||||
let srcTri = cv.matFromArray(4, 1, cv.CV_32FC2, [
|
||||
topLeftCorner.x,
|
||||
topLeftCorner.y,
|
||||
topRightCorner.x,
|
||||
topRightCorner.y,
|
||||
bottomLeftCorner.x,
|
||||
bottomLeftCorner.y,
|
||||
bottomRightCorner.x,
|
||||
bottomRightCorner.y,
|
||||
]);
|
||||
|
||||
let dstTri = cv.matFromArray(4, 1, cv.CV_32FC2, [
|
||||
0,
|
||||
0,
|
||||
resultWidth,
|
||||
0,
|
||||
0,
|
||||
resultHeight,
|
||||
resultWidth,
|
||||
resultHeight,
|
||||
]);
|
||||
|
||||
let M = cv.getPerspectiveTransform(srcTri, dstTri);
|
||||
cv.warpPerspective(
|
||||
img,
|
||||
warpedDst,
|
||||
M,
|
||||
dsize,
|
||||
cv.INTER_LINEAR,
|
||||
cv.BORDER_CONSTANT,
|
||||
new cv.Scalar()
|
||||
);
|
||||
|
||||
cv.imshow(canvas, warpedDst);
|
||||
|
||||
img.delete()
|
||||
warpedDst.delete()
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the corner points of a contour.
|
||||
* @param {*} contour contour from {@link findPaperContour}
|
||||
* @returns object with properties `topLeftCorner`, `topRightCorner`, `bottomLeftCorner`, `bottomRightCorner`, each with `x` and `y` property
|
||||
*/
|
||||
getCornerPoints(contour) {
|
||||
let rect = cv.minAreaRect(contour);
|
||||
const center = rect.center;
|
||||
|
||||
let topLeftCorner;
|
||||
let topLeftCornerDist = 0;
|
||||
|
||||
let topRightCorner;
|
||||
let topRightCornerDist = 0;
|
||||
|
||||
let bottomLeftCorner;
|
||||
let bottomLeftCornerDist = 0;
|
||||
|
||||
let bottomRightCorner;
|
||||
let bottomRightCornerDist = 0;
|
||||
|
||||
for (let i = 0; i < contour.data32S.length; i += 2) {
|
||||
const point = { x: contour.data32S[i], y: contour.data32S[i + 1] };
|
||||
const dist = distance(point, center);
|
||||
if (point.x < center.x && point.y < center.y) {
|
||||
// top left
|
||||
if (dist > topLeftCornerDist) {
|
||||
topLeftCorner = point;
|
||||
topLeftCornerDist = dist;
|
||||
}
|
||||
} else if (point.x > center.x && point.y < center.y) {
|
||||
// top right
|
||||
if (dist > topRightCornerDist) {
|
||||
topRightCorner = point;
|
||||
topRightCornerDist = dist;
|
||||
}
|
||||
} else if (point.x < center.x && point.y > center.y) {
|
||||
// bottom left
|
||||
if (dist > bottomLeftCornerDist) {
|
||||
bottomLeftCorner = point;
|
||||
bottomLeftCornerDist = dist;
|
||||
}
|
||||
} else if (point.x > center.x && point.y > center.y) {
|
||||
// bottom right
|
||||
if (dist > bottomRightCornerDist) {
|
||||
bottomRightCorner = point;
|
||||
bottomRightCornerDist = dist;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
topLeftCorner,
|
||||
topRightCorner,
|
||||
bottomLeftCorner,
|
||||
bottomRightCorner,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return jscanify;
|
||||
});
|
||||
+48
File diff suppressed because one or more lines are too long
@@ -19,27 +19,27 @@ const debug = (message) => {
|
||||
function scanForUsedIcons() {
|
||||
const usedIcons = new Set();
|
||||
const srcDir = path.join(__dirname, '..', 'src');
|
||||
|
||||
|
||||
info('🔍 Scanning codebase for LocalIcon usage...');
|
||||
|
||||
|
||||
if (!fs.existsSync(srcDir)) {
|
||||
console.error('❌ Source directory not found:', srcDir);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
// Recursively scan all .tsx and .ts files
|
||||
function scanDirectory(dir) {
|
||||
const files = fs.readdirSync(dir);
|
||||
|
||||
|
||||
files.forEach(file => {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
scanDirectory(filePath);
|
||||
} else if (file.endsWith('.tsx') || file.endsWith('.ts')) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
|
||||
// Match LocalIcon usage: <LocalIcon icon="icon-name" ...>
|
||||
const localIconMatches = content.match(/<LocalIcon\s+[^>]*icon="([^"]+)"/g);
|
||||
if (localIconMatches) {
|
||||
@@ -51,7 +51,7 @@ function scanForUsedIcons() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Match old material-symbols-rounded spans: <span className="material-symbols-rounded">icon-name</span>
|
||||
const spanMatches = content.match(/<span[^>]*className="[^"]*material-symbols-rounded[^"]*"[^>]*>([^<]+)<\/span>/g);
|
||||
if (spanMatches) {
|
||||
@@ -64,7 +64,7 @@ function scanForUsedIcons() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Match Icon component usage: <Icon icon="material-symbols:icon-name" ...>
|
||||
const iconMatches = content.match(/<Icon\s+[^>]*icon="material-symbols:([^"]+)"/g);
|
||||
if (iconMatches) {
|
||||
@@ -79,12 +79,12 @@ function scanForUsedIcons() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
scanDirectory(srcDir);
|
||||
|
||||
|
||||
const iconArray = Array.from(usedIcons).sort();
|
||||
info(`📋 Found ${iconArray.length} unique icons across codebase`);
|
||||
|
||||
|
||||
return iconArray;
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ async function main() {
|
||||
const existingSet = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
|
||||
const existingIcons = Object.keys(existingSet.icons || {}).sort();
|
||||
const currentIcons = [...usedIcons].sort();
|
||||
|
||||
|
||||
if (JSON.stringify(existingIcons) === JSON.stringify(currentIcons)) {
|
||||
needsRegeneration = false;
|
||||
info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`);
|
||||
@@ -122,7 +122,7 @@ async function main() {
|
||||
|
||||
// Dynamic import of ES module
|
||||
const { getIcons } = await import('@iconify/utils');
|
||||
|
||||
|
||||
// Extract only our used icons from the full set
|
||||
const extractedIcons = getIcons(icons, usedIcons);
|
||||
|
||||
@@ -183,4 +183,4 @@ export default iconSet;
|
||||
main().catch(error => {
|
||||
console.error('❌ Script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Stirling-PDF",
|
||||
"version": "2.1.5",
|
||||
"version": "2.2.0",
|
||||
"identifier": "stirling.pdf.dev",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user