mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5598e9aa09 | ||
|
|
86958786bc | ||
|
|
4c0767a90f | ||
|
|
3deee325a9 | ||
|
|
b82c95ac61 | ||
|
|
1e9af08c00 |
+8
-5
@@ -2,17 +2,20 @@
|
||||
* @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
|
||||
|
||||
# Backend
|
||||
/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh @balazs-szucs
|
||||
/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
|
||||
|
||||
#V1 frontend
|
||||
/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
|
||||
/app/core/src/main/resources/templates/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
|
||||
|
||||
#V2 frontend
|
||||
/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @balazs-szucs
|
||||
/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87 @balazs-szucs
|
||||
/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle
|
||||
|
||||
#V2 docker
|
||||
/docker/backend/** @Frooodle @Ludy87 @DarioGii
|
||||
/docker/backend/** @Frooodle @Ludy87 @DarioGii @Ludy87
|
||||
/docker/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
|
||||
/docker/compose/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
|
||||
|
||||
|
||||
#GHA (All users)
|
||||
/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87 @balazs-szucs
|
||||
/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
"reecebrowne",
|
||||
"DarioGii",
|
||||
"ConnorYoh",
|
||||
"EthanHealy01",
|
||||
"jbrunton96",
|
||||
"balazs-szucs"
|
||||
"EthanHealy01"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gradle" # See documentation for possible values
|
||||
directories:
|
||||
directories:
|
||||
- "/" # Location of package manifests
|
||||
- "/app/common"
|
||||
- "/app/core"
|
||||
|
||||
+1
-1
@@ -200,4 +200,4 @@
|
||||
from_name: "tauri"
|
||||
- name: "license-review-required"
|
||||
color: "EDEDED"
|
||||
description: "This PR requires a license review"
|
||||
description: "This PR requires a license review"
|
||||
@@ -11,16 +11,13 @@ adjusting the format.
|
||||
Usage:
|
||||
python check_language_toml.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
|
||||
"""
|
||||
|
||||
# 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 argparse
|
||||
import glob
|
||||
import os
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import tomllib # Python 3.11+ (stdlib)
|
||||
import tomli_w # For writing TOML files
|
||||
|
||||
@@ -39,8 +36,7 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
|
||||
duplicates = []
|
||||
|
||||
# Load TOML file
|
||||
file_path = Path(file_path)
|
||||
with file_path.open("rb") as file:
|
||||
with open(file_path, "rb") as file:
|
||||
data = tomllib.load(file)
|
||||
|
||||
def process_dict(obj, current_prefix=""):
|
||||
@@ -59,8 +55,8 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
|
||||
return duplicates
|
||||
|
||||
|
||||
# Maximum size for TOML files (e.g., 570 KB)
|
||||
MAX_FILE_SIZE = 570 * 1024
|
||||
# Maximum size for TOML files (e.g., 500 KB)
|
||||
MAX_FILE_SIZE = 500 * 1024
|
||||
|
||||
|
||||
def parse_toml_file(file_path):
|
||||
@@ -69,8 +65,7 @@ def parse_toml_file(file_path):
|
||||
:param file_path: Path to the TOML file.
|
||||
:return: Dictionary with flattened keys.
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
with file_path.open("rb") as file:
|
||||
with open(file_path, "rb") as file:
|
||||
data = tomllib.load(file)
|
||||
|
||||
def flatten_dict(d, parent_key="", sep="."):
|
||||
@@ -113,8 +108,7 @@ def write_toml_file(file_path, updated_properties):
|
||||
"""
|
||||
nested_data = unflatten_dict(updated_properties)
|
||||
|
||||
file_path = Path(file_path)
|
||||
with file_path.open("wb") as file:
|
||||
with open(file_path, "wb") as file:
|
||||
tomli_w.dump(nested_data, file)
|
||||
|
||||
|
||||
@@ -125,23 +119,18 @@ def update_missing_keys(reference_file, file_list, branch=""):
|
||||
:param file_list: List of translation files to update.
|
||||
:param branch: Branch where the files are located.
|
||||
"""
|
||||
reference_file = Path(reference_file)
|
||||
reference_properties = parse_toml_file(reference_file)
|
||||
branch_path = Path(branch) if branch else Path()
|
||||
|
||||
for file_path in file_list:
|
||||
file_path = Path(file_path)
|
||||
language_dir = file_path.parent.name
|
||||
reference_lang_dir = reference_file.parent.name
|
||||
basename_current_file = os.path.basename(os.path.join(branch, file_path))
|
||||
if (
|
||||
language_dir == reference_lang_dir
|
||||
or file_path.suffix != ".toml"
|
||||
or file_path.parents[1].name != "locales"
|
||||
basename_current_file == os.path.basename(reference_file)
|
||||
or not file_path.endswith(".toml")
|
||||
or not os.path.dirname(file_path).endswith("locales")
|
||||
):
|
||||
print(f"Skipping file: {file_path}")
|
||||
continue
|
||||
|
||||
current_properties = parse_toml_file(branch_path / file_path)
|
||||
current_properties = parse_toml_file(os.path.join(branch, file_path))
|
||||
updated_properties = {}
|
||||
|
||||
for ref_key, ref_value in reference_properties.items():
|
||||
@@ -152,7 +141,7 @@ def update_missing_keys(reference_file, file_list, branch=""):
|
||||
# Add missing key with reference value
|
||||
updated_properties[ref_key] = ref_value
|
||||
|
||||
write_toml_file(branch_path / file_path, updated_properties)
|
||||
write_toml_file(os.path.join(branch, file_path), updated_properties)
|
||||
|
||||
|
||||
def check_for_missing_keys(reference_file, file_list, branch):
|
||||
@@ -160,17 +149,14 @@ def check_for_missing_keys(reference_file, file_list, branch):
|
||||
|
||||
|
||||
def read_toml_keys(file_path):
|
||||
file_path = Path(file_path)
|
||||
if file_path.is_file():
|
||||
if os.path.isfile(file_path) and os.path.exists(file_path):
|
||||
return parse_toml_file(file_path)
|
||||
return {}
|
||||
|
||||
|
||||
def check_for_differences(reference_file, file_list, branch, actor):
|
||||
reference_branch = branch
|
||||
reference_file = Path(reference_file)
|
||||
basename_reference_file = reference_file.name
|
||||
branch_path = Path(branch) if branch else Path()
|
||||
basename_reference_file = os.path.basename(reference_file)
|
||||
|
||||
report = []
|
||||
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
|
||||
@@ -184,44 +170,39 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
if len(file_list) == 1:
|
||||
file_arr = file_list[0].split()
|
||||
|
||||
base_dir = Path.cwd() / "frontend" / "public" / "locales"
|
||||
base_dir = os.path.abspath(
|
||||
os.path.join(os.getcwd(), "frontend", "public", "locales")
|
||||
)
|
||||
|
||||
for file_path in file_arr:
|
||||
file_path = Path(file_path)
|
||||
file_normpath = file_path
|
||||
absolute_path = file_normpath.resolve()
|
||||
|
||||
basename_current_file = (branch_path / file_normpath).name
|
||||
locale_dir = file_normpath.parent.name
|
||||
report.append(f"#### 📃 **File Check:** `{locale_dir}/{basename_current_file}`")
|
||||
file_normpath = os.path.normpath(file_path)
|
||||
absolute_path = os.path.abspath(file_normpath)
|
||||
|
||||
# Verify that file is within the expected directory
|
||||
if not absolute_path.is_relative_to(base_dir):
|
||||
has_differences = True
|
||||
report.append(
|
||||
f"\n⚠️ Unsafe file found: `{locale_dir}/{basename_current_file}`\n\n---\n"
|
||||
)
|
||||
continue
|
||||
if not absolute_path.startswith(base_dir):
|
||||
raise ValueError(f"Unsafe file found: {file_normpath}")
|
||||
|
||||
# Verify file size before processing
|
||||
if (branch_path / file_normpath).stat().st_size > MAX_FILE_SIZE:
|
||||
has_differences = True
|
||||
report.append(
|
||||
f"\n⚠️ The file `{locale_dir}/{basename_current_file}` is too large and could pose a security risk.\n\n---\n"
|
||||
if os.path.getsize(os.path.join(branch, file_normpath)) > MAX_FILE_SIZE:
|
||||
raise ValueError(
|
||||
f"The file {file_normpath} is too large and could pose a security risk."
|
||||
)
|
||||
continue
|
||||
|
||||
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":
|
||||
continue
|
||||
|
||||
if (
|
||||
file_normpath.suffix != ".toml"
|
||||
not file_normpath.endswith(".toml")
|
||||
or basename_current_file != "translation.toml"
|
||||
):
|
||||
continue
|
||||
|
||||
only_reference_file = False
|
||||
current_keys = read_toml_keys(branch_path / file_path)
|
||||
report.append(f"#### 📃 **File Check:** `{locale_dir}/{basename_current_file}`")
|
||||
current_keys = read_toml_keys(os.path.join(branch, file_path))
|
||||
reference_key_count = len(reference_keys)
|
||||
current_key_count = len(current_keys)
|
||||
|
||||
@@ -266,13 +247,13 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
else:
|
||||
report.append("2. **Test Status:** ✅ **_Passed_**")
|
||||
|
||||
if find_duplicate_keys(branch_path / file_normpath):
|
||||
if find_duplicate_keys(os.path.join(branch, file_normpath)):
|
||||
has_differences = True
|
||||
output = "\n".join(
|
||||
[
|
||||
f" - `{key}`: first at {first}, duplicate at `{duplicate}`"
|
||||
for key, first, duplicate in find_duplicate_keys(
|
||||
branch_path / file_normpath
|
||||
os.path.join(branch, file_normpath)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -11,10 +11,6 @@ on:
|
||||
allow_fork:
|
||||
description: "Allow deploying fork PR?"
|
||||
required: false
|
||||
type: choice
|
||||
options:
|
||||
- "true"
|
||||
- "false"
|
||||
default: "false"
|
||||
|
||||
permissions:
|
||||
@@ -35,7 +31,7 @@ jobs:
|
||||
pr_ref: ${{ steps.resolve.outputs.ref }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -46,7 +42,7 @@ jobs:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
let prNumber = context.eventName === 'workflow_dispatch'
|
||||
? parseInt(context.payload.inputs.pr, 10)
|
||||
? parseInt(process.env.INPUT_PR, 10)
|
||||
: context.payload.number;
|
||||
|
||||
if (!Number.isInteger(prNumber)) { core.setFailed('Invalid PR number'); return; }
|
||||
@@ -111,7 +107,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -180,7 +176,7 @@ jobs:
|
||||
fetch-depth: 0 # Fetch full history for commit hash detection
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
@@ -189,7 +185,7 @@ jobs:
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
@@ -275,7 +271,7 @@ jobs:
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Deploy V2 to VPS
|
||||
@@ -320,16 +316,16 @@ jobs:
|
||||
ports:
|
||||
- "${V2_PORT}:80" # Frontend port (same as regular PRs)
|
||||
environment:
|
||||
VITE_API_BASE_URL: "http://${{ secrets.NEW_VPS_HOST }}:${BACKEND_PORT}"
|
||||
VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:${BACKEND_PORT}"
|
||||
depends_on:
|
||||
- stirling-pdf-v2-backend
|
||||
restart: on-failure:5
|
||||
EOF
|
||||
|
||||
# Deploy to VPS
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose-v2.yml
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose-v2.yml
|
||||
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
|
||||
# Create V2 PR-specific directories
|
||||
mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs}
|
||||
|
||||
@@ -379,7 +375,7 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`;
|
||||
const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${v2Port}`;
|
||||
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
|
||||
|
||||
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
|
||||
@@ -406,7 +402,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -454,12 +450,12 @@ jobs:
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Cleanup V2 deployment
|
||||
run: |
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << 'ENDSSH'
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
|
||||
if [ -d "/stirling/V2-PR-${{ github.event.pull_request.number }}" ]; then
|
||||
echo "Found V2 PR directory, proceeding with cleanup..."
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ jobs:
|
||||
github.event.comment.user.login == 'frooodle' ||
|
||||
github.event.comment.user.login == 'sf298' ||
|
||||
github.event.comment.user.login == 'Ludy87' ||
|
||||
github.event.comment.user.login == 'balazs-szucs' ||
|
||||
github.event.comment.user.login == 'LaserKaspar' ||
|
||||
github.event.comment.user.login == 'sbplat' ||
|
||||
github.event.comment.user.login == 'reecebrowne' ||
|
||||
github.event.comment.user.login == 'DarioGii' ||
|
||||
github.event.comment.user.login == 'EthanHealy01' ||
|
||||
@@ -40,7 +41,7 @@ jobs:
|
||||
enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -128,7 +129,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -168,7 +169,7 @@ jobs:
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
@@ -189,7 +190,7 @@ jobs:
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Deploy to VPS
|
||||
@@ -251,9 +252,9 @@ jobs:
|
||||
EOF
|
||||
|
||||
# Then copy the file and execute commands
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose.yml
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose.yml
|
||||
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
|
||||
# Create PR-specific directories
|
||||
mkdir -p /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/{data,config,logs}
|
||||
|
||||
@@ -335,7 +336,7 @@ jobs:
|
||||
const prNumber = ${{ needs.check-comment.outputs.pr_number }};
|
||||
const securityStatus = process.env.security_status || "Security Disabled";
|
||||
|
||||
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${prNumber}`;
|
||||
const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${prNumber}`;
|
||||
const commentBody = `## 🚀 PR Test Deployment\n\n` +
|
||||
`Your PR has been deployed for testing!\n\n` +
|
||||
`🔗 **Test URL:** [${deploymentUrl}](${deploymentUrl})\n` +
|
||||
@@ -362,7 +363,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
SERVER_IP: ${{ secrets.NEW_VPS_IP }} # Add this to your GitHub secrets
|
||||
SERVER_IP: ${{ secrets.VPS_IP }} # Add this to your GitHub secrets
|
||||
CLEANUP_PERFORMED: "false" # Add flag to track if cleanup occurred
|
||||
|
||||
jobs:
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -100,14 +100,14 @@ jobs:
|
||||
if: steps.remove-label-comment.outputs.present == 'true'
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Cleanup PR deployment
|
||||
if: steps.remove-label-comment.outputs.present == 'true'
|
||||
id: cleanup
|
||||
run: |
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << 'ENDSSH'
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
|
||||
if [ -d "/stirling/PR-${{ github.event.pull_request.number }}" ]; then
|
||||
echo "Found PR directory, proceeding with cleanup..."
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
+12
-24
@@ -32,7 +32,7 @@ jobs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
spring-security: [true, false]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -97,7 +97,6 @@ jobs:
|
||||
with:
|
||||
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
|
||||
path: |
|
||||
app/**/build/reports/jacoco/test
|
||||
app/**/build/reports/tests/
|
||||
app/**/build/test-results/
|
||||
app/**/build/reports/problems/
|
||||
@@ -105,24 +104,13 @@ jobs:
|
||||
retention-days: 3
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Add coverage to PR with spring security ${{ matrix.spring-security }} and JDK ${{ matrix.jdk-version }}
|
||||
id: jacoco
|
||||
uses: madrapps/jacoco-report@50d3aff4548aa991e6753342d9ba291084e63848 # v1.7.2
|
||||
with:
|
||||
paths: |
|
||||
${{ github.workspace }}/**/build/reports/jacoco/test/jacocoTestReport.xml
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
min-coverage-overall: 10
|
||||
min-coverage-changed-files: 0
|
||||
comment-type: summary
|
||||
|
||||
check-generateOpenApiDocs:
|
||||
if: needs.files-changed.outputs.openapi == 'true'
|
||||
needs: [files-changed]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -152,7 +140,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -186,7 +174,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -231,7 +219,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -245,7 +233,7 @@ jobs:
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Install Docker Compose
|
||||
run: |
|
||||
@@ -286,7 +274,7 @@ jobs:
|
||||
artifact-suffix: Dockerfile.fat
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -313,11 +301,11 @@ jobs:
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Build ${{ matrix.docker-rev }}
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
@@ -326,8 +314,8 @@ jobs:
|
||||
context: .
|
||||
file: ./${{ matrix.docker-rev }}
|
||||
push: false
|
||||
cache-from: type=gha,scope=${{ matrix.artifact-suffix }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.artifact-suffix }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
@@ -7,8 +7,6 @@ on:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- "frontend/public/locales/*/translation.toml"
|
||||
- ".github/scripts/check_language_toml.py"
|
||||
- ".github/workflows/check_toml.yml"
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
concurrency:
|
||||
@@ -27,7 +25,7 @@ jobs:
|
||||
pull-requests: write # Allow writing to pull requests
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Get commit hashes for frontend and backend
|
||||
id: commit-hashes
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
chmod 600 ../private.key
|
||||
|
||||
- name: Deploy to VPS on port 3000
|
||||
@@ -158,17 +158,17 @@ jobs:
|
||||
ports:
|
||||
- "3000:80"
|
||||
environment:
|
||||
VITE_API_BASE_URL: "http://${{ secrets.NEW_VPS_HOST }}:13000"
|
||||
VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:13000"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: on-failure:5
|
||||
EOF
|
||||
|
||||
# Copy to remote with unique name
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/$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.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
|
||||
mkdir -p /stirling/V2/{data,config,logs}
|
||||
mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml
|
||||
cd /stirling/V2
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
licenses-backend: ${{ steps.changes.outputs.licenses-backend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -312,7 +312,7 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
file_suffix: "-server"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -268,7 +268,7 @@ jobs:
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
run: npm install
|
||||
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
@@ -530,7 +530,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
name: Push Docker Image - V2 Branch
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- V2-master
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
# or a pull request is updated.
|
||||
# It helps to save resources and time by ensuring that only the latest commit is built and tested
|
||||
# This is particularly useful for long-running jobs that may take a while to complete.
|
||||
# The `group` is set to a combination of the workflow name, event name, and branch name.
|
||||
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
|
||||
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
push:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-24.04-8core
|
||||
permissions:
|
||||
packages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate tags for latest (V2-master branch - production)
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Generate tags for latest (V1_V2_merge branch - test)
|
||||
id: meta-test
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref == 'refs/heads/V1_V2_merge'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Unified Dockerfile (latest variant)
|
||||
id: build-push-latest
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta.outputs.tags || steps.meta-test.outputs.tags }}
|
||||
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta.outputs.labels || steps.meta-test.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign regular images
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-latest.outputs.digest }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --yes \
|
||||
--key env://COSIGN_PRIVATE_KEY \
|
||||
"${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags for latest-fat (V2-master branch - production)
|
||||
id: meta-fat
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
|
||||
type=raw,value=latest-fat
|
||||
|
||||
- name: Generate tags for latest-fat (V1_V2_merge branch - test)
|
||||
id: meta-fat-test
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref == 'refs/heads/V1_V2_merge'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
|
||||
type=raw,value=latest-fat
|
||||
|
||||
- name: Build and push Unified Dockerfile (fat variant)
|
||||
id: build-push-fat
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile.fat
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-fat.outputs.tags || steps.meta-fat-test.outputs.tags }}
|
||||
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-fat.outputs.labels || steps.meta-fat-test.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign fat images
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-fat.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags for ultra-lite (V2-master branch - production)
|
||||
id: meta-lite
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
|
||||
type=raw,value=latest-ultra-lite
|
||||
|
||||
- name: Generate tags for ultra-lite (V1_V2_merge branch - test)
|
||||
id: meta-lite-test
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref == 'refs/heads/V1_V2_merge'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
|
||||
type=raw,value=latest-ultra-lite
|
||||
|
||||
- name: Build and push Unified Dockerfile (ultra-lite variant)
|
||||
id: build-push-lite
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile.ultra-lite
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-lite.outputs.tags || steps.meta-lite-test.outputs.tags }}
|
||||
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-lite.outputs.labels || steps.meta-lite-test.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign ultra-lite images
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-lite.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
@@ -6,8 +6,6 @@ on:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
- V2-master
|
||||
- testMain
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
@@ -27,13 +25,13 @@ permissions:
|
||||
jobs:
|
||||
push:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-24.04-8core
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -49,20 +47,26 @@ jobs:
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Run Gradle Command
|
||||
run: ./gradlew clean build
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/master'
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
@@ -83,9 +87,10 @@ jobs:
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate tags for latest
|
||||
- name: Generate tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
@@ -93,13 +98,13 @@ jobs:
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/testMain' }}
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
|
||||
- name: Build and push Unified Dockerfile (latest variant)
|
||||
id: build-push-latest
|
||||
- name: Build and push main Dockerfile
|
||||
id: build-push-regular
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
@@ -115,9 +120,9 @@ jobs:
|
||||
sbom: true
|
||||
|
||||
- name: Sign regular images
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
if: github.ref == 'refs/heads/master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-latest.outputs.digest }}
|
||||
DIGEST: ${{ steps.build-push-regular.outputs.digest }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
@@ -128,10 +133,10 @@ jobs:
|
||||
"${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags for latest-fat
|
||||
id: meta-fat
|
||||
- name: Generate tags ultra-lite
|
||||
id: meta2
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
@@ -139,13 +144,43 @@ jobs:
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
|
||||
- name: Build and push Unified Dockerfile (fat variant)
|
||||
- name: Build and push Dockerfile-ultra-lite
|
||||
id: build-push-lite
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile.ultra-lite
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ steps.meta2.outputs.tags }}
|
||||
labels: ${{ steps.meta2.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Generate tags fat
|
||||
id: meta3
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push main Dockerfile fat
|
||||
id: build-push-fat
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
@@ -153,62 +188,18 @@ jobs:
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ steps.meta-fat.outputs.tags }}
|
||||
labels: ${{ steps.meta-fat.outputs.labels }}
|
||||
tags: ${{ steps.meta3.outputs.tags }}
|
||||
labels: ${{ steps.meta3.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign fat images
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
if: github.ref == 'refs/heads/master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-fat.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags for ultra-lite
|
||||
id: meta-lite
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
|
||||
- name: Build and push Unified Dockerfile (ultra-lite variant)
|
||||
id: build-push-lite
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile.ultra-lite
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ steps.meta-lite.outputs.tags }}
|
||||
labels: ${{ steps.meta-lite.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign ultra-lite images
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-lite.outputs.tags }}
|
||||
TAGS: ${{ steps.meta3.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -75,6 +75,6 @@ jobs:
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v3.29.5
|
||||
uses: github/codeql-action/upload-sarif@fdbfb4d2750291e159f0156def62b853c2798ca2 # v3.29.5
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -174,7 +174,7 @@ jobs:
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
run: npm install
|
||||
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
@@ -507,6 +507,7 @@ jobs:
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
|
||||
else
|
||||
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
|
||||
@@ -594,8 +595,8 @@ jobs:
|
||||
fi
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
echo "Checking for macOS artifacts..."
|
||||
find . -name "*.dmg" | head -5
|
||||
if [ $(find . -name "*.dmg" | wc -l) -eq 0 ]; then
|
||||
find . -name "*.dmg" -o -name "*.app" | head -5
|
||||
if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then
|
||||
echo "❌ No macOS artifacts found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Deploy to VPS
|
||||
@@ -107,9 +107,9 @@ jobs:
|
||||
restart: on-failure:5
|
||||
EOF
|
||||
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose.yml
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose.yml
|
||||
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << EOF
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << EOF
|
||||
mkdir -p /stirling/test-${{ github.sha }}/{data,config,logs}
|
||||
mv /tmp/docker-compose.yml /stirling/test-${{ github.sha }}/docker-compose.yml
|
||||
cd /stirling/test-${{ github.sha }}
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
npm install
|
||||
npm run build
|
||||
npm install dashcam-chrome --save
|
||||
Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.NEW_VPS_HOST }}:1337"
|
||||
Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.VPS_HOST }}:1337"
|
||||
Start-Sleep -Seconds 20
|
||||
prompt: |
|
||||
1. /run testing/testdriver/test.yml
|
||||
@@ -176,20 +176,20 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Cleanup deployment
|
||||
if: always()
|
||||
run: |
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << EOF
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << EOF
|
||||
cd /stirling/test-${{ github.sha }}
|
||||
docker-compose down
|
||||
cd /stirling
|
||||
|
||||
@@ -37,16 +37,12 @@ dependencies {
|
||||
api 'com.drewnoakes:metadata-extractor:2.19.0' // Image metadata extractor
|
||||
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
|
||||
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
|
||||
api "org.apache.pdfbox:pdfbox-io:$pdfboxVersion"
|
||||
api "org.apache.pdfbox:xmpbox:$pdfboxVersion"
|
||||
api "org.apache.pdfbox:preflight:$pdfboxVersion"
|
||||
api 'com.github.junrar:junrar:7.5.7' // RAR archive support for CBR files
|
||||
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
|
||||
api 'org.snakeyaml:snakeyaml-engine:2.10'
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.14"
|
||||
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
|
||||
api 'org.simplejavamail:simple-java-mail:8.12.6'
|
||||
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
|
||||
api 'jakarta.mail:jakarta.mail-api:2.1.5'
|
||||
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
|
||||
}
|
||||
|
||||
+8
-11
@@ -94,19 +94,16 @@ public class RuntimePathConfig {
|
||||
defaultSOfficePath, operations != null ? operations.getSoffice() : null);
|
||||
|
||||
// Initialize Tesseract data path
|
||||
// Priority: config setting > TESSDATA_PREFIX env var > default path
|
||||
String defaultTessDataPath =
|
||||
isDocker ? "/usr/share/tesseract-ocr/5/tessdata" : "/usr/share/tessdata";
|
||||
|
||||
String tessPath = system.getTessdataDir();
|
||||
String tessdataPrefix = java.lang.System.getenv("TESSDATA_PREFIX");
|
||||
String defaultPath = "/usr/share/tesseract-ocr/5/tessdata";
|
||||
|
||||
if (tessPath != null && !tessPath.isEmpty()) {
|
||||
this.tessDataPath = tessPath;
|
||||
} else if (tessdataPrefix != null && !tessdataPrefix.isEmpty()) {
|
||||
this.tessDataPath = tessdataPrefix;
|
||||
} else {
|
||||
this.tessDataPath = defaultPath;
|
||||
}
|
||||
String tessdataDir = java.lang.System.getenv("TESSDATA_PREFIX");
|
||||
|
||||
this.tessDataPath =
|
||||
resolvePath(
|
||||
defaultTessDataPath,
|
||||
(tessPath != null && !tessPath.isEmpty()) ? tessPath : tessdataDir);
|
||||
log.info("Using Tesseract data path: {}", this.tessDataPath);
|
||||
}
|
||||
|
||||
|
||||
+4
-118
@@ -61,7 +61,6 @@ public class ApplicationProperties {
|
||||
private AutomaticallyGenerated automaticallyGenerated = new AutomaticallyGenerated();
|
||||
|
||||
private Mail mail = new Mail();
|
||||
private Telegram telegram = new Telegram();
|
||||
|
||||
private Premium premium = new Premium();
|
||||
|
||||
@@ -419,15 +418,6 @@ public class ApplicationProperties {
|
||||
|
||||
// 'https://app.example.com'). If not set, falls back to backendUrl.
|
||||
private boolean enableMobileScanner = false; // Enable mobile phone QR code upload feature
|
||||
private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings();
|
||||
|
||||
@Data
|
||||
public static class MobileScannerSettings {
|
||||
private boolean convertToPdf = true; // Whether to automatically convert images to PDF
|
||||
private String imageResolution = "full"; // Options: "full", "reduced"
|
||||
private String pageFormat = "A4"; // Options: "keep", "A4", "letter"
|
||||
private boolean stretchToFit = false; // Whether to stretch image to fill page
|
||||
}
|
||||
|
||||
public boolean isAnalyticsEnabled() {
|
||||
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
|
||||
@@ -552,10 +542,10 @@ public class ApplicationProperties {
|
||||
@Override
|
||||
public String toString() {
|
||||
return """
|
||||
Driver {
|
||||
driverName='%s'
|
||||
}
|
||||
"""
|
||||
Driver {
|
||||
driverName='%s'
|
||||
}
|
||||
"""
|
||||
.formatted(driverName);
|
||||
}
|
||||
}
|
||||
@@ -608,7 +598,6 @@ public class ApplicationProperties {
|
||||
private boolean ssoAutoLogin;
|
||||
private CustomMetadata customMetadata = new CustomMetadata();
|
||||
|
||||
@Deprecated
|
||||
@Data
|
||||
public static class CustomMetadata {
|
||||
private boolean autoUpdateMetadata;
|
||||
@@ -616,23 +605,16 @@ public class ApplicationProperties {
|
||||
private String creator;
|
||||
private String producer;
|
||||
|
||||
@Deprecated
|
||||
public String getCreator() {
|
||||
return creator == null || creator.trim().isEmpty() ? "Stirling-PDF" : creator;
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
public String getProducer() {
|
||||
return producer == null || producer.trim().isEmpty() ? "Stirling-PDF" : producer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mail server configuration properties.
|
||||
*
|
||||
* @since 0.46.1
|
||||
*/
|
||||
@Data
|
||||
public static class Mail {
|
||||
private boolean enabled;
|
||||
@@ -655,102 +637,6 @@ public class ApplicationProperties {
|
||||
private Boolean sslCheckServerIdentity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram bot configuration properties.
|
||||
*
|
||||
* @since 2.2.x
|
||||
*/
|
||||
@Data
|
||||
public static class Telegram {
|
||||
private Boolean enabled = false;
|
||||
@ToString.Exclude private String botToken;
|
||||
private String botUsername;
|
||||
private String pipelineInboxFolder = "telegram";
|
||||
private Boolean customFolderSuffix = false;
|
||||
private Boolean enableAllowUserIDs = false;
|
||||
private List<Long> allowUserIDs = new ArrayList<>();
|
||||
private Boolean enableAllowChannelIDs = false;
|
||||
private List<Long> allowChannelIDs = new ArrayList<>();
|
||||
private long processingTimeoutSeconds = 180;
|
||||
private long pollingIntervalMillis = 2000;
|
||||
private Feedback feedback = new Feedback();
|
||||
|
||||
/**
|
||||
* Configuration for feedback messages sent by the Telegram bot.
|
||||
*
|
||||
* @since 2.2.x
|
||||
*/
|
||||
@Data
|
||||
public static class Feedback {
|
||||
private Channel channel = new Channel();
|
||||
private User user = new User();
|
||||
|
||||
/**
|
||||
* Channel-specific feedback settings.
|
||||
*
|
||||
* @since 2.2.x
|
||||
*/
|
||||
@Data
|
||||
public static class Channel {
|
||||
/**
|
||||
* Set to {@code false} to hide/suppress "no valid document" feedback messages to
|
||||
* the channel (to avoid spam).
|
||||
*/
|
||||
private Boolean noValidDocument = true;
|
||||
|
||||
/**
|
||||
* Set to {@code false} to hide/suppress generic error feedback messages to the
|
||||
* channel (to avoid spam).
|
||||
*/
|
||||
private Boolean errorMessage = true;
|
||||
|
||||
/**
|
||||
* Set to {@code false} to hide/suppress processing error feedback messages to the
|
||||
* channel (to avoid spam).
|
||||
*/
|
||||
private Boolean errorProcessing = true;
|
||||
|
||||
/**
|
||||
* Set to {@code false} to hide/suppress "processing" feedback messages to the
|
||||
* channel (to avoid spam).
|
||||
*/
|
||||
private Boolean processing = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-specific feedback settings.
|
||||
*
|
||||
* @since 2.2.x
|
||||
*/
|
||||
@Data
|
||||
public static class User {
|
||||
/**
|
||||
* Set to {@code false} to hide/suppress "no valid document" feedback messages to
|
||||
* users (to avoid spam).
|
||||
*/
|
||||
private Boolean noValidDocument = true;
|
||||
|
||||
/**
|
||||
* Set to {@code false} to hide/suppress generic error feedback messages to users
|
||||
* (to avoid spam).
|
||||
*/
|
||||
private Boolean errorMessage = true;
|
||||
|
||||
/**
|
||||
* Set to {@code false} to hide/suppress processing error feedback messages to users
|
||||
* (to avoid spam).
|
||||
*/
|
||||
private Boolean errorProcessing = true;
|
||||
|
||||
/**
|
||||
* Set to {@code false} to hide/suppress "processing" feedback messages to users (to
|
||||
* avoid spam).
|
||||
*/
|
||||
private Boolean processing = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Premium {
|
||||
private boolean enabled;
|
||||
|
||||
@@ -129,12 +129,7 @@ public class CbrUtils {
|
||||
new PDRectangle(pdImage.getWidth(), pdImage.getHeight()));
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true,
|
||||
true)) {
|
||||
new PDPageContentStream(document, page)) {
|
||||
contentStream.drawImage(pdImage, 0, 0);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
|
||||
@@ -97,12 +97,7 @@ public class CbzUtils {
|
||||
new PDRectangle(pdImage.getWidth(), pdImage.getHeight()));
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true,
|
||||
true)) {
|
||||
new PDPageContentStream(document, page)) {
|
||||
contentStream.drawImage(pdImage, 0, 0);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
|
||||
@@ -1,417 +1,651 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.ZoneOffset;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.Properties;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.simplejavamail.api.email.AttachmentResource;
|
||||
import org.simplejavamail.api.email.Email;
|
||||
import org.simplejavamail.api.email.Recipient;
|
||||
import org.simplejavamail.converter.EmailConverter;
|
||||
|
||||
import jakarta.activation.DataSource;
|
||||
import jakarta.mail.Message.RecipientType;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.api.converters.EmlToPdfRequest;
|
||||
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
public class EmlParser {
|
||||
|
||||
// Configuration constants
|
||||
private final int DEFAULT_MAX_ATTACHMENT_MB = 10;
|
||||
private final long MAX_SIZE_ESTIMATION_BYTES = 500L * 1024 * 1024; // 500MB
|
||||
private static volatile Boolean jakartaMailAvailable = null;
|
||||
private static volatile Method mimeUtilityDecodeTextMethod = null;
|
||||
private static volatile boolean mimeUtilityChecked = false;
|
||||
|
||||
// Message constants
|
||||
private final String NO_CONTENT_MESSAGE = "Email content could not be parsed";
|
||||
private final String ATTACHMENT_PREFIX = "attachment-";
|
||||
private static final Pattern MIME_ENCODED_PATTERN =
|
||||
RegexPatternUtils.getInstance().getMimeEncodedWordPattern();
|
||||
|
||||
public EmailContent extractEmailContent(
|
||||
private static final String DISPOSITION_ATTACHMENT = "attachment";
|
||||
private static final String TEXT_PLAIN = MediaType.TEXT_PLAIN_VALUE;
|
||||
private static final String TEXT_HTML = MediaType.TEXT_HTML_VALUE;
|
||||
private static final String MULTIPART_PREFIX = "multipart/";
|
||||
|
||||
private static final String HEADER_CONTENT_TYPE = "content-type:";
|
||||
private static final String HEADER_CONTENT_DISPOSITION = "content-disposition:";
|
||||
private static final String HEADER_CONTENT_TRANSFER_ENCODING = "content-transfer-encoding:";
|
||||
private static final String HEADER_CONTENT_ID = "Content-ID";
|
||||
private static final String HEADER_SUBJECT = "Subject:";
|
||||
private static final String HEADER_FROM = "From:";
|
||||
private static final String HEADER_TO = "To:";
|
||||
private static final String HEADER_CC = "Cc:";
|
||||
private static final String HEADER_BCC = "Bcc:";
|
||||
private static final String HEADER_DATE = "Date:";
|
||||
|
||||
private static synchronized boolean isJakartaMailAvailable() {
|
||||
if (jakartaMailAvailable == null) {
|
||||
try {
|
||||
Class.forName("jakarta.mail.internet.MimeMessage");
|
||||
Class.forName("jakarta.mail.Session");
|
||||
Class.forName("jakarta.mail.internet.MimeUtility");
|
||||
Class.forName("jakarta.mail.internet.MimePart");
|
||||
Class.forName("jakarta.mail.internet.MimeMultipart");
|
||||
Class.forName("jakarta.mail.Multipart");
|
||||
Class.forName("jakarta.mail.Part");
|
||||
jakartaMailAvailable = true;
|
||||
} catch (ClassNotFoundException e) {
|
||||
jakartaMailAvailable = false;
|
||||
}
|
||||
}
|
||||
return jakartaMailAvailable;
|
||||
}
|
||||
|
||||
public static EmailContent extractEmailContent(
|
||||
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer)
|
||||
throws IOException {
|
||||
|
||||
EmlProcessingUtils.validateEmlInput(emlBytes);
|
||||
|
||||
Email email = parseEmail(emlBytes);
|
||||
return buildEmailContent(email, request, customHtmlSanitizer);
|
||||
}
|
||||
|
||||
private Email parseEmail(byte[] emlBytes) throws IOException {
|
||||
boolean isMsgFile = EmlProcessingUtils.isMsgFile(emlBytes);
|
||||
try (ByteArrayInputStream input = new ByteArrayInputStream(emlBytes)) {
|
||||
Email email;
|
||||
if (isMsgFile) {
|
||||
try {
|
||||
email = EmailConverter.outlookMsgToEmail(input);
|
||||
} catch (Exception e) {
|
||||
// OLE2 magic bytes match but parsing failed - might be DOC/XLS/other OLE2 file
|
||||
throw new IOException(
|
||||
"The file appears to be an OLE2 file (MSG/DOC/XLS) but could not be "
|
||||
+ "parsed as an Outlook email. Ensure it is a valid .msg file: "
|
||||
+ e.getMessage(),
|
||||
e);
|
||||
}
|
||||
} else {
|
||||
email = EmailConverter.emlToEmail(input);
|
||||
}
|
||||
|
||||
return email;
|
||||
} catch (IOException e) {
|
||||
throw e; // Re-throw IOException as-is
|
||||
} catch (Exception e) {
|
||||
throw new IOException(
|
||||
String.format(
|
||||
"Failed to parse EML file with Simple Java Mail: %s", e.getMessage()),
|
||||
e);
|
||||
if (isJakartaMailAvailable()) {
|
||||
return extractEmailContentAdvanced(emlBytes, request, customHtmlSanitizer);
|
||||
} else {
|
||||
return extractEmailContentBasic(emlBytes, customHtmlSanitizer);
|
||||
}
|
||||
}
|
||||
|
||||
private EmailContent buildEmailContent(
|
||||
Email email, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer)
|
||||
throws IOException {
|
||||
|
||||
private static EmailContent extractEmailContentBasic(
|
||||
byte[] emlBytes, CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
String emlContent = new String(emlBytes, StandardCharsets.UTF_8);
|
||||
EmailContent content = new EmailContent();
|
||||
content.setSubject(defaultString(email.getSubject()));
|
||||
content.setFrom(formatRecipient(email.getFromRecipient()));
|
||||
content.setTo(formatRecipients(email.getRecipients(), RecipientType.TO));
|
||||
content.setCc(formatRecipients(email.getRecipients(), RecipientType.CC));
|
||||
content.setBcc(formatRecipients(email.getRecipients(), RecipientType.BCC));
|
||||
|
||||
Date sentDate = email.getSentDate();
|
||||
if (sentDate != null) {
|
||||
// Use UTC for consistent timezone handling across deployments
|
||||
content.setDate(ZonedDateTime.ofInstant(sentDate.toInstant(), ZoneOffset.UTC));
|
||||
content.setSubject(extractBasicHeader(emlContent, HEADER_SUBJECT));
|
||||
content.setFrom(extractBasicHeader(emlContent, HEADER_FROM));
|
||||
content.setTo(extractBasicHeader(emlContent, HEADER_TO));
|
||||
content.setCc(extractBasicHeader(emlContent, HEADER_CC));
|
||||
content.setBcc(extractBasicHeader(emlContent, HEADER_BCC));
|
||||
|
||||
String dateStr = extractBasicHeader(emlContent, HEADER_DATE);
|
||||
if (!dateStr.isEmpty()) {
|
||||
content.setDateString(dateStr);
|
||||
}
|
||||
|
||||
String htmlBody = email.getHTMLText();
|
||||
if (customHtmlSanitizer != null && htmlBody != null) {
|
||||
htmlBody = customHtmlSanitizer.sanitize(htmlBody);
|
||||
}
|
||||
content.setHtmlBody(htmlBody);
|
||||
|
||||
String textBody = email.getPlainText();
|
||||
if (customHtmlSanitizer != null && textBody != null) {
|
||||
textBody = customHtmlSanitizer.sanitize(textBody);
|
||||
}
|
||||
content.setTextBody(textBody);
|
||||
|
||||
if (isBlank(content.getHtmlBody()) && isBlank(content.getTextBody())) {
|
||||
content.setTextBody(NO_CONTENT_MESSAGE);
|
||||
String htmlBody = extractHtmlBody(emlContent);
|
||||
if (htmlBody != null) {
|
||||
content.setHtmlBody(htmlBody);
|
||||
} else {
|
||||
String textBody = extractTextBody(emlContent);
|
||||
content.setTextBody(textBody != null ? textBody : "Email content could not be parsed");
|
||||
}
|
||||
|
||||
List<EmailAttachment> attachments = new ArrayList<>();
|
||||
attachments.addAll(mapResources(email.getEmbeddedImages(), request, true));
|
||||
attachments.addAll(mapResources(email.getAttachments(), request, false));
|
||||
content.setAttachments(attachments);
|
||||
content.setAttachmentCount(attachments.size());
|
||||
content.getAttachments().addAll(extractAttachmentsBasic(emlContent));
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
private List<EmailAttachment> mapResources(
|
||||
List<AttachmentResource> resources, EmlToPdfRequest request, boolean embedded)
|
||||
throws IOException {
|
||||
private static EmailContent extractEmailContentAdvanced(
|
||||
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
try {
|
||||
Class<?> sessionClass = Class.forName("jakarta.mail.Session");
|
||||
Class<?> mimeMessageClass = Class.forName("jakarta.mail.internet.MimeMessage");
|
||||
|
||||
if (resources == null || resources.isEmpty()) {
|
||||
return List.of();
|
||||
Method getDefaultInstance =
|
||||
sessionClass.getMethod("getDefaultInstance", Properties.class);
|
||||
Object session = getDefaultInstance.invoke(null, new Properties());
|
||||
|
||||
Class<?>[] constructorArgs = new Class<?>[] {sessionClass, InputStream.class};
|
||||
Constructor<?> mimeMessageConstructor =
|
||||
mimeMessageClass.getConstructor(constructorArgs);
|
||||
Object message =
|
||||
mimeMessageConstructor.newInstance(session, new ByteArrayInputStream(emlBytes));
|
||||
|
||||
return extractFromMimeMessage(message, request, customHtmlSanitizer);
|
||||
|
||||
} catch (ReflectiveOperationException e) {
|
||||
return extractEmailContentBasic(emlBytes, customHtmlSanitizer);
|
||||
}
|
||||
|
||||
List<EmailAttachment> mapped = new ArrayList<>(resources.size());
|
||||
int unnamedCounter = 0; // Start at 0, increment before use
|
||||
|
||||
for (AttachmentResource resource : resources) {
|
||||
if (resource == null) {
|
||||
continue; // Skip null resources early
|
||||
}
|
||||
|
||||
// Pre-determine if this resource needs a generated filename
|
||||
boolean needsGeneratedName = !embedded && needsGeneratedFilename(resource);
|
||||
|
||||
if (needsGeneratedName) {
|
||||
unnamedCounter++;
|
||||
}
|
||||
|
||||
EmailAttachment attachment =
|
||||
toEmailAttachment(resource, request, embedded, unnamedCounter);
|
||||
if (attachment != null) {
|
||||
mapped.add(attachment);
|
||||
}
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
/** Checks if a resource needs a generated filename (has no usable name). */
|
||||
private boolean needsGeneratedFilename(AttachmentResource resource) {
|
||||
if (resource == null) {
|
||||
return false;
|
||||
private static EmailContent extractFromMimeMessage(
|
||||
Object message, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
EmailContent content = new EmailContent();
|
||||
|
||||
try {
|
||||
Class<?> messageClass = message.getClass();
|
||||
|
||||
Method getSubject = messageClass.getMethod("getSubject");
|
||||
String subject = (String) getSubject.invoke(message);
|
||||
content.setSubject(subject != null ? safeMimeDecode(subject) : "No Subject");
|
||||
|
||||
Method getFrom = messageClass.getMethod("getFrom");
|
||||
Object[] fromAddresses = (Object[]) getFrom.invoke(message);
|
||||
content.setFrom(buildAddressString(fromAddresses));
|
||||
|
||||
extractRecipients(message, messageClass, content);
|
||||
|
||||
Method getSentDate = messageClass.getMethod("getSentDate");
|
||||
Date legacyDate = (Date) getSentDate.invoke(message);
|
||||
if (legacyDate != null) {
|
||||
content.setDate(
|
||||
ZonedDateTime.ofInstant(legacyDate.toInstant(), ZoneId.systemDefault()));
|
||||
}
|
||||
|
||||
Method getContent = messageClass.getMethod("getContent");
|
||||
Object messageContent = getContent.invoke(message);
|
||||
|
||||
processMessageContent(message, messageContent, content, request, customHtmlSanitizer);
|
||||
|
||||
} catch (ReflectiveOperationException | RuntimeException e) {
|
||||
content.setSubject("Email Conversion");
|
||||
content.setFrom("Unknown");
|
||||
content.setTo("Unknown");
|
||||
content.setCc("");
|
||||
content.setBcc("");
|
||||
content.setTextBody("Email content could not be parsed with advanced processing");
|
||||
}
|
||||
String resourceName = resource.getName();
|
||||
if (!isBlank(resourceName)) {
|
||||
return false;
|
||||
}
|
||||
DataSource dataSource = resource.getDataSource();
|
||||
return isBlank(dataSource.getName());
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
private EmailAttachment toEmailAttachment(
|
||||
AttachmentResource resource, EmlToPdfRequest request, boolean embedded, int counter)
|
||||
throws IOException {
|
||||
private static void extractRecipients(
|
||||
Object message, Class<?> messageClass, EmailContent content) {
|
||||
try {
|
||||
Method getRecipients =
|
||||
messageClass.getMethod(
|
||||
"getRecipients", Class.forName("jakarta.mail.Message$RecipientType"));
|
||||
Class<?> recipientTypeClass = Class.forName("jakarta.mail.Message$RecipientType");
|
||||
|
||||
if (resource == null) {
|
||||
return null;
|
||||
}
|
||||
Object toType = recipientTypeClass.getField("TO").get(null);
|
||||
Object[] toRecipients = (Object[]) getRecipients.invoke(message, toType);
|
||||
content.setTo(buildAddressString(toRecipients));
|
||||
|
||||
EmailAttachment attachment = new EmailAttachment();
|
||||
attachment.setEmbedded(embedded);
|
||||
Object ccType = recipientTypeClass.getField("CC").get(null);
|
||||
Object[] ccRecipients = (Object[]) getRecipients.invoke(message, ccType);
|
||||
content.setCc(buildAddressString(ccRecipients));
|
||||
|
||||
String resourceName = defaultString(resource.getName());
|
||||
String filename = resourceName;
|
||||
DataSource dataSource = resource.getDataSource();
|
||||
String contentType = dataSource.getContentType();
|
||||
Object bccType = recipientTypeClass.getField("BCC").get(null);
|
||||
Object[] bccRecipients = (Object[]) getRecipients.invoke(message, bccType);
|
||||
content.setBcc(buildAddressString(bccRecipients));
|
||||
|
||||
if (!isBlank(dataSource.getName())) {
|
||||
filename = dataSource.getName();
|
||||
}
|
||||
filename = safeMimeDecode(filename);
|
||||
|
||||
// Generate unique filename for unnamed attachments
|
||||
if (isBlank(filename)) {
|
||||
String extension = detectExtensionFromMimeType(contentType);
|
||||
filename = embedded ? resourceName : (ATTACHMENT_PREFIX + counter + extension);
|
||||
}
|
||||
attachment.setFilename(filename);
|
||||
|
||||
String contentId = embedded ? stripCid(resourceName) : null;
|
||||
attachment.setContentId(contentId);
|
||||
|
||||
String detectedContentType = EmlProcessingUtils.detectMimeType(filename, contentType);
|
||||
attachment.setContentType(detectedContentType);
|
||||
|
||||
// Read data with size limit to prevent OOM
|
||||
ReadResult readResult = readData(dataSource, embedded, request);
|
||||
if (readResult != null) {
|
||||
attachment.setSizeBytes(readResult.totalSize);
|
||||
if (shouldIncludeAttachmentData(embedded, request, readResult)) {
|
||||
attachment.setData(readResult.data);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
try {
|
||||
Method getAllRecipients = messageClass.getMethod("getAllRecipients");
|
||||
Object[] recipients = (Object[]) getAllRecipients.invoke(message);
|
||||
content.setTo(buildAddressString(recipients));
|
||||
content.setCc("");
|
||||
content.setBcc("");
|
||||
} catch (ReflectiveOperationException ex) {
|
||||
content.setTo("");
|
||||
content.setCc("");
|
||||
content.setBcc("");
|
||||
}
|
||||
}
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
private boolean shouldIncludeAttachmentData(
|
||||
boolean embedded, EmlToPdfRequest request, ReadResult readResult) {
|
||||
// Always include embedded images for proper rendering
|
||||
if (embedded) {
|
||||
return readResult != null && readResult.data() != null;
|
||||
}
|
||||
// Check if attachments are requested and data is available within size limit
|
||||
if (request == null || !request.isIncludeAttachments()) {
|
||||
return false;
|
||||
}
|
||||
if (readResult == null || readResult.data() == null) {
|
||||
return false;
|
||||
}
|
||||
return readResult.data().length <= getMaxAttachmentSizeBytes(request);
|
||||
}
|
||||
|
||||
private String detectExtensionFromMimeType(String mimeType) {
|
||||
if (mimeType == null) {
|
||||
private static String buildAddressString(Object[] addresses) {
|
||||
if (addresses == null || addresses.length == 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String lower = mimeType.toLowerCase(Locale.ROOT);
|
||||
|
||||
// Remove any parameters (e.g., "text/plain; charset=utf-8" -> "text/plain")
|
||||
int semicolon = lower.indexOf(';');
|
||||
if (semicolon > 0) {
|
||||
lower = lower.substring(0, semicolon).trim();
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < addresses.length; i++) {
|
||||
if (i > 0) builder.append(", ");
|
||||
builder.append(safeMimeDecode(addresses[i].toString()));
|
||||
}
|
||||
|
||||
// Match exact MIME types first, then fall back to contains() for variants
|
||||
return switch (lower) {
|
||||
case "application/pdf" -> ".pdf";
|
||||
case "image/png" -> ".png";
|
||||
case "image/jpeg", "image/jpg" -> ".jpg";
|
||||
case "image/gif" -> ".gif";
|
||||
case "image/webp" -> ".webp";
|
||||
case "image/bmp" -> ".bmp";
|
||||
case "text/plain" -> ".txt";
|
||||
case "text/html" -> ".html";
|
||||
case "text/xml", "application/xml" -> ".xml";
|
||||
case "application/json" -> ".json";
|
||||
case "application/zip" -> ".zip";
|
||||
case "application/octet-stream" -> ".bin";
|
||||
default -> {
|
||||
if (lower.contains("wordprocessingml") || lower.contains("msword")) yield ".docx";
|
||||
if (lower.contains("spreadsheetml") || lower.contains("excel")) yield ".xlsx";
|
||||
if (lower.contains("presentationml") || lower.contains("powerpoint")) yield ".pptx";
|
||||
if (lower.contains("opendocument.text")) yield ".odt";
|
||||
if (lower.contains("opendocument.spreadsheet")) yield ".ods";
|
||||
yield "";
|
||||
}
|
||||
};
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private ReadResult readData(DataSource dataSource, boolean embedded, EmlToPdfRequest request)
|
||||
throws IOException {
|
||||
if (dataSource == null) {
|
||||
return null;
|
||||
}
|
||||
private static void processMessageContent(
|
||||
Object message,
|
||||
Object messageContent,
|
||||
EmailContent content,
|
||||
EmlToPdfRequest request,
|
||||
CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
try {
|
||||
if (messageContent instanceof String stringContent) {
|
||||
Method getContentType = message.getClass().getMethod("getContentType");
|
||||
String contentType = (String) getContentType.invoke(message);
|
||||
|
||||
long maxBytes = getMaxAttachmentSizeBytes(request);
|
||||
|
||||
try (InputStream input = dataSource.getInputStream()) {
|
||||
// Embedded images are usually needed for display regardless of size,
|
||||
// but regular attachments should be guarded against OOM
|
||||
if (!embedded && request != null) {
|
||||
byte[] buffer = new byte[8192];
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
int bytesRead;
|
||||
long totalBytes = 0;
|
||||
while ((bytesRead = input.read(buffer)) != -1) {
|
||||
totalBytes += bytesRead;
|
||||
if (totalBytes > maxBytes) {
|
||||
// Attachment too large - skip remaining data but estimate total size
|
||||
long remainingBytes = countRemainingBytes(input, totalBytes);
|
||||
log.debug(
|
||||
"Attachment exceeds size limit: {} bytes (max: {} bytes), skipping",
|
||||
remainingBytes,
|
||||
maxBytes);
|
||||
return new ReadResult(null, remainingBytes);
|
||||
}
|
||||
output.write(buffer, 0, bytesRead);
|
||||
if (contentType != null
|
||||
&& contentType.toLowerCase(Locale.ROOT).contains(TEXT_HTML)) {
|
||||
content.setHtmlBody(stringContent);
|
||||
} else {
|
||||
content.setTextBody(stringContent);
|
||||
}
|
||||
byte[] data = output.toByteArray();
|
||||
return new ReadResult(data, data.length);
|
||||
} else {
|
||||
byte[] data = input.readAllBytes();
|
||||
return new ReadResult(data, data.length);
|
||||
Class<?> multipartClass = Class.forName("jakarta.mail.Multipart");
|
||||
if (multipartClass.isInstance(messageContent)) {
|
||||
processMultipart(messageContent, content, request, customHtmlSanitizer, 0);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
if (embedded) {
|
||||
log.debug(
|
||||
"Failed to read embedded image, using empty placeholder: {}",
|
||||
e.getMessage());
|
||||
return new ReadResult(new byte[0], 0);
|
||||
}
|
||||
throw e;
|
||||
} catch (ReflectiveOperationException | ClassCastException e) {
|
||||
content.setTextBody("Email content could not be parsed with advanced processing");
|
||||
}
|
||||
}
|
||||
|
||||
private long countRemainingBytes(InputStream input, long alreadyRead) throws IOException {
|
||||
long count = alreadyRead;
|
||||
private static void processMultipart(
|
||||
Object multipart,
|
||||
EmailContent content,
|
||||
EmlToPdfRequest request,
|
||||
CustomHtmlSanitizer customHtmlSanitizer,
|
||||
int depth) {
|
||||
|
||||
long skipped;
|
||||
while (count < MAX_SIZE_ESTIMATION_BYTES
|
||||
&& (skipped = input.skip(MAX_SIZE_ESTIMATION_BYTES - count)) > 0) {
|
||||
count += skipped;
|
||||
final int MAX_MULTIPART_DEPTH = 10;
|
||||
if (depth > MAX_MULTIPART_DEPTH) {
|
||||
content.setHtmlBody("<div class=\"error\">Maximum multipart depth exceeded</div>");
|
||||
return;
|
||||
}
|
||||
|
||||
if (count < MAX_SIZE_ESTIMATION_BYTES && input.available() > 0) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = input.read(buffer)) != -1 && count < MAX_SIZE_ESTIMATION_BYTES) {
|
||||
count += read;
|
||||
try {
|
||||
Class<?> multipartClass = multipart.getClass();
|
||||
Method getCount = multipartClass.getMethod("getCount");
|
||||
int count = (Integer) getCount.invoke(multipart);
|
||||
|
||||
Method getBodyPart = multipartClass.getMethod("getBodyPart", int.class);
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
Object part = getBodyPart.invoke(multipart, i);
|
||||
processPart(part, content, request, customHtmlSanitizer, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
} catch (ReflectiveOperationException | ClassCastException e) {
|
||||
content.setHtmlBody("<div class=\"error\">Error processing multipart content</div>");
|
||||
}
|
||||
}
|
||||
|
||||
private String formatRecipients(List<Recipient> recipients, RecipientType type) {
|
||||
if (recipients == null || type == null) {
|
||||
private static void processPart(
|
||||
Object part,
|
||||
EmailContent content,
|
||||
EmlToPdfRequest request,
|
||||
CustomHtmlSanitizer customHtmlSanitizer,
|
||||
int depth) {
|
||||
try {
|
||||
Class<?> partClass = part.getClass();
|
||||
|
||||
Method isMimeType = partClass.getMethod("isMimeType", String.class);
|
||||
Method getContent = partClass.getMethod("getContent");
|
||||
Method getDisposition = partClass.getMethod("getDisposition");
|
||||
Method getFileName = partClass.getMethod("getFileName");
|
||||
Method getContentType = partClass.getMethod("getContentType");
|
||||
Method getHeader = partClass.getMethod("getHeader", String.class);
|
||||
|
||||
Object disposition = getDisposition.invoke(part);
|
||||
String filename = (String) getFileName.invoke(part);
|
||||
String contentType = (String) getContentType.invoke(part);
|
||||
|
||||
String normalizedDisposition =
|
||||
disposition != null ? ((String) disposition).toLowerCase(Locale.ROOT) : null;
|
||||
|
||||
if ((Boolean) isMimeType.invoke(part, TEXT_PLAIN) && normalizedDisposition == null) {
|
||||
Object partContent = getContent.invoke(part);
|
||||
if (partContent instanceof String stringContent) {
|
||||
content.setTextBody(stringContent);
|
||||
}
|
||||
} else if ((Boolean) isMimeType.invoke(part, TEXT_HTML)
|
||||
&& normalizedDisposition == null) {
|
||||
Object partContent = getContent.invoke(part);
|
||||
if (partContent instanceof String stringContent) {
|
||||
String htmlBody =
|
||||
customHtmlSanitizer != null
|
||||
? customHtmlSanitizer.sanitize(stringContent)
|
||||
: stringContent;
|
||||
content.setHtmlBody(htmlBody);
|
||||
}
|
||||
} else if ((normalizedDisposition != null
|
||||
&& normalizedDisposition.contains(DISPOSITION_ATTACHMENT))
|
||||
|| (filename != null && !filename.trim().isEmpty())) {
|
||||
|
||||
processAttachment(
|
||||
part, content, request, getHeader, getContent, filename, contentType);
|
||||
} else if ((Boolean) isMimeType.invoke(part, "multipart/*")) {
|
||||
Object multipartContent = getContent.invoke(part);
|
||||
if (multipartContent != null) {
|
||||
Class<?> multipartClass = Class.forName("jakarta.mail.Multipart");
|
||||
if (multipartClass.isInstance(multipartContent)) {
|
||||
processMultipart(
|
||||
multipartContent, content, request, customHtmlSanitizer, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (ReflectiveOperationException | RuntimeException e) {
|
||||
// Continue processing other parts if one fails
|
||||
}
|
||||
}
|
||||
|
||||
private static void processAttachment(
|
||||
Object part,
|
||||
EmailContent content,
|
||||
EmlToPdfRequest request,
|
||||
Method getHeader,
|
||||
Method getContent,
|
||||
String filename,
|
||||
String contentType) {
|
||||
|
||||
content.setAttachmentCount(content.getAttachmentCount() + 1);
|
||||
|
||||
if (filename != null && !filename.trim().isEmpty()) {
|
||||
EmailAttachment attachment = new EmailAttachment();
|
||||
attachment.setFilename(safeMimeDecode(filename));
|
||||
attachment.setContentType(contentType);
|
||||
|
||||
try {
|
||||
String[] contentIdHeaders = (String[]) getHeader.invoke(part, HEADER_CONTENT_ID);
|
||||
if (contentIdHeaders != null) {
|
||||
for (String contentIdHeader : contentIdHeaders) {
|
||||
if (contentIdHeader != null && !contentIdHeader.trim().isEmpty()) {
|
||||
attachment.setEmbedded(true);
|
||||
String contentId =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getAngleBracketsPattern()
|
||||
.matcher(contentIdHeader.trim())
|
||||
.replaceAll("");
|
||||
attachment.setContentId(contentId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ReflectiveOperationException e) {
|
||||
}
|
||||
|
||||
if ((request != null && request.isIncludeAttachments()) || attachment.isEmbedded()) {
|
||||
extractAttachmentData(part, attachment, getContent, request);
|
||||
}
|
||||
|
||||
content.getAttachments().add(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
private static void extractAttachmentData(
|
||||
Object part, EmailAttachment attachment, Method getContent, EmlToPdfRequest request) {
|
||||
try {
|
||||
Object attachmentContent = getContent.invoke(part);
|
||||
byte[] attachmentData = null;
|
||||
|
||||
if (attachmentContent instanceof InputStream inputStream) {
|
||||
try (InputStream stream = inputStream) {
|
||||
attachmentData = stream.readAllBytes();
|
||||
} catch (IOException e) {
|
||||
if (attachment.isEmbedded()) {
|
||||
attachmentData = new byte[0];
|
||||
} else {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
} else if (attachmentContent instanceof byte[] byteArray) {
|
||||
attachmentData = byteArray;
|
||||
} else if (attachmentContent instanceof String stringContent) {
|
||||
attachmentData = stringContent.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
if (attachmentData != null) {
|
||||
long maxSizeMB = request != null ? request.getMaxAttachmentSizeMB() : 10L;
|
||||
long maxSizeBytes = maxSizeMB * 1024 * 1024;
|
||||
|
||||
if (attachmentData.length <= maxSizeBytes || attachment.isEmbedded()) {
|
||||
attachment.setData(attachmentData);
|
||||
attachment.setSizeBytes(attachmentData.length);
|
||||
} else {
|
||||
attachment.setSizeBytes(attachmentData.length);
|
||||
}
|
||||
}
|
||||
} catch (ReflectiveOperationException | RuntimeException e) {
|
||||
// Continue without attachment data
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractBasicHeader(String emlContent, String headerName) {
|
||||
try {
|
||||
String[] lines =
|
||||
RegexPatternUtils.getInstance().getNewlineSplitPattern().split(emlContent);
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
String line = lines[i];
|
||||
if (line.toLowerCase(Locale.ROOT).startsWith(headerName.toLowerCase(Locale.ROOT))) {
|
||||
StringBuilder value =
|
||||
new StringBuilder(line.substring(headerName.length()).trim());
|
||||
for (int j = i + 1; j < lines.length; j++) {
|
||||
if (lines[j].startsWith(" ") || lines[j].startsWith("\t")) {
|
||||
value.append(" ").append(lines[j].trim());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return safeMimeDecode(value.toString());
|
||||
}
|
||||
if (line.trim().isEmpty()) break;
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
// Ignore errors in header extraction
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String extractHtmlBody(String emlContent) {
|
||||
try {
|
||||
String lowerContent = emlContent.toLowerCase(Locale.ROOT);
|
||||
int htmlStart = lowerContent.indexOf(HEADER_CONTENT_TYPE + " " + TEXT_HTML);
|
||||
if (htmlStart == -1) return null;
|
||||
|
||||
int bodyStart = emlContent.indexOf("\r\n\r\n", htmlStart);
|
||||
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n", htmlStart);
|
||||
if (bodyStart == -1) return null;
|
||||
|
||||
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
|
||||
int bodyEnd = findPartEnd(emlContent, bodyStart);
|
||||
|
||||
return emlContent.substring(bodyStart, bodyEnd).trim();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractTextBody(String emlContent) {
|
||||
try {
|
||||
String lowerContent = emlContent.toLowerCase(Locale.ROOT);
|
||||
int textStart = lowerContent.indexOf(HEADER_CONTENT_TYPE + " " + TEXT_PLAIN);
|
||||
if (textStart == -1) {
|
||||
int bodyStart = emlContent.indexOf("\r\n\r\n");
|
||||
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n");
|
||||
if (bodyStart != -1) {
|
||||
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
|
||||
int bodyEnd = findPartEnd(emlContent, bodyStart);
|
||||
return emlContent.substring(bodyStart, bodyEnd).trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
int bodyStart = emlContent.indexOf("\r\n\r\n", textStart);
|
||||
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n", textStart);
|
||||
if (bodyStart == -1) return null;
|
||||
|
||||
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
|
||||
int bodyEnd = findPartEnd(emlContent, bodyStart);
|
||||
|
||||
return emlContent.substring(bodyStart, bodyEnd).trim();
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static int findPartEnd(String content, int start) {
|
||||
String[] lines =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getNewlineSplitPattern()
|
||||
.split(content.substring(start));
|
||||
StringBuilder result = new StringBuilder();
|
||||
|
||||
for (String line : lines) {
|
||||
if (line.startsWith("--") && line.length() > 10) break;
|
||||
result.append(line).append("\n");
|
||||
}
|
||||
|
||||
return start + result.length();
|
||||
}
|
||||
|
||||
private static List<EmailAttachment> extractAttachmentsBasic(String emlContent) {
|
||||
List<EmailAttachment> attachments = new ArrayList<>();
|
||||
try {
|
||||
String[] lines =
|
||||
RegexPatternUtils.getInstance().getNewlineSplitPattern().split(emlContent);
|
||||
boolean inHeaders = true;
|
||||
String currentContentType = "";
|
||||
String currentDisposition = "";
|
||||
String currentFilename = "";
|
||||
String currentEncoding = "";
|
||||
|
||||
for (String line : lines) {
|
||||
String lowerLine = line.toLowerCase(Locale.ROOT).trim();
|
||||
|
||||
if (line.trim().isEmpty()) {
|
||||
inHeaders = false;
|
||||
if (isAttachment(currentDisposition, currentFilename, currentContentType)) {
|
||||
EmailAttachment attachment = new EmailAttachment();
|
||||
attachment.setFilename(currentFilename);
|
||||
attachment.setContentType(currentContentType);
|
||||
attachment.setTransferEncoding(currentEncoding);
|
||||
attachments.add(attachment);
|
||||
}
|
||||
currentContentType = "";
|
||||
currentDisposition = "";
|
||||
currentFilename = "";
|
||||
currentEncoding = "";
|
||||
inHeaders = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inHeaders) continue;
|
||||
|
||||
if (lowerLine.startsWith(HEADER_CONTENT_TYPE)) {
|
||||
currentContentType = line.substring(HEADER_CONTENT_TYPE.length()).trim();
|
||||
} else if (lowerLine.startsWith(HEADER_CONTENT_DISPOSITION)) {
|
||||
currentDisposition = line.substring(HEADER_CONTENT_DISPOSITION.length()).trim();
|
||||
currentFilename = extractFilenameFromDisposition(currentDisposition);
|
||||
} else if (lowerLine.startsWith(HEADER_CONTENT_TRANSFER_ENCODING)) {
|
||||
currentEncoding =
|
||||
line.substring(HEADER_CONTENT_TRANSFER_ENCODING.length()).trim();
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
// Continue with empty list
|
||||
}
|
||||
return attachments;
|
||||
}
|
||||
|
||||
private static boolean isAttachment(String disposition, String filename, String contentType) {
|
||||
return (disposition.toLowerCase(Locale.ROOT).contains(DISPOSITION_ATTACHMENT)
|
||||
&& !filename.isEmpty())
|
||||
|| (!filename.isEmpty()
|
||||
&& !contentType.toLowerCase(Locale.ROOT).startsWith("text/"))
|
||||
|| (contentType.toLowerCase(Locale.ROOT).contains("application/")
|
||||
&& !filename.isEmpty());
|
||||
}
|
||||
|
||||
private static String extractFilenameFromDisposition(String disposition) {
|
||||
if (disposition == null || !disposition.contains("filename=")) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return recipients.stream()
|
||||
.filter(Objects::nonNull)
|
||||
// Use type.equals() for null-safe comparison (recipient.getType() may be null)
|
||||
.filter(recipient -> type.equals(recipient.getType()))
|
||||
.map(EmlParser::formatRecipient)
|
||||
.filter(string -> !isBlank(string))
|
||||
.collect(Collectors.joining(", "));
|
||||
// Handle filename*= (RFC 2231 encoded filename)
|
||||
if (disposition.toLowerCase(Locale.ROOT).contains("filename*=")) {
|
||||
int filenameStarStart = disposition.toLowerCase(Locale.ROOT).indexOf("filename*=") + 10;
|
||||
int filenameStarEnd = disposition.indexOf(";", filenameStarStart);
|
||||
if (filenameStarEnd == -1) filenameStarEnd = disposition.length();
|
||||
String extendedFilename =
|
||||
disposition.substring(filenameStarStart, filenameStarEnd).trim();
|
||||
extendedFilename =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getQuotesRemovalPattern()
|
||||
.matcher(extendedFilename)
|
||||
.replaceAll("");
|
||||
|
||||
if (extendedFilename.contains("'")) {
|
||||
String[] parts = extendedFilename.split("'", 3);
|
||||
if (parts.length == 3) {
|
||||
return EmlProcessingUtils.decodeUrlEncoded(parts[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle regular filename=
|
||||
int filenameStart = disposition.toLowerCase(Locale.ROOT).indexOf("filename=") + 9;
|
||||
int filenameEnd = disposition.indexOf(";", filenameStart);
|
||||
if (filenameEnd == -1) filenameEnd = disposition.length();
|
||||
String filename = disposition.substring(filenameStart, filenameEnd).trim();
|
||||
filename =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getQuotesRemovalPattern()
|
||||
.matcher(filename)
|
||||
.replaceAll("");
|
||||
return safeMimeDecode(filename);
|
||||
}
|
||||
|
||||
private String formatRecipient(Recipient recipient) {
|
||||
if (recipient == null) {
|
||||
public static String safeMimeDecode(String headerValue) {
|
||||
if (headerValue == null || headerValue.trim().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String name = safeMimeDecode(recipient.getName());
|
||||
String address = safeMimeDecode(recipient.getAddress());
|
||||
|
||||
if (!isBlank(name) && !isBlank(address)) {
|
||||
return name + " <" + address + ">";
|
||||
if (!mimeUtilityChecked) {
|
||||
synchronized (EmlParser.class) {
|
||||
if (!mimeUtilityChecked) {
|
||||
initializeMimeUtilityDecoding();
|
||||
}
|
||||
}
|
||||
}
|
||||
return !isBlank(name) ? name : address;
|
||||
}
|
||||
|
||||
public String safeMimeDecode(String headerValue) {
|
||||
if (isBlank(headerValue)) {
|
||||
return "";
|
||||
if (mimeUtilityDecodeTextMethod != null) {
|
||||
try {
|
||||
return (String) mimeUtilityDecodeTextMethod.invoke(null, headerValue.trim());
|
||||
} catch (ReflectiveOperationException | RuntimeException e) {
|
||||
// Fall through to custom implementation
|
||||
}
|
||||
}
|
||||
|
||||
return EmlProcessingUtils.decodeMimeHeader(headerValue.trim());
|
||||
}
|
||||
|
||||
private String stripCid(String contentId) {
|
||||
if (contentId == null) {
|
||||
return null;
|
||||
}
|
||||
return RegexPatternUtils.getInstance()
|
||||
.getAngleBracketsPattern()
|
||||
.matcher(contentId)
|
||||
.replaceAll("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
private long getMaxAttachmentSizeBytes(EmlToPdfRequest request) {
|
||||
long maxMb = request != null ? request.getMaxAttachmentSizeMB() : DEFAULT_MAX_ATTACHMENT_MB;
|
||||
return maxMb * 1024L * 1024L;
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.trim().isEmpty();
|
||||
}
|
||||
|
||||
private String defaultString(String value) {
|
||||
return value != null ? value : "";
|
||||
}
|
||||
|
||||
private record ReadResult(byte[] data, long totalSize) {
|
||||
public ReadResult {
|
||||
if (totalSize < 0) {
|
||||
throw new IllegalArgumentException("Size cannot be negative: " + totalSize);
|
||||
}
|
||||
if (data != null && data.length > totalSize) {
|
||||
throw new IllegalArgumentException(
|
||||
"Data length (" + data.length + ") exceeds total size (" + totalSize + ")");
|
||||
}
|
||||
private static void initializeMimeUtilityDecoding() {
|
||||
try {
|
||||
Class<?> mimeUtilityClass = Class.forName("jakarta.mail.internet.MimeUtility");
|
||||
mimeUtilityDecodeTextMethod = mimeUtilityClass.getMethod("decodeText", String.class);
|
||||
} catch (ClassNotFoundException | NoSuchMethodException e) {
|
||||
mimeUtilityDecodeTextMethod = null;
|
||||
}
|
||||
mimeUtilityChecked = true;
|
||||
}
|
||||
|
||||
@Data
|
||||
public class EmailContent {
|
||||
public static class EmailContent {
|
||||
private String subject;
|
||||
private String from;
|
||||
private String to;
|
||||
private String cc;
|
||||
private String bcc;
|
||||
private ZonedDateTime date;
|
||||
private String dateString; // Maintained for compatibility
|
||||
private String dateString; // For basic parsing fallback
|
||||
private String htmlBody;
|
||||
private String textBody;
|
||||
private int attachmentCount;
|
||||
@@ -439,7 +673,7 @@ public class EmlParser {
|
||||
}
|
||||
|
||||
@Data
|
||||
public class EmailAttachment {
|
||||
public static class EmailAttachment {
|
||||
private String filename;
|
||||
private String contentType;
|
||||
private byte[] data;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
@@ -10,41 +8,32 @@ import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
import lombok.Synchronized;
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.api.converters.EmlToPdfRequest;
|
||||
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
|
||||
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
public class EmlProcessingUtils {
|
||||
|
||||
// Style constants
|
||||
private final int DEFAULT_FONT_SIZE = 12;
|
||||
private final String DEFAULT_FONT_FAMILY = "Helvetica, sans-serif";
|
||||
private final float DEFAULT_LINE_HEIGHT = 1.4f;
|
||||
private final String DEFAULT_ZOOM = "1.0";
|
||||
private final String DEFAULT_TEXT_COLOR = "#202124";
|
||||
private final String DEFAULT_BACKGROUND_COLOR = "#ffffff";
|
||||
private final String DEFAULT_BORDER_COLOR = "#e8eaed";
|
||||
private final String ATTACHMENT_BACKGROUND_COLOR = "#f9f9f9";
|
||||
private final String ATTACHMENT_BORDER_COLOR = "#eeeeee";
|
||||
private static final int DEFAULT_FONT_SIZE = 12;
|
||||
private static final String DEFAULT_FONT_FAMILY = "Helvetica, sans-serif";
|
||||
private static final float DEFAULT_LINE_HEIGHT = 1.4f;
|
||||
private static final String DEFAULT_ZOOM = "1.0";
|
||||
private static final String DEFAULT_TEXT_COLOR = "#202124";
|
||||
private static final String DEFAULT_BACKGROUND_COLOR = "#ffffff";
|
||||
private static final String DEFAULT_BORDER_COLOR = "#e8eaed";
|
||||
private static final String ATTACHMENT_BACKGROUND_COLOR = "#f9f9f9";
|
||||
private static final String ATTACHMENT_BORDER_COLOR = "#eeeeee";
|
||||
|
||||
private final String CSS_RESOURCE_PATH = "templates/email-pdf-styles.css";
|
||||
private final int EML_CHECK_LENGTH = 8192;
|
||||
private final int MIN_HEADER_COUNT_FOR_VALID_EML = 2;
|
||||
// MSG file magic bytes (Compound File Binary Format / OLE2)
|
||||
// D0 CF 11 E0 A1 B1 1A E1
|
||||
private final byte[] MSG_MAGIC_BYTES = {
|
||||
(byte) 0xD0, (byte) 0xCF, (byte) 0x11, (byte) 0xE0,
|
||||
(byte) 0xA1, (byte) 0xB1, (byte) 0x1A, (byte) 0xE1
|
||||
};
|
||||
private final Map<String, String> EXTENSION_TO_MIME_TYPE =
|
||||
private static final int EML_CHECK_LENGTH = 8192;
|
||||
private static final int MIN_HEADER_COUNT_FOR_VALID_EML = 2;
|
||||
|
||||
// MIME type detection
|
||||
private static final Map<String, String> EXTENSION_TO_MIME_TYPE =
|
||||
Map.of(
|
||||
".png", MediaType.IMAGE_PNG_VALUE,
|
||||
".jpg", MediaType.IMAGE_JPEG_VALUE,
|
||||
@@ -56,36 +45,18 @@ public class EmlProcessingUtils {
|
||||
".ico", "image/x-icon",
|
||||
".tiff", "image/tiff",
|
||||
".tif", "image/tiff");
|
||||
private volatile String cachedCssContent = null;
|
||||
|
||||
public void validateEmlInput(byte[] emlBytes) {
|
||||
public static void validateEmlInput(byte[] emlBytes) {
|
||||
if (emlBytes == null || emlBytes.length == 0) {
|
||||
throw ExceptionUtils.createEmlEmptyException();
|
||||
}
|
||||
|
||||
if (isMsgFile(emlBytes)) {
|
||||
return; // Valid MSG file, no further EML validation needed
|
||||
}
|
||||
|
||||
if (isInvalidEmlFormat(emlBytes)) {
|
||||
throw ExceptionUtils.createEmlInvalidFormatException();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isMsgFile(byte[] fileBytes) {
|
||||
if (fileBytes == null || fileBytes.length < MSG_MAGIC_BYTES.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < MSG_MAGIC_BYTES.length; i++) {
|
||||
if (fileBytes[i] != MSG_MAGIC_BYTES[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean isInvalidEmlFormat(byte[] emlBytes) {
|
||||
private static boolean isInvalidEmlFormat(byte[] emlBytes) {
|
||||
try {
|
||||
int checkLength = Math.min(emlBytes.length, EML_CHECK_LENGTH);
|
||||
String content;
|
||||
@@ -130,7 +101,7 @@ public class EmlProcessingUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public String generateEnhancedEmailHtml(
|
||||
public static String generateEnhancedEmailHtml(
|
||||
EmlParser.EmailContent content,
|
||||
EmlToPdfRequest request,
|
||||
CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
@@ -174,7 +145,7 @@ public class EmlProcessingUtils {
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"<div><strong>CC:</strong> %s</div>%n",
|
||||
"<div><strong>CC:</strong> %s</div>\n",
|
||||
sanitizeText(content.getCc(), customHtmlSanitizer)));
|
||||
}
|
||||
|
||||
@@ -182,7 +153,7 @@ public class EmlProcessingUtils {
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"<div><strong>BCC:</strong> %s</div>%n",
|
||||
"<div><strong>BCC:</strong> %s</div>\n",
|
||||
sanitizeText(content.getBcc(), customHtmlSanitizer)));
|
||||
}
|
||||
|
||||
@@ -190,19 +161,19 @@ public class EmlProcessingUtils {
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"<div><strong>Date:</strong> %s</div>%n",
|
||||
"<div><strong>Date:</strong> %s</div>\n",
|
||||
PdfAttachmentHandler.formatEmailDate(content.getDate())));
|
||||
} else if (content.getDateString() != null && !content.getDateString().trim().isEmpty()) {
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"<div><strong>Date:</strong> %s</div>%n",
|
||||
"<div><strong>Date:</strong> %s</div>\n",
|
||||
sanitizeText(content.getDateString(), customHtmlSanitizer)));
|
||||
}
|
||||
|
||||
html.append(String.format(Locale.ROOT, "</div></div>%n"));
|
||||
html.append("</div></div>\n");
|
||||
|
||||
html.append(String.format(Locale.ROOT, "<div class=\"email-body\">%n"));
|
||||
html.append("<div class=\"email-body\">\n");
|
||||
if (content.getHtmlBody() != null && !content.getHtmlBody().trim().isEmpty()) {
|
||||
String processedHtml =
|
||||
processEmailHtmlBody(content.getHtmlBody(), content, customHtmlSanitizer);
|
||||
@@ -216,17 +187,17 @@ public class EmlProcessingUtils {
|
||||
} else {
|
||||
html.append("<div class=\"no-content\"><p><em>No content available</em></p></div>");
|
||||
}
|
||||
html.append(String.format(Locale.ROOT, "</div>%n"));
|
||||
html.append("</div>\n");
|
||||
|
||||
if (content.getAttachmentCount() > 0 || !content.getAttachments().isEmpty()) {
|
||||
appendAttachmentsSection(html, content, request);
|
||||
appendAttachmentsSection(html, content, request, customHtmlSanitizer);
|
||||
}
|
||||
|
||||
html.append(String.format(Locale.ROOT, "</div>%n</body></html>"));
|
||||
html.append("</div>\n</body></html>");
|
||||
return html.toString();
|
||||
}
|
||||
|
||||
public String processEmailHtmlBody(
|
||||
public static String processEmailHtmlBody(
|
||||
String htmlBody,
|
||||
EmlParser.EmailContent emailContent,
|
||||
CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
@@ -253,7 +224,8 @@ public class EmlProcessingUtils {
|
||||
return processed;
|
||||
}
|
||||
|
||||
public String convertTextToHtml(String textBody, CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
public static String convertTextToHtml(
|
||||
String textBody, CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
if (textBody == null) return "";
|
||||
|
||||
String html =
|
||||
@@ -283,25 +255,129 @@ public class EmlProcessingUtils {
|
||||
return html;
|
||||
}
|
||||
|
||||
private void appendEnhancedStyles(StringBuilder html) {
|
||||
html.append(
|
||||
private static void appendEnhancedStyles(StringBuilder html) {
|
||||
String css =
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
:root {
|
||||
--font-family: %s;
|
||||
--font-size: %dpx;
|
||||
--line-height: %s;
|
||||
--text-color: %s;
|
||||
--bg-color: %s;
|
||||
--border-color: %s;
|
||||
--header-font-size: %dpx;
|
||||
--meta-font-size: %dpx;
|
||||
--attachment-bg: %s;
|
||||
--attachment-border: %s;
|
||||
--attachment-header-size: %dpx;
|
||||
--attachment-detail-size: %dpx;
|
||||
--note-font-size: %dpx;
|
||||
body {
|
||||
font-family: %s;
|
||||
font-size: %dpx;
|
||||
line-height: %s;
|
||||
color: %s;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
background-color: %s;
|
||||
}
|
||||
|
||||
.email-container {
|
||||
width: 100%%;
|
||||
max-width: 100%%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.email-header {
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid %s;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.email-header h1 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: %dpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.email-meta div {
|
||||
margin-bottom: 2px;
|
||||
font-size: %dpx;
|
||||
}
|
||||
|
||||
.email-body {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.attachment-section {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
background-color: %s;
|
||||
border: 1px solid %s;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.attachment-section h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: %dpx;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.attachment-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.attachment-details, .attachment-type {
|
||||
font-size: %dpx;
|
||||
color: #555555;
|
||||
}
|
||||
|
||||
.attachment-inclusion-note, .attachment-info-note {
|
||||
margin-top: 8px;
|
||||
padding: 6px;
|
||||
font-size: %dpx;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.attachment-inclusion-note {
|
||||
background-color: #e6ffed;
|
||||
border: 1px solid #d4f7dc;
|
||||
color: #006420;
|
||||
}
|
||||
|
||||
.attachment-info-note {
|
||||
background-color: #fff9e6;
|
||||
border: 1px solid #fff0c2;
|
||||
color: #664d00;
|
||||
}
|
||||
|
||||
.attachment-link-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.attachment-link-container:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.attachment-note {
|
||||
font-size: %dpx;
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.no-content {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.text-body {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
""",
|
||||
DEFAULT_FONT_FAMILY,
|
||||
@@ -310,70 +386,29 @@ public class EmlProcessingUtils {
|
||||
DEFAULT_TEXT_COLOR,
|
||||
DEFAULT_BACKGROUND_COLOR,
|
||||
DEFAULT_BORDER_COLOR,
|
||||
DEFAULT_FONT_SIZE + 6,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_SIZE + 4,
|
||||
DEFAULT_FONT_SIZE - 1,
|
||||
ATTACHMENT_BACKGROUND_COLOR,
|
||||
ATTACHMENT_BORDER_COLOR,
|
||||
DEFAULT_FONT_SIZE + 2,
|
||||
DEFAULT_FONT_SIZE - 1,
|
||||
DEFAULT_FONT_SIZE - 1));
|
||||
DEFAULT_FONT_SIZE + 1,
|
||||
DEFAULT_FONT_SIZE - 2,
|
||||
DEFAULT_FONT_SIZE - 2,
|
||||
DEFAULT_FONT_SIZE - 3);
|
||||
|
||||
html.append(loadEmailStyles());
|
||||
html.append(css);
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private String loadEmailStyles() {
|
||||
if (cachedCssContent != null) {
|
||||
return cachedCssContent;
|
||||
}
|
||||
|
||||
try {
|
||||
ClassPathResource resource = new ClassPathResource(CSS_RESOURCE_PATH);
|
||||
try (InputStream inputStream = resource.getInputStream()) {
|
||||
cachedCssContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
return cachedCssContent;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to load email CSS from resource, using fallback: {}", e.getMessage());
|
||||
cachedCssContent = getFallbackStyles(); // Cache fallback to avoid repeated attempts
|
||||
return cachedCssContent;
|
||||
}
|
||||
}
|
||||
|
||||
private String getFallbackStyles() {
|
||||
return """
|
||||
/* Minimal fallback - main CSS resource failed to load */
|
||||
body {
|
||||
font-family: var(--font-family, Helvetica, sans-serif);
|
||||
font-size: var(--font-size, 12px);
|
||||
line-height: var(--line-height, 1.4);
|
||||
color: var(--text-color, #202124);
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.email-container { max-width: 100%; }
|
||||
.email-header { border-bottom: 1px solid #ccc; margin-bottom: 16px; padding-bottom: 12px; }
|
||||
.email-header h1 { margin: 0 0 8px 0; font-size: 18px; }
|
||||
.email-meta { font-size: 12px; color: #666; }
|
||||
.email-body { line-height: 1.6; }
|
||||
.attachment-section { margin-top: 20px; padding: 12px; background: #f5f5f5; border-radius: 4px; }
|
||||
.attachment-item { padding: 6px 0; border-bottom: 1px solid #ddd; }
|
||||
.no-content { padding: 20px; text-align: center; color: #888; font-style: italic; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
""";
|
||||
}
|
||||
|
||||
private void appendAttachmentsSection(
|
||||
StringBuilder html, EmlParser.EmailContent content, EmlToPdfRequest request) {
|
||||
html.append(String.format(Locale.ROOT, "<div class=\"attachment-section\">%n"));
|
||||
private static void appendAttachmentsSection(
|
||||
StringBuilder html,
|
||||
EmlParser.EmailContent content,
|
||||
EmlToPdfRequest request,
|
||||
CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
html.append("<div class=\"attachment-section\">\n");
|
||||
int displayedAttachmentCount =
|
||||
content.getAttachmentCount() > 0
|
||||
? content.getAttachmentCount()
|
||||
: content.getAttachments().size();
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT, "<h3>Attachments (%d)</h3>%n", displayedAttachmentCount));
|
||||
html.append("<h3>Attachments (").append(displayedAttachmentCount).append(")</h3>\n");
|
||||
|
||||
if (!content.getAttachments().isEmpty()) {
|
||||
for (int i = 0; i < content.getAttachments().size(); i++) {
|
||||
@@ -426,10 +461,10 @@ public class EmlProcessingUtils {
|
||||
</div>
|
||||
""");
|
||||
}
|
||||
html.append(String.format(Locale.ROOT, "</div>%n"));
|
||||
html.append("</div>\n");
|
||||
}
|
||||
|
||||
public HTMLToPdfRequest createHtmlRequest(EmlToPdfRequest request) {
|
||||
public static HTMLToPdfRequest createHtmlRequest(EmlToPdfRequest request) {
|
||||
HTMLToPdfRequest htmlRequest = new HTMLToPdfRequest();
|
||||
|
||||
if (request != null) {
|
||||
@@ -440,7 +475,7 @@ public class EmlProcessingUtils {
|
||||
return htmlRequest;
|
||||
}
|
||||
|
||||
public String detectMimeType(String filename, String existingMimeType) {
|
||||
public static String detectMimeType(String filename, String existingMimeType) {
|
||||
if (existingMimeType != null && !existingMimeType.isEmpty()) {
|
||||
return existingMimeType;
|
||||
}
|
||||
@@ -457,7 +492,7 @@ public class EmlProcessingUtils {
|
||||
return MediaType.IMAGE_PNG_VALUE; // Default MIME type
|
||||
}
|
||||
|
||||
public String decodeUrlEncoded(String encoded) {
|
||||
public static String decodeUrlEncoded(String encoded) {
|
||||
try {
|
||||
return java.net.URLDecoder.decode(encoded, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
@@ -465,7 +500,7 @@ public class EmlProcessingUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public String decodeMimeHeader(String encodedText) {
|
||||
public static String decodeMimeHeader(String encodedText) {
|
||||
if (encodedText == null || encodedText.trim().isEmpty()) {
|
||||
return encodedText;
|
||||
}
|
||||
@@ -531,7 +566,7 @@ public class EmlProcessingUtils {
|
||||
}
|
||||
}
|
||||
|
||||
private String decodeQuotedPrintable(String encodedText, String charset) {
|
||||
private static String decodeQuotedPrintable(String encodedText, String charset) {
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (int i = 0; i < encodedText.length(); i++) {
|
||||
char c = encodedText.charAt(i);
|
||||
@@ -574,7 +609,7 @@ public class EmlProcessingUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public String escapeHtml(String text) {
|
||||
public static String escapeHtml(String text) {
|
||||
if (text == null) return "";
|
||||
return text.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
@@ -583,7 +618,7 @@ public class EmlProcessingUtils {
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
public String sanitizeText(String text, CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
public static String sanitizeText(String text, CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
if (customHtmlSanitizer != null) {
|
||||
return customHtmlSanitizer.sanitize(text);
|
||||
} else {
|
||||
@@ -591,7 +626,7 @@ public class EmlProcessingUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public String simplifyHtmlContent(String htmlContent) {
|
||||
public static String simplifyHtmlContent(String htmlContent) {
|
||||
String simplified =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getScriptTagPattern()
|
||||
|
||||
@@ -41,7 +41,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
* <pre>{@code
|
||||
* // In service layer - create exception with ExceptionUtils
|
||||
* try {
|
||||
* PDDocument doc = Loader.loadPDF(file);
|
||||
* PDDocument doc = PDDocument.load(file);
|
||||
* } catch (IOException e) {
|
||||
* throw ExceptionUtils.createPdfCorruptedException("during load", e);
|
||||
* }
|
||||
|
||||
@@ -89,11 +89,6 @@ public class PDFToFile {
|
||||
TempDirectory tempOutputDir = new TempDirectory(tempFileManager)) {
|
||||
inputFile.transferTo(tempInputFile.getFile());
|
||||
|
||||
// Create unique subdirectory for pdftohtml output (collision-proof even if called
|
||||
// multiple times)
|
||||
Path pdftohtmlDir = Files.createTempDirectory(tempOutputDir.getPath(), "pdftohtml_");
|
||||
String outputBasename = pdftohtmlDir.resolve("output").toString();
|
||||
|
||||
List<String> command =
|
||||
new ArrayList<>(
|
||||
Arrays.asList(
|
||||
@@ -102,13 +97,15 @@ public class PDFToFile {
|
||||
"-noframes",
|
||||
"-c",
|
||||
tempInputFile.getAbsolutePath(),
|
||||
outputBasename));
|
||||
pdfBaseName));
|
||||
|
||||
ProcessExecutorResult returnCode =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.PDFTOHTML)
|
||||
.runCommandWithOutputHandling(command, pdftohtmlDir.toFile());
|
||||
.runCommandWithOutputHandling(
|
||||
command, tempOutputDir.getPath().toFile());
|
||||
// Process HTML files to Markdown
|
||||
File[] outputFiles = Objects.requireNonNull(pdftohtmlDir.toFile().listFiles());
|
||||
File[] outputFiles =
|
||||
Objects.requireNonNull(tempOutputDir.getPath().toFile().listFiles());
|
||||
List<File> markdownFiles = new ArrayList<>();
|
||||
|
||||
// Convert HTML files to Markdown
|
||||
@@ -118,7 +115,7 @@ public class PDFToFile {
|
||||
String markdown = htmlToMarkdownConverter.convert(html);
|
||||
|
||||
String mdFileName = outputFile.getName().replace(".html", ".md");
|
||||
File mdFile = new File(pdftohtmlDir.toFile(), mdFileName);
|
||||
File mdFile = new File(tempOutputDir.getPath().toFile(), mdFileName);
|
||||
Files.writeString(mdFile.toPath(), markdown);
|
||||
markdownFiles.add(mdFile);
|
||||
}
|
||||
@@ -185,22 +182,18 @@ public class PDFToFile {
|
||||
// Save the uploaded file to a temporary location
|
||||
inputFile.transferTo(tempInputFile);
|
||||
|
||||
// Create unique subdirectory for pdftohtml output (collision-proof even if called
|
||||
// multiple times)
|
||||
Path pdftohtmlDir = Files.createTempDirectory(tempOutputDir, "pdftohtml_");
|
||||
String outputBasename = pdftohtmlDir.resolve("output").toString();
|
||||
|
||||
// Run the pdftohtml command with complex output
|
||||
List<String> command =
|
||||
new ArrayList<>(
|
||||
Arrays.asList(
|
||||
"pdftohtml", "-c", tempInputFile.toString(), outputBasename));
|
||||
"pdftohtml", "-c", tempInputFile.toString(), pdfBaseName));
|
||||
|
||||
ProcessExecutorResult returnCode =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.PDFTOHTML)
|
||||
.runCommandWithOutputHandling(command, pdftohtmlDir.toFile());
|
||||
.runCommandWithOutputHandling(command, tempOutputDir.toFile());
|
||||
|
||||
// Get output files
|
||||
File[] outputFiles = Objects.requireNonNull(pdftohtmlDir.toFile().listFiles());
|
||||
File[] outputFiles = Objects.requireNonNull(tempOutputDir.toFile().listFiles());
|
||||
|
||||
// Return output files in a ZIP archive
|
||||
fileName = pdfBaseName + "ToHtml.zip";
|
||||
@@ -261,6 +254,9 @@ public class PDFToFile {
|
||||
|
||||
Path tempInputFile = inputFileTemp.getPath();
|
||||
Path tempOutputDir = outputDirTemp.getPath();
|
||||
Path unoOutputFile =
|
||||
tempOutputDir.resolve(
|
||||
pdfBaseName + "." + resolvePrimaryExtension(outputFormat));
|
||||
|
||||
// Save the uploaded file to a temporary location
|
||||
inputFile.transferTo(tempInputFile);
|
||||
@@ -269,15 +265,8 @@ public class PDFToFile {
|
||||
ProcessExecutorResult returnCode = null;
|
||||
IOException unoconvertException = null;
|
||||
|
||||
Path unoOutputFile = null;
|
||||
if (isUnoConvertEnabled()) {
|
||||
try {
|
||||
// Create output file only for unoconvert (it needs specific path)
|
||||
unoOutputFile =
|
||||
Files.createTempFile(
|
||||
tempOutputDir,
|
||||
"output_",
|
||||
"." + resolvePrimaryExtension(outputFormat));
|
||||
List<String> unoCommand =
|
||||
buildUnoConvertCommand(
|
||||
tempInputFile, unoOutputFile, outputFormat, libreOfficeFilter);
|
||||
@@ -286,14 +275,6 @@ public class PDFToFile {
|
||||
.runCommandWithOutputHandling(unoCommand);
|
||||
} catch (IOException e) {
|
||||
unoconvertException = e;
|
||||
// Clean up temp file if unoconvert failed, so soffice doesn't see it
|
||||
if (unoOutputFile != null && Files.exists(unoOutputFile)) {
|
||||
try {
|
||||
Files.delete(unoOutputFile);
|
||||
} catch (IOException deleteException) {
|
||||
log.debug("Failed to clean up temp file after unoconvert failure");
|
||||
}
|
||||
}
|
||||
log.warn(
|
||||
"Unoconvert command failed ({}). Falling back to soffice command.",
|
||||
e.getMessage());
|
||||
|
||||
@@ -60,7 +60,6 @@ public class PdfToCbrUtils {
|
||||
|
||||
private static byte[] createCbrFromPdf(PDDocument document, int dpi) throws IOException {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
|
||||
|
||||
Path tempDir = Files.createTempDirectory("stirling-pdf-cbr-");
|
||||
List<Path> generatedImages = new ArrayList<>();
|
||||
|
||||
@@ -55,7 +55,6 @@ public class PdfToCbzUtils {
|
||||
|
||||
private static byte[] createCbzFromPdf(PDDocument document, int dpi) throws IOException {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
|
||||
|
||||
try (ByteArrayOutputStream cbzOutputStream = new ByteArrayOutputStream();
|
||||
ZipOutputStream zipOut = new ZipOutputStream(cbzOutputStream)) {
|
||||
|
||||
@@ -119,11 +119,11 @@ public class PdfUtils {
|
||||
|
||||
public boolean hasTextOnPage(PDPage page, String phrase) throws IOException {
|
||||
PDFTextStripper textStripper = new PDFTextStripper();
|
||||
try (PDDocument tempDoc = new PDDocument()) {
|
||||
tempDoc.addPage(page);
|
||||
String pageText = textStripper.getText(tempDoc);
|
||||
return pageText.contains(phrase);
|
||||
}
|
||||
PDDocument tempDoc = new PDDocument();
|
||||
tempDoc.addPage(page);
|
||||
String pageText = textStripper.getText(tempDoc);
|
||||
tempDoc.close();
|
||||
return pageText.contains(phrase);
|
||||
}
|
||||
|
||||
public byte[] convertFromPdf(
|
||||
@@ -153,8 +153,7 @@ public class PdfUtils {
|
||||
maxSafeDpi);
|
||||
}
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(inputStream);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
try (PDDocument document = pdfDocumentFactory.load(inputStream)) {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true);
|
||||
if (!includeAnnotations) {
|
||||
@@ -162,6 +161,9 @@ public class PdfUtils {
|
||||
}
|
||||
int pageCount = document.getNumberOfPages();
|
||||
|
||||
// Create a ByteArrayOutputStream to save the image(s) to
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
|
||||
if (singleImage) {
|
||||
if ("tiff".equals(imageType.toLowerCase(Locale.ROOT))
|
||||
|| "tif".equals(imageType.toLowerCase(Locale.ROOT))) {
|
||||
@@ -398,61 +400,55 @@ public class PdfUtils {
|
||||
*/
|
||||
public PDDocument convertPdfToPdfImage(PDDocument document) throws IOException {
|
||||
PDDocument imageDocument = new PDDocument();
|
||||
try {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true);
|
||||
for (int page = 0; page < document.getNumberOfPages(); ++page) {
|
||||
final int pageIndex = page;
|
||||
BufferedImage bim;
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true);
|
||||
for (int page = 0; page < document.getNumberOfPages(); ++page) {
|
||||
final int pageIndex = page;
|
||||
BufferedImage bim;
|
||||
|
||||
// Use global maximum DPI setting, fallback to 300 if not set
|
||||
int renderDpi = 300; // Default fallback
|
||||
ApplicationProperties properties =
|
||||
ApplicationContextProvider.getBean(ApplicationProperties.class);
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
renderDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
final int dpi = renderDpi;
|
||||
|
||||
try {
|
||||
bim =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
dpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, dpi, ImageType.RGB));
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage().contains("Maximum size of image exceeded")) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pageTooBigFor300Dpi",
|
||||
"PDF page {0} is too large to render at 300 DPI. The resulting image"
|
||||
+ " would exceed Java's maximum array size. Please use a lower DPI"
|
||||
+ " value for PDF-to-image conversion.",
|
||||
pageIndex + 1);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
PDPage originalPage = document.getPage(page);
|
||||
|
||||
float width = originalPage.getMediaBox().getWidth();
|
||||
float height = originalPage.getMediaBox().getHeight();
|
||||
|
||||
PDPage newPage = new PDPage(new PDRectangle(width, height));
|
||||
imageDocument.addPage(newPage);
|
||||
PDImageXObject pdImage = LosslessFactory.createFromImage(imageDocument, bim);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
imageDocument, newPage, AppendMode.APPEND, true, true)) {
|
||||
contentStream.drawImage(pdImage, 0, 0, width, height);
|
||||
}
|
||||
bim.flush();
|
||||
// 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();
|
||||
}
|
||||
return imageDocument;
|
||||
} catch (Exception e) {
|
||||
throw e;
|
||||
final int dpi = renderDpi;
|
||||
|
||||
try {
|
||||
bim =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
dpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, dpi, ImageType.RGB));
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage().contains("Maximum size of image exceeded")) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pageTooBigFor300Dpi",
|
||||
"PDF page {0} is too large to render at 300 DPI. The resulting image"
|
||||
+ " would exceed Java's maximum array size. Please use a lower DPI"
|
||||
+ " value for PDF-to-image conversion.",
|
||||
pageIndex + 1);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
PDPage originalPage = document.getPage(page);
|
||||
|
||||
float width = originalPage.getMediaBox().getWidth();
|
||||
float height = originalPage.getMediaBox().getHeight();
|
||||
|
||||
PDPage newPage = new PDPage(new PDRectangle(width, height));
|
||||
imageDocument.addPage(newPage);
|
||||
PDImageXObject pdImage = LosslessFactory.createFromImage(imageDocument, bim);
|
||||
PDPageContentStream contentStream =
|
||||
new PDPageContentStream(imageDocument, newPage, AppendMode.APPEND, true, true);
|
||||
contentStream.drawImage(pdImage, 0, 0, width, height);
|
||||
contentStream.close();
|
||||
}
|
||||
return imageDocument;
|
||||
}
|
||||
|
||||
private BufferedImage prepareImageForPdfToImage(int maxWidth, int height, String imageType) {
|
||||
|
||||
@@ -69,6 +69,7 @@ public class WebResponseUtils {
|
||||
// Open Byte Array and save document to it
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
document.close();
|
||||
|
||||
return baosToWebResponse(baos, docName);
|
||||
}
|
||||
|
||||
+1
-6
@@ -146,18 +146,13 @@ public class CustomColorReplaceStrategy extends ReplaceAndInvertColorStrategy {
|
||||
// Save the modified PDF to a ByteArrayOutputStream
|
||||
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
|
||||
document.save(byteArrayOutputStream);
|
||||
document.close();
|
||||
|
||||
// Prepare the modified PDF for download
|
||||
ByteArrayInputStream inputStream =
|
||||
new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
|
||||
InputStreamResource resource = new InputStreamResource(inputStream);
|
||||
return resource;
|
||||
} finally {
|
||||
try {
|
||||
Files.deleteIfExists(file.toPath());
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temporary file: {}", file.getAbsolutePath(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-35
@@ -7,7 +7,6 @@ import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
@@ -20,14 +19,11 @@ import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.api.misc.ReplaceAndInvert;
|
||||
import stirling.software.common.util.ApplicationContextProvider;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
@Slf4j
|
||||
public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
|
||||
|
||||
public InvertFullColorStrategy(MultipartFile file, ReplaceAndInvert replaceAndInvert) {
|
||||
@@ -47,8 +43,6 @@ public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
|
||||
try (PDDocument document = Loader.loadPDF(tempFile.getFile())) {
|
||||
// Render each page and invert colors
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(
|
||||
true); // Enable subsampling to reduce memory usage
|
||||
for (int page = 0; page < document.getNumberOfPages(); page++) {
|
||||
BufferedImage image;
|
||||
|
||||
@@ -79,27 +73,12 @@ public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
|
||||
PDImageXObject pdImage =
|
||||
PDImageXObject.createFromFileByContent(tempImageFile, document);
|
||||
|
||||
// Delete temp file immediately after loading into memory to prevent disk
|
||||
// exhaustion
|
||||
// The file content is now in the PDImageXObject, so the file is no longer
|
||||
// needed
|
||||
try {
|
||||
Files.deleteIfExists(tempImageFile.toPath());
|
||||
tempImageFile = null; // Mark as deleted to avoid double deletion
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Failed to delete temporary image file: {}",
|
||||
tempImageFile.getAbsolutePath(),
|
||||
e);
|
||||
}
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
pdPage,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true,
|
||||
true)) { // resetContext=true ensures clean graphics state
|
||||
true)) {
|
||||
contentStream.drawImage(
|
||||
pdImage,
|
||||
0,
|
||||
@@ -108,16 +87,8 @@ public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
|
||||
pdPage.getMediaBox().getHeight());
|
||||
}
|
||||
} finally {
|
||||
// Safety net: ensure temp file is deleted even if an exception occurred
|
||||
if (tempImageFile != null && tempImageFile.exists()) {
|
||||
try {
|
||||
Files.deleteIfExists(tempImageFile.toPath());
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Failed to delete temporary image file: {}",
|
||||
tempImageFile.getAbsolutePath(),
|
||||
e);
|
||||
}
|
||||
Files.delete(tempImageFile.toPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,10 +128,7 @@ public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
|
||||
|
||||
// Helper method to convert BufferedImage to InputStream
|
||||
private File convertToBufferedImageTpFile(BufferedImage image) throws IOException {
|
||||
// Use Files.createTempFile instead of File.createTempFile for better security and modern
|
||||
// Java practices
|
||||
Path tempPath = Files.createTempFile("image", ".png");
|
||||
File file = tempPath.toFile();
|
||||
File file = File.createTempFile("image", ".png");
|
||||
ImageIO.write(image, "png", file);
|
||||
return file;
|
||||
}
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-family, 'Helvetica, sans-serif');
|
||||
font-size: var(--font-size, 12px);
|
||||
line-height: var(--line-height, 1.4);
|
||||
color: var(--text-color, #202124);
|
||||
margin: 0;
|
||||
padding: 20px 24px;
|
||||
background-color: var(--bg-color, #ffffff);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
.email-container {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.email-header {
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 2px solid var(--border-color, #e8eaed);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.email-header h1 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: var(--header-font-size, 18px);
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.3;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.email-meta {
|
||||
font-size: var(--meta-font-size, 12px);
|
||||
color: #5f6368;
|
||||
}
|
||||
|
||||
.email-meta div {
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.email-meta strong {
|
||||
color: #3c4043;
|
||||
font-weight: 600;
|
||||
min-width: 50px;
|
||||
display: inline-block;
|
||||
}
|
||||
.email-body {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.email-body p {
|
||||
margin: 0 0 1em 0;
|
||||
}
|
||||
|
||||
.email-body a {
|
||||
color: #1a73e8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.email-body table {
|
||||
border-collapse: collapse;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.email-body td,
|
||||
.email-body th {
|
||||
padding: 8px 12px;
|
||||
vertical-align: top;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
.email-body ul,
|
||||
.email-body ol {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.email-body li {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
.email-body blockquote {
|
||||
margin: 1em 0;
|
||||
padding: 0 0 0 16px;
|
||||
border-left: 3px solid #dadce0;
|
||||
color: #5f6368;
|
||||
}
|
||||
.email-body pre,
|
||||
.email-body code {
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.email-body pre {
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.email-body code {
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.email-body hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
.attachment-section {
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
background-color: var(--attachment-bg, #f9f9f9);
|
||||
border: 1px solid var(--attachment-border, #eeeeee);
|
||||
border-radius: 6px;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.attachment-section h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: var(--attachment-header-size, 14px);
|
||||
font-weight: 600;
|
||||
color: #3c4043;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #eeeeee;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.attachment-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.attachment-icon {
|
||||
margin-right: 8px;
|
||||
font-weight: bold;
|
||||
color: #5f6368;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
font-weight: 500;
|
||||
color: #1a1a1a;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.attachment-details,
|
||||
.attachment-type {
|
||||
font-size: var(--attachment-detail-size, 11px);
|
||||
color: #5f6368;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.attachment-info-note {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
font-size: var(--note-font-size, 11px);
|
||||
border-radius: 4px;
|
||||
background-color: #e8f0fe;
|
||||
border: 1px solid #d2e3fc;
|
||||
color: #1967d2;
|
||||
}
|
||||
|
||||
.attachment-info-note p {
|
||||
margin: 0;
|
||||
}
|
||||
.no-content {
|
||||
padding: 32px 20px;
|
||||
text-align: center;
|
||||
color: #80868b;
|
||||
font-style: italic;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.text-body {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
font-family: inherit;
|
||||
line-height: 1.6;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
}
|
||||
@media print {
|
||||
body {
|
||||
padding: 0;
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.email-header {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
.attachment-section {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
.email-body div[class*="signature"],
|
||||
.email-body table[class*="signature"] {
|
||||
margin-top: 1.5em;
|
||||
padding-top: 1em;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
font-size: 0.95em;
|
||||
color: #5f6368;
|
||||
}
|
||||
|
||||
@@ -439,7 +439,9 @@ class EmlToPdfTest {
|
||||
"binary data");
|
||||
|
||||
testEmailConversion(
|
||||
emlContent, new String[] {"Attachment Only Test", "data.bin"}, true);
|
||||
emlContent,
|
||||
new String[] {"Attachment Only Test", "data.bin", "No content available"},
|
||||
true);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -467,13 +469,10 @@ class EmlToPdfTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should accept ISO-8859-1 charset declaration without errors")
|
||||
@DisplayName("Should handle non-standard but valid character sets like ISO-8859-1")
|
||||
void handleIso88591Charset() throws IOException {
|
||||
// Note: Uses ASCII content to test charset header parsing without
|
||||
// platform-dependent encoding issues. Actual charset decoding is
|
||||
// handled by Simple Java Mail library which is thoroughly tested upstream.
|
||||
String subject = "Subject with ISO-8859-1 charset";
|
||||
String body = "Body content encoded in ISO-8859-1";
|
||||
String subject = "Subject with special characters: ñ é ü";
|
||||
String body = "Body with special characters: ñ é ü";
|
||||
|
||||
String emlContent =
|
||||
createSimpleTextEmailWithCharset(
|
||||
@@ -489,13 +488,8 @@ class EmlToPdfTest {
|
||||
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request);
|
||||
|
||||
assertNotNull(htmlResult);
|
||||
// Verify the core subject text is present (charset should be decoded properly)
|
||||
assertTrue(
|
||||
htmlResult.contains("Subject with ISO-8859-1 charset"),
|
||||
"HTML should contain subject text");
|
||||
assertTrue(
|
||||
htmlResult.contains("Body content encoded in ISO-8859-1"),
|
||||
"HTML should contain body text");
|
||||
assertTrue(htmlResult.contains(subject));
|
||||
assertTrue(htmlResult.contains(body));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -92,7 +92,8 @@ public class WebResponseUtilsTest {
|
||||
|
||||
@Test
|
||||
public void testPdfDocToWebResponse() {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
try {
|
||||
PDDocument document = new PDDocument();
|
||||
document.addPage(new org.apache.pdfbox.pdmodel.PDPage());
|
||||
String docName = "sample.pdf";
|
||||
|
||||
|
||||
+1
-10
@@ -59,11 +59,10 @@ dependencies {
|
||||
implementation project(':common')
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jetty'
|
||||
implementation 'com.posthog.java:posthog:1.2.0'
|
||||
implementation 'org.telegram:telegrambots:6.9.7.1'
|
||||
implementation 'commons-io:commons-io:2.21.0'
|
||||
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
|
||||
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
|
||||
implementation 'io.micrometer:micrometer-core:1.16.1'
|
||||
implementation 'io.micrometer:micrometer-core:1.16.0'
|
||||
implementation 'com.google.zxing:core:3.5.4'
|
||||
implementation "org.commonmark:commonmark:$commonmarkVersion" // https://mvnrepository.com/artifact/org.commonmark/commonmark
|
||||
implementation "org.commonmark:commonmark-ext-gfm-tables:$commonmarkVersion"
|
||||
@@ -208,14 +207,6 @@ tasks.register('npmInstall', Exec) {
|
||||
println "node_modules not found, will install..."
|
||||
return true
|
||||
}
|
||||
|
||||
// if required devDependency is missing, reinstall
|
||||
def iconifyPkg = new File(frontendDir, 'node_modules/@iconify-json/material-symbols/package.json')
|
||||
if (!iconifyPkg.exists()) {
|
||||
println "@iconify-json/material-symbols missing, will reinstall..."
|
||||
return true
|
||||
}
|
||||
|
||||
def packageJson = new File(frontendDir, 'package.json')
|
||||
def packageLock = new File(frontendDir, 'package-lock.json')
|
||||
def isOutdated = nodeModules.lastModified() < packageJson.lastModified() ||
|
||||
|
||||
@@ -141,10 +141,10 @@ public class SPDFApplication {
|
||||
String backendUrl = appConfig.getBackendUrl();
|
||||
String contextPath = appConfig.getContextPath();
|
||||
String serverPort = appConfig.getServerPort();
|
||||
baseUrlStatic = normalizeBackendUrl(backendUrl, serverPort);
|
||||
baseUrlStatic = backendUrl;
|
||||
contextPathStatic = contextPath;
|
||||
serverPortStatic = serverPort;
|
||||
String url = buildFullUrl(baseUrlStatic, getStaticPort(), contextPathStatic);
|
||||
String url = backendUrl + ":" + getStaticPort() + contextPath;
|
||||
|
||||
// Log Tauri mode information
|
||||
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
|
||||
@@ -210,7 +210,7 @@ public class SPDFApplication {
|
||||
|
||||
private static void printStartupLogs() {
|
||||
log.info("Stirling-PDF Started.");
|
||||
String url = buildFullUrl(baseUrlStatic, getStaticPort(), contextPathStatic);
|
||||
String url = baseUrlStatic + ":" + getStaticPort() + contextPathStatic;
|
||||
log.info("Navigate to {}", url);
|
||||
}
|
||||
|
||||
@@ -258,79 +258,4 @@ public class SPDFApplication {
|
||||
public static String getStaticContextPath() {
|
||||
return contextPathStatic;
|
||||
}
|
||||
|
||||
private static String buildFullUrl(String backendUrl, String port, String contextPath) {
|
||||
String normalizedBase = normalizeBackendUrl(backendUrl, port);
|
||||
|
||||
String normalizedContextPath =
|
||||
(contextPath == null || contextPath.isBlank() || "/".equals(contextPath))
|
||||
? "/"
|
||||
: (contextPath.startsWith("/") ? contextPath : "/" + contextPath);
|
||||
|
||||
return normalizedBase + normalizedContextPath;
|
||||
}
|
||||
|
||||
private static String normalizeBackendUrl(String backendUrl, String port) {
|
||||
String trimmedBase =
|
||||
(backendUrl == null || backendUrl.isBlank())
|
||||
? "http://localhost"
|
||||
: backendUrl.trim().replaceAll("/+$", "");
|
||||
boolean hasScheme = trimmedBase.matches("^[a-zA-Z][a-zA-Z0-9+.-]*://.*");
|
||||
String baseForParsing = hasScheme ? trimmedBase : "http://" + trimmedBase;
|
||||
Integer parsedPort = parsePort(port);
|
||||
|
||||
try {
|
||||
java.net.URI uri = new java.net.URI(baseForParsing);
|
||||
String scheme = uri.getScheme() == null ? "http" : uri.getScheme();
|
||||
String host = uri.getHost();
|
||||
if (host == null) {
|
||||
return appendPortFallback(trimmedBase, parsedPort);
|
||||
}
|
||||
|
||||
boolean defaultHttp =
|
||||
"http".equalsIgnoreCase(scheme) && Integer.valueOf(80).equals(parsedPort);
|
||||
boolean defaultHttps =
|
||||
"https".equalsIgnoreCase(scheme) && Integer.valueOf(443).equals(parsedPort);
|
||||
|
||||
int effectivePort = uri.getPort();
|
||||
if (effectivePort == -1 && parsedPort != null && !defaultHttp && !defaultHttps) {
|
||||
effectivePort = parsedPort;
|
||||
}
|
||||
|
||||
java.net.URI rebuilt =
|
||||
new java.net.URI(
|
||||
scheme,
|
||||
uri.getUserInfo(),
|
||||
host,
|
||||
effectivePort,
|
||||
uri.getPath(),
|
||||
uri.getQuery(),
|
||||
uri.getFragment());
|
||||
return rebuilt.toString();
|
||||
} catch (java.net.URISyntaxException e) {
|
||||
return appendPortFallback(trimmedBase, parsedPort);
|
||||
}
|
||||
}
|
||||
|
||||
private static Integer parsePort(String port) {
|
||||
if (port == null || port.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
int parsed = Integer.parseInt(port);
|
||||
return parsed > 0 ? parsed : null;
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String appendPortFallback(String trimmedBase, Integer port) {
|
||||
if (port == null) {
|
||||
return trimmedBase;
|
||||
}
|
||||
if (trimmedBase.matches(".+:\\d+$")) {
|
||||
return trimmedBase;
|
||||
}
|
||||
return trimmedBase + ":" + port;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.telegram.telegrambots.meta.TelegramBotsApi;
|
||||
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
|
||||
import org.telegram.telegrambots.updatesreceivers.DefaultBotSession;
|
||||
|
||||
@Configuration
|
||||
@ConditionalOnProperty(prefix = "telegram", name = "enabled", havingValue = "true")
|
||||
public class TelegramBotConfig {
|
||||
|
||||
@Bean
|
||||
public TelegramBotsApi telegramBotsApi() throws TelegramApiException {
|
||||
return new TelegramBotsApi(DefaultBotSession.class);
|
||||
}
|
||||
}
|
||||
+24
-25
@@ -29,7 +29,6 @@ import lombok.RequiredArgsConstructor;
|
||||
import stirling.software.SPDF.model.api.general.BookletImpositionRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@@ -69,33 +68,33 @@ public class BookletImpositionController {
|
||||
"Booklet printing uses 2 pages per side (landscape). For 4-up, use the N-up feature.");
|
||||
}
|
||||
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
|
||||
// Create proper booklet with signature-based page ordering
|
||||
try (PDDocument newDocument =
|
||||
createSaddleBooklet(
|
||||
sourceDocument,
|
||||
totalPages,
|
||||
addBorder,
|
||||
spineLocation,
|
||||
addGutter,
|
||||
gutterSize,
|
||||
doubleSided,
|
||||
duplexPass,
|
||||
flipOnShortEdge)) {
|
||||
// Create proper booklet with signature-based page ordering
|
||||
PDDocument newDocument =
|
||||
createSaddleBooklet(
|
||||
sourceDocument,
|
||||
totalPages,
|
||||
addBorder,
|
||||
spineLocation,
|
||||
addGutter,
|
||||
gutterSize,
|
||||
doubleSided,
|
||||
duplexPass,
|
||||
flipOnShortEdge);
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
sourceDocument.close();
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
GeneralUtils.generateFilename(
|
||||
Filenames.toSimpleFileName(file.getOriginalFilename()),
|
||||
"_booklet.pdf"));
|
||||
}
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
newDocument.close();
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
Filenames.toSimpleFileName(file.getOriginalFilename()).replaceFirst("[.][^.]+$", "")
|
||||
+ "_booklet.pdf");
|
||||
}
|
||||
|
||||
private static int padToMultipleOf4(int n) {
|
||||
|
||||
@@ -155,7 +155,6 @@ public class CropController {
|
||||
try (PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
|
||||
PDFRenderer renderer = new PDFRenderer(sourceDocument);
|
||||
renderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
|
||||
for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) {
|
||||
|
||||
@@ -3,7 +3,6 @@ package stirling.software.SPDF.controller.api;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
@@ -28,6 +27,7 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
@@ -277,7 +277,7 @@ public class MergeController {
|
||||
"This endpoint merges multiple PDF files into a single PDF file. The merged"
|
||||
+ " file will contain all pages from the input files in the order they were"
|
||||
+ " provided. Input:PDF Output:PDF Type:MISO")
|
||||
public ResponseEntity<byte[]> mergePdfs(
|
||||
public ResponseEntity<StreamingResponseBody> mergePdfs(
|
||||
@ModelAttribute MergePdfsRequest request,
|
||||
@RequestParam(value = "fileOrder", required = false) String fileOrder)
|
||||
throws IOException {
|
||||
@@ -304,6 +304,8 @@ public class MergeController {
|
||||
request.getSortType())); // Sort files based on requested sort type
|
||||
}
|
||||
|
||||
ResponseEntity<StreamingResponseBody> response;
|
||||
|
||||
try (TempFile mt = new TempFile(tempFileManager, ".pdf")) {
|
||||
|
||||
PDFMergerUtility mergerUtility = new PDFMergerUtility();
|
||||
@@ -397,7 +399,7 @@ public class MergeController {
|
||||
String mergedFileName =
|
||||
GeneralUtils.generateFilename(firstFilename, "_merged_unsigned.pdf");
|
||||
|
||||
byte[] pdfBytes = Files.readAllBytes(outputTempFile.getPath());
|
||||
return WebResponseUtils.bytesToWebResponse(pdfBytes, mergedFileName);
|
||||
response = WebResponseUtils.pdfFileToWebResponse(outputTempFile, mergedFileName);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
+97
-98
@@ -70,109 +70,108 @@ public class MultiPageLayoutController {
|
||||
: (int) Math.sqrt(pagesPerSheet);
|
||||
int rows = pagesPerSheet == 2 || pagesPerSheet == 3 ? 1 : (int) Math.sqrt(pagesPerSheet);
|
||||
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
|
||||
try (PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
PDPage newPage = new PDPage(PDRectangle.A4);
|
||||
newDocument.addPage(newPage);
|
||||
|
||||
// Calculate cell dimensions once (all output pages are A4) - declare outside try
|
||||
// blocks
|
||||
float cellWidth = PDRectangle.A4.getWidth() / cols;
|
||||
float cellHeight = PDRectangle.A4.getHeight() / rows;
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
float cellWidth = newPage.getMediaBox().getWidth() / cols;
|
||||
float cellHeight = newPage.getMediaBox().getHeight() / rows;
|
||||
|
||||
// Process pages in groups of pagesPerSheet, creating a new page and content stream
|
||||
// for each group
|
||||
for (int i = 0; i < totalPages; i += pagesPerSheet) {
|
||||
// Create a new output page for each group of pagesPerSheet
|
||||
PDPage newPage = new PDPage(PDRectangle.A4);
|
||||
newDocument.addPage(newPage);
|
||||
PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
newDocument, newPage, PDPageContentStream.AppendMode.APPEND, true, true);
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
|
||||
// Use try-with-resources for each content stream to ensure proper cleanup
|
||||
// resetContext=true: Start with a clean graphics state for new content
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
newDocument,
|
||||
newPage,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true)) {
|
||||
float borderThickness = 1.5f; // Specify border thickness as required
|
||||
contentStream.setLineWidth(borderThickness);
|
||||
contentStream.setStrokingColor(Color.BLACK);
|
||||
float borderThickness = 1.5f; // Specify border thickness as required
|
||||
contentStream.setLineWidth(borderThickness);
|
||||
contentStream.setStrokingColor(Color.BLACK);
|
||||
|
||||
// Process all pages in this group
|
||||
for (int j = 0; j < pagesPerSheet && (i + j) < totalPages; j++) {
|
||||
int pageIndex = i + j;
|
||||
PDPage sourcePage = sourceDocument.getPage(pageIndex);
|
||||
PDRectangle rect = sourcePage.getMediaBox();
|
||||
float scaleWidth = cellWidth / rect.getWidth();
|
||||
float scaleHeight = cellHeight / rect.getHeight();
|
||||
float scale = Math.min(scaleWidth, scaleHeight);
|
||||
|
||||
int adjustedPageIndex = j % pagesPerSheet;
|
||||
int rowIndex = adjustedPageIndex / cols;
|
||||
int colIndex = adjustedPageIndex % cols;
|
||||
|
||||
float x =
|
||||
colIndex * cellWidth
|
||||
+ (cellWidth - rect.getWidth() * scale) / 2;
|
||||
float y =
|
||||
newPage.getMediaBox().getHeight()
|
||||
- ((rowIndex + 1) * cellHeight
|
||||
- (cellHeight - rect.getHeight() * scale) / 2);
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.transform(Matrix.getTranslateInstance(x, y));
|
||||
contentStream.transform(Matrix.getScaleInstance(scale, scale));
|
||||
|
||||
PDFormXObject formXObject =
|
||||
layerUtility.importPageAsForm(sourceDocument, pageIndex);
|
||||
contentStream.drawForm(formXObject);
|
||||
|
||||
contentStream.restoreGraphicsState();
|
||||
|
||||
if (addBorder) {
|
||||
// Draw border around each page
|
||||
float borderX = colIndex * cellWidth;
|
||||
float borderY =
|
||||
newPage.getMediaBox().getHeight()
|
||||
- (rowIndex + 1) * cellHeight;
|
||||
contentStream.addRect(borderX, borderY, cellWidth, cellHeight);
|
||||
contentStream.stroke();
|
||||
}
|
||||
}
|
||||
} // contentStream is automatically closed here
|
||||
}
|
||||
|
||||
// If any source page is rotated, skip form copying/transformation entirely
|
||||
boolean hasRotation = GeneralFormCopyUtils.hasAnyRotatedPage(sourceDocument);
|
||||
if (hasRotation) {
|
||||
log.info("Source document has rotated pages; skipping form field copying.");
|
||||
} else {
|
||||
try {
|
||||
GeneralFormCopyUtils.copyAndTransformFormFields(
|
||||
sourceDocument,
|
||||
for (int i = 0; i < totalPages; i++) {
|
||||
if (i != 0 && i % pagesPerSheet == 0) {
|
||||
// Close the current content stream and create a new page and content stream
|
||||
contentStream.close();
|
||||
newPage = new PDPage(PDRectangle.A4);
|
||||
newDocument.addPage(newPage);
|
||||
contentStream =
|
||||
new PDPageContentStream(
|
||||
newDocument,
|
||||
totalPages,
|
||||
pagesPerSheet,
|
||||
cols,
|
||||
rows,
|
||||
cellWidth,
|
||||
cellHeight);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to copy and transform form fields: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
newPage,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true);
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
byte[] result = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
GeneralUtils.generateFilename(
|
||||
file.getOriginalFilename(), "_multi_page_layout.pdf"));
|
||||
} // newDocument is closed here
|
||||
} // sourceDocument is closed here
|
||||
PDPage sourcePage = sourceDocument.getPage(i);
|
||||
PDRectangle rect = sourcePage.getMediaBox();
|
||||
float scaleWidth = cellWidth / rect.getWidth();
|
||||
float scaleHeight = cellHeight / rect.getHeight();
|
||||
float scale = Math.min(scaleWidth, scaleHeight);
|
||||
|
||||
int adjustedPageIndex =
|
||||
i % pagesPerSheet; // Close the current content stream and create a new
|
||||
// page and content stream
|
||||
int rowIndex = adjustedPageIndex / cols;
|
||||
int colIndex = adjustedPageIndex % cols;
|
||||
|
||||
float x = colIndex * cellWidth + (cellWidth - rect.getWidth() * scale) / 2;
|
||||
float y =
|
||||
newPage.getMediaBox().getHeight()
|
||||
- ((rowIndex + 1) * cellHeight
|
||||
- (cellHeight - rect.getHeight() * scale) / 2);
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.transform(Matrix.getTranslateInstance(x, y));
|
||||
contentStream.transform(Matrix.getScaleInstance(scale, scale));
|
||||
|
||||
PDFormXObject formXObject = layerUtility.importPageAsForm(sourceDocument, i);
|
||||
contentStream.drawForm(formXObject);
|
||||
|
||||
contentStream.restoreGraphicsState();
|
||||
|
||||
if (addBorder) {
|
||||
// Draw border around each page
|
||||
float borderX = colIndex * cellWidth;
|
||||
float borderY = newPage.getMediaBox().getHeight() - (rowIndex + 1) * cellHeight;
|
||||
contentStream.addRect(borderX, borderY, cellWidth, cellHeight);
|
||||
contentStream.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
contentStream.close();
|
||||
|
||||
// If any source page is rotated, skip form copying/transformation entirely
|
||||
boolean hasRotation = GeneralFormCopyUtils.hasAnyRotatedPage(sourceDocument);
|
||||
if (hasRotation) {
|
||||
log.info("Source document has rotated pages; skipping form field copying.");
|
||||
} else {
|
||||
try {
|
||||
GeneralFormCopyUtils.copyAndTransformFormFields(
|
||||
sourceDocument,
|
||||
newDocument,
|
||||
totalPages,
|
||||
pagesPerSheet,
|
||||
cols,
|
||||
rows,
|
||||
cellWidth,
|
||||
cellHeight);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to copy and transform form fields: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
sourceDocument.close();
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
newDocument.close();
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
GeneralUtils.generateFilename(
|
||||
file.getOriginalFilename(), "_multi_page_layout.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
+15
-18
@@ -53,28 +53,25 @@ public class PdfImageRemovalController {
|
||||
"This endpoint remove images from file to reduce the file size.Input:PDF"
|
||||
+ " Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> removeImages(@ModelAttribute PDFFile file) throws IOException {
|
||||
// Load the PDF document with proper resource management
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
// Load the PDF document
|
||||
PDDocument document = pdfDocumentFactory.load(file);
|
||||
|
||||
// Remove images from the PDF document using the service
|
||||
try (PDDocument modifiedDocument =
|
||||
pdfImageRemovalService.removeImagesFromPdf(document)) {
|
||||
// Remove images from the PDF document using the service
|
||||
PDDocument modifiedDocument = pdfImageRemovalService.removeImagesFromPdf(document);
|
||||
|
||||
// Create a ByteArrayOutputStream to hold the modified PDF data
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
// Create a ByteArrayOutputStream to hold the modified PDF data
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
|
||||
// Save the modified PDF document to the output stream
|
||||
modifiedDocument.save(outputStream);
|
||||
// Save the modified PDF document to the output stream
|
||||
modifiedDocument.save(outputStream);
|
||||
modifiedDocument.close();
|
||||
|
||||
// Generate a new filename for the modified PDF
|
||||
String mergedFileName =
|
||||
GeneralUtils.generateFilename(
|
||||
file.getFileInput().getOriginalFilename(), "_images_removed.pdf");
|
||||
// Generate a new filename for the modified PDF
|
||||
String mergedFileName =
|
||||
GeneralUtils.generateFilename(
|
||||
file.getFileInput().getOriginalFilename(), "_images_removed.pdf");
|
||||
|
||||
// Convert the byte array to a web response and return it
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
outputStream.toByteArray(), mergedFileName);
|
||||
}
|
||||
}
|
||||
// Convert the byte array to a web response and return it
|
||||
return WebResponseUtils.bytesToWebResponse(outputStream.toByteArray(), mergedFileName);
|
||||
}
|
||||
}
|
||||
|
||||
+45
-49
@@ -50,25 +50,23 @@ public class RearrangePagesPDFController {
|
||||
MultipartFile pdfFile = request.getFileInput();
|
||||
String pagesToDelete = request.getPageNumbers();
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
|
||||
PDDocument document = pdfDocumentFactory.load(pdfFile);
|
||||
|
||||
// Split the page order string into an array of page numbers or range of numbers
|
||||
String[] pageOrderArr = pagesToDelete.split(",");
|
||||
// Split the page order string into an array of page numbers or range of numbers
|
||||
String[] pageOrderArr = pagesToDelete.split(",");
|
||||
|
||||
List<Integer> pagesToRemove =
|
||||
GeneralUtils.parsePageList(pageOrderArr, document.getNumberOfPages(), false);
|
||||
List<Integer> pagesToRemove =
|
||||
GeneralUtils.parsePageList(pageOrderArr, document.getNumberOfPages(), false);
|
||||
|
||||
Collections.sort(pagesToRemove);
|
||||
Collections.sort(pagesToRemove);
|
||||
|
||||
for (int i = pagesToRemove.size() - 1; i >= 0; i--) {
|
||||
int pageIndex = pagesToRemove.get(i);
|
||||
document.removePage(pageIndex);
|
||||
}
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
pdfFile.getOriginalFilename(), "_removed_pages.pdf"));
|
||||
for (int i = pagesToRemove.size() - 1; i >= 0; i--) {
|
||||
int pageIndex = pagesToRemove.get(i);
|
||||
document.removePage(pageIndex);
|
||||
}
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_removed_pages.pdf"));
|
||||
}
|
||||
|
||||
private List<Integer> removeFirst(int totalPages) {
|
||||
@@ -245,43 +243,41 @@ public class RearrangePagesPDFController {
|
||||
String pageOrder = request.getPageNumbers();
|
||||
String sortType = request.getCustomMode();
|
||||
try {
|
||||
// Load the input PDF with proper resource management
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
|
||||
// Load the input PDF
|
||||
PDDocument document = pdfDocumentFactory.load(pdfFile);
|
||||
|
||||
// Split the page order string into an array of page numbers or range of numbers
|
||||
String[] pageOrderArr = pageOrder != null ? pageOrder.split(",") : new String[0];
|
||||
int totalPages = document.getNumberOfPages();
|
||||
List<Integer> newPageOrder;
|
||||
if (sortType != null
|
||||
&& !sortType.isEmpty()
|
||||
&& !"custom".equals(sortType.toLowerCase(Locale.ROOT))) {
|
||||
newPageOrder = processSortTypes(sortType, totalPages, pageOrder);
|
||||
} else {
|
||||
newPageOrder = GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
|
||||
}
|
||||
log.info("newPageOrder = {}", newPageOrder);
|
||||
log.info("totalPages = {}", totalPages);
|
||||
// Create a new list to hold the pages in the new order
|
||||
List<PDPage> newPages = new ArrayList<>();
|
||||
for (int i = 0; i < newPageOrder.size(); i++) {
|
||||
newPages.add(document.getPage(newPageOrder.get(i)));
|
||||
}
|
||||
|
||||
// Create a new document based on the original one
|
||||
try (PDDocument rearrangedDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document)) {
|
||||
|
||||
// Add the pages in the new order
|
||||
for (PDPage page : newPages) {
|
||||
rearrangedDocument.addPage(page);
|
||||
}
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
rearrangedDocument,
|
||||
GeneralUtils.generateFilename(
|
||||
pdfFile.getOriginalFilename(), "_rearranged.pdf"));
|
||||
}
|
||||
// Split the page order string into an array of page numbers or range of numbers
|
||||
String[] pageOrderArr = pageOrder != null ? pageOrder.split(",") : new String[0];
|
||||
int totalPages = document.getNumberOfPages();
|
||||
List<Integer> newPageOrder;
|
||||
if (sortType != null
|
||||
&& !sortType.isEmpty()
|
||||
&& !"custom".equals(sortType.toLowerCase(Locale.ROOT))) {
|
||||
newPageOrder = processSortTypes(sortType, totalPages, pageOrder);
|
||||
} else {
|
||||
newPageOrder = GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
|
||||
}
|
||||
log.info("newPageOrder = {}", newPageOrder);
|
||||
log.info("totalPages = {}", totalPages);
|
||||
// Create a new list to hold the pages in the new order
|
||||
List<PDPage> newPages = new ArrayList<>();
|
||||
for (int i = 0; i < newPageOrder.size(); i++) {
|
||||
newPages.add(document.getPage(newPageOrder.get(i)));
|
||||
}
|
||||
|
||||
// Create a new document based on the original one
|
||||
PDDocument rearrangedDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document);
|
||||
|
||||
// Add the pages in the new order
|
||||
for (PDPage page : newPages) {
|
||||
rearrangedDocument.addPage(page);
|
||||
}
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
rearrangedDocument,
|
||||
GeneralUtils.generateFilename(
|
||||
pdfFile.getOriginalFilename(), "_rearranged.pdf"));
|
||||
} catch (IOException e) {
|
||||
ExceptionUtils.logException("document rearrangement", e);
|
||||
throw e;
|
||||
|
||||
+11
-12
@@ -47,20 +47,19 @@ public class RotationController {
|
||||
"error.angleNotMultipleOf90", "Angle must be a multiple of 90");
|
||||
}
|
||||
|
||||
// Load the PDF document with proper resource management
|
||||
try (PDDocument document = pdfDocumentFactory.load(request)) {
|
||||
// Load the PDF document
|
||||
PDDocument document = pdfDocumentFactory.load(request);
|
||||
|
||||
// Get the list of pages in the document
|
||||
PDPageTree pages = document.getPages();
|
||||
// Get the list of pages in the document
|
||||
PDPageTree pages = document.getPages();
|
||||
|
||||
for (PDPage page : pages) {
|
||||
page.setRotation(page.getRotation() + angle);
|
||||
}
|
||||
|
||||
// Return the rotated PDF as a response
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_rotated.pdf"));
|
||||
for (PDPage page : pages) {
|
||||
page.setRotation(page.getRotation() + angle);
|
||||
}
|
||||
|
||||
// Return the rotated PDF as a response
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_rotated.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
+4
-7
@@ -32,19 +32,16 @@ public class SettingsController {
|
||||
|
||||
@AutoJobPostMapping("/update-enable-analytics")
|
||||
@Hidden
|
||||
public ResponseEntity<Map<String, Object>> updateApiKey(@RequestParam Boolean enabled)
|
||||
throws IOException {
|
||||
public ResponseEntity<String> updateApiKey(@RequestParam Boolean enabled) throws IOException {
|
||||
if (applicationProperties.getSystem().getEnableAnalytics() != null) {
|
||||
return ResponseEntity.status(HttpStatus.ALREADY_REPORTED)
|
||||
.body(
|
||||
Map.of(
|
||||
"message",
|
||||
"Setting has already been set, To adjust please edit "
|
||||
+ InstallationPathConfig.getSettingsPath()));
|
||||
"Setting has already been set, To adjust please edit "
|
||||
+ InstallationPathConfig.getSettingsPath());
|
||||
}
|
||||
GeneralUtils.saveKeyToSettings("system.enableAnalytics", enabled);
|
||||
applicationProperties.getSystem().setEnableAnalytics(enabled);
|
||||
return ResponseEntity.ok(Map.of("message", "Updated"));
|
||||
return ResponseEntity.ok("Updated");
|
||||
}
|
||||
|
||||
@GetMapping("/get-endpoints-status")
|
||||
|
||||
+48
-95
@@ -4,7 +4,6 @@ import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.*;
|
||||
import java.util.stream.IntStream;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@@ -19,18 +18,18 @@ import org.apache.pdfbox.util.Matrix;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.MultiFileResponse;
|
||||
import stirling.software.SPDF.model.SplitTypes;
|
||||
import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
@@ -38,18 +37,16 @@ import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@GeneralApi
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class SplitPdfBySectionsController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/split-pdf-by-sections")
|
||||
@MultiFileResponse
|
||||
@PostMapping(value = "/split-pdf-by-sections", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Split PDF pages into smaller sections",
|
||||
description =
|
||||
@@ -57,7 +54,7 @@ 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<byte[]> splitPdf(@ModelAttribute SplitPdfBySectionsRequest request)
|
||||
public ResponseEntity<?> splitPdf(@ModelAttribute SplitPdfBySectionsRequest request)
|
||||
throws Exception {
|
||||
MultipartFile file = request.getFileInput();
|
||||
String pageNumbers = request.getPageNumbers();
|
||||
@@ -66,9 +63,10 @@ public class SplitPdfBySectionsController {
|
||||
.map(SplitTypes::valueOf)
|
||||
.orElse(SplitTypes.SPLIT_ALL);
|
||||
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
|
||||
Set<Integer> pagesToSplit =
|
||||
getPagesToSplit(pageNumbers, splitMode, sourceDocument.getNumberOfPages());
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file, true)) {
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
Set<Integer> customPagesToSplit =
|
||||
getCustomPagesToSplit(pageNumbers, splitMode, totalPages);
|
||||
|
||||
// Process the PDF based on split parameters
|
||||
int horiz = request.getHorizontalDivisions() + 1;
|
||||
@@ -82,10 +80,8 @@ public class SplitPdfBySectionsController {
|
||||
sourceDocument);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
LayerUtility layerUtility = new LayerUtility(mergedDoc);
|
||||
for (int pageIndex = 0;
|
||||
pageIndex < sourceDocument.getNumberOfPages();
|
||||
pageIndex++) {
|
||||
if (pagesToSplit.contains(pageIndex)) {
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
if (shouldSplitPage(pageIndex, totalPages, splitMode, customPagesToSplit)) {
|
||||
addSplitPageToTarget(
|
||||
sourceDocument,
|
||||
pageIndex,
|
||||
@@ -104,11 +100,9 @@ public class SplitPdfBySectionsController {
|
||||
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
|
||||
try (ZipOutputStream zipOut =
|
||||
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
|
||||
for (int pageIndex = 0;
|
||||
pageIndex < sourceDocument.getNumberOfPages();
|
||||
pageIndex++) {
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
int pageNum = pageIndex + 1;
|
||||
if (pagesToSplit.contains(pageIndex)) {
|
||||
if (shouldSplitPage(pageIndex, totalPages, splitMode, customPagesToSplit)) {
|
||||
for (int i = 0; i < horiz; i++) {
|
||||
for (int j = 0; j < verti; j++) {
|
||||
try (PDDocument subDoc =
|
||||
@@ -132,13 +126,6 @@ public class SplitPdfBySectionsController {
|
||||
+ sectionNum
|
||||
+ ".pdf";
|
||||
saveDocToZip(subDoc, zipOut, entryName);
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
"Error creating section {} for page {}",
|
||||
(i * verti + j + 1),
|
||||
pageNum,
|
||||
e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,23 +135,12 @@ public class SplitPdfBySectionsController {
|
||||
addPageToTarget(sourceDocument, pageIndex, subDoc, subLayerUtility);
|
||||
String entryName = filename + "_" + pageNum + "_1.pdf";
|
||||
saveDocToZip(subDoc, zipOut, entryName);
|
||||
} catch (IOException e) {
|
||||
log.error("Error processing unsplit page {}", pageNum, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Error creating ZIP file with split PDF sections", e);
|
||||
throw e;
|
||||
}
|
||||
byte[] zipBytes = Files.readAllBytes(zipTempFile.getPath());
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
zipBytes, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
|
||||
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + ".zip");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error splitting PDF file: {}", file.getOriginalFilename(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,9 +155,6 @@ public class SplitPdfBySectionsController {
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(targetDoc, newPage, AppendMode.APPEND, true, true)) {
|
||||
contentStream.drawForm(form);
|
||||
} catch (IOException e) {
|
||||
log.error("Error adding page {} to target document", pageIndex, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,10 +192,6 @@ public class SplitPdfBySectionsController {
|
||||
contentStream.transform(new Matrix(1, 0, 0, 1, translateX, translateY));
|
||||
contentStream.drawForm(form);
|
||||
contentStream.restoreGraphicsState();
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
"Error adding split section ({}, {}) for page {}", i, j, pageIndex, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -259,14 +228,6 @@ public class SplitPdfBySectionsController {
|
||||
contentStream.transform(new Matrix(1, 0, 0, 1, translateX, translateY));
|
||||
contentStream.drawForm(form);
|
||||
contentStream.restoreGraphicsState();
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
"Error adding single section ({}, {}) for page {} to target",
|
||||
horizIndex,
|
||||
vertIndex,
|
||||
pageIndex,
|
||||
e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,45 +240,37 @@ public class SplitPdfBySectionsController {
|
||||
}
|
||||
|
||||
// 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:
|
||||
pagesToSplit.addAll(IntStream.range(0, totalPages).boxed().toList());
|
||||
break;
|
||||
|
||||
case SPLIT_ALL_EXCEPT_FIRST:
|
||||
pagesToSplit.addAll(IntStream.range(1, totalPages).boxed().toList());
|
||||
break;
|
||||
|
||||
case SPLIT_ALL_EXCEPT_LAST:
|
||||
pagesToSplit.addAll(IntStream.range(0, totalPages - 1).boxed().toList());
|
||||
break;
|
||||
|
||||
case SPLIT_ALL_EXCEPT_FIRST_AND_LAST:
|
||||
pagesToSplit.addAll(IntStream.range(1, totalPages - 1).boxed().toList());
|
||||
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();
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
return pagesToSplit;
|
||||
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);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+24
-26
@@ -473,36 +473,34 @@ public class SplitPdfBySizeController {
|
||||
PDDocument document, ZipOutputStream zipOut, String baseFilename, int index)
|
||||
throws IOException {
|
||||
log.debug("Starting saveDocumentToZip for document part {}", index);
|
||||
try (ByteArrayOutputStream outStream = new ByteArrayOutputStream()) {
|
||||
ByteArrayOutputStream outStream = new ByteArrayOutputStream();
|
||||
|
||||
try (PDDocument doc = document) {
|
||||
log.debug("Saving document part {} to byte array", index);
|
||||
doc.save(outStream);
|
||||
log.debug(
|
||||
"Successfully saved document part {} ({} bytes)", index, outStream.size());
|
||||
} catch (Exception e) {
|
||||
log.error("Error saving document part {} to byte array", index, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
try (PDDocument doc = document) {
|
||||
log.debug("Saving document part {} to byte array", index);
|
||||
doc.save(outStream);
|
||||
log.debug("Successfully saved document part {} ({} bytes)", index, outStream.size());
|
||||
} catch (Exception e) {
|
||||
log.error("Error saving document part {} to byte array", index, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
|
||||
try {
|
||||
// Create a new zip entry
|
||||
String entryName = baseFilename + "_" + index + ".pdf";
|
||||
log.debug("Creating ZIP entry: {}", entryName);
|
||||
ZipEntry zipEntry = new ZipEntry(entryName);
|
||||
zipOut.putNextEntry(zipEntry);
|
||||
try {
|
||||
// Create a new zip entry
|
||||
String entryName = baseFilename + "_" + index + ".pdf";
|
||||
log.debug("Creating ZIP entry: {}", entryName);
|
||||
ZipEntry zipEntry = new ZipEntry(entryName);
|
||||
zipOut.putNextEntry(zipEntry);
|
||||
|
||||
byte[] bytes = outStream.toByteArray();
|
||||
log.debug("Writing {} bytes to ZIP entry", bytes.length);
|
||||
zipOut.write(bytes);
|
||||
byte[] bytes = outStream.toByteArray();
|
||||
log.debug("Writing {} bytes to ZIP entry", bytes.length);
|
||||
zipOut.write(bytes);
|
||||
|
||||
log.debug("Closing ZIP entry");
|
||||
zipOut.closeEntry();
|
||||
log.debug("Successfully added document part {} to ZIP", index);
|
||||
} catch (Exception e) {
|
||||
log.error("Error adding document part {} to ZIP", index, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
log.debug("Closing ZIP entry");
|
||||
zipOut.closeEntry();
|
||||
log.debug("Successfully added document part {} to ZIP", index);
|
||||
} catch (Exception e) {
|
||||
log.error("Error adding document part {} to ZIP", index, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ public class UIDataController {
|
||||
}
|
||||
|
||||
private List<String> getAvailableTesseractLanguages() {
|
||||
String tessdataDir = runtimePathConfig.getTessDataPath();
|
||||
String tessdataDir = applicationProperties.getSystem().getTessdataDir();
|
||||
java.io.File[] files = new java.io.File(tessdataDir).listFiles();
|
||||
if (files == null) {
|
||||
return Collections.emptyList();
|
||||
|
||||
+24
-28
@@ -10,7 +10,6 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.util.HtmlUtils;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -42,12 +41,12 @@ public class ConvertEmlToPDF {
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/eml/pdf")
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Convert EML/MSG to PDF",
|
||||
summary = "Convert EML to PDF",
|
||||
description =
|
||||
"This endpoint converts EML (email) and MSG (Outlook) files to PDF format"
|
||||
+ " with extensive customization options. Features include font settings,"
|
||||
+ " image constraints, display modes, attachment handling, and HTML debug"
|
||||
+ " output. Input: EML or MSG file, Output: PDF or HTML file. Type: SISO")
|
||||
"This endpoint converts EML (email) files to PDF format with extensive"
|
||||
+ " customization options. Features include font settings, image"
|
||||
+ " constraints, display modes, attachment handling, and HTML debug output."
|
||||
+ " Input: EML file, Output: PDF or HTML file. Type: SISO")
|
||||
public ResponseEntity<byte[]> convertEmlToPdf(@ModelAttribute EmlToPdfRequest request) {
|
||||
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
@@ -55,7 +54,7 @@ public class ConvertEmlToPDF {
|
||||
|
||||
// Validate input
|
||||
if (inputFile.isEmpty()) {
|
||||
log.error("No file provided for EML/MSG to PDF conversion.");
|
||||
log.error("No file provided for EML to PDF conversion.");
|
||||
return ResponseEntity.badRequest()
|
||||
.body("No file provided".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
@@ -66,12 +65,12 @@ public class ConvertEmlToPDF {
|
||||
.body("Please provide a valid filename".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
// Validate file type - support EML and MSG (Outlook) files
|
||||
// Validate file type - support EML
|
||||
String lowerFilename = originalFilename.toLowerCase(Locale.ROOT);
|
||||
if (!lowerFilename.endsWith(".eml") && !lowerFilename.endsWith(".msg")) {
|
||||
log.error("Invalid file type for EML/MSG to PDF: {}", originalFilename);
|
||||
if (!lowerFilename.endsWith(".eml")) {
|
||||
log.error("Invalid file type for EML to PDF: {}", originalFilename);
|
||||
return ResponseEntity.badRequest()
|
||||
.body("Please upload a valid EML or MSG file".getBytes(StandardCharsets.UTF_8));
|
||||
.body("Please upload a valid EML file".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
String baseFilename = Filenames.toSimpleFileName(originalFilename); // Use Filenames utility
|
||||
@@ -82,7 +81,7 @@ public class ConvertEmlToPDF {
|
||||
if (request.isDownloadHtml()) {
|
||||
try {
|
||||
String htmlContent = EmlToPdf.convertEmlToHtml(fileBytes, request);
|
||||
log.info("Successfully converted email to HTML: {}", originalFilename);
|
||||
log.info("Successfully converted EML to HTML: {}", originalFilename);
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
htmlContent.getBytes(StandardCharsets.UTF_8),
|
||||
baseFilename + ".html",
|
||||
@@ -96,11 +95,12 @@ public class ConvertEmlToPDF {
|
||||
}
|
||||
}
|
||||
|
||||
// Convert EML/MSG to PDF with enhanced options
|
||||
// Convert EML to PDF with enhanced options
|
||||
try {
|
||||
byte[] pdfBytes =
|
||||
EmlToPdf.convertEmlToPdf(
|
||||
runtimePathConfig.getWeasyPrintPath(),
|
||||
runtimePathConfig
|
||||
.getWeasyPrintPath(), // Use configured WeasyPrint path
|
||||
request,
|
||||
fileBytes,
|
||||
originalFilename,
|
||||
@@ -115,19 +115,19 @@ public class ConvertEmlToPDF {
|
||||
"PDF conversion failed - empty output"
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
log.info("Successfully converted email to PDF: {}", originalFilename);
|
||||
log.info("Successfully converted EML to PDF: {}", originalFilename);
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
pdfBytes, baseFilename + ".pdf", MediaType.APPLICATION_PDF);
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.error("Email to PDF conversion was interrupted for {}", originalFilename, e);
|
||||
log.error("EML to PDF conversion was interrupted for {}", originalFilename, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("Conversion was interrupted".getBytes(StandardCharsets.UTF_8));
|
||||
} catch (IllegalArgumentException e) {
|
||||
String errorMessage = buildErrorMessage(e, originalFilename);
|
||||
log.error(
|
||||
"Email to PDF conversion failed for {}: {}",
|
||||
"EML to PDF conversion failed for {}: {}",
|
||||
originalFilename,
|
||||
errorMessage,
|
||||
e);
|
||||
@@ -136,7 +136,7 @@ public class ConvertEmlToPDF {
|
||||
} catch (RuntimeException e) {
|
||||
String errorMessage = buildErrorMessage(e, originalFilename);
|
||||
log.error(
|
||||
"Email to PDF conversion failed for {}: {}",
|
||||
"EML to PDF conversion failed for {}: {}",
|
||||
originalFilename,
|
||||
errorMessage,
|
||||
e);
|
||||
@@ -145,31 +145,27 @@ public class ConvertEmlToPDF {
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("File processing error for email to PDF: {}", originalFilename, e);
|
||||
log.error("File processing error for EML to PDF: {}", originalFilename, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("File processing error".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private static @NotNull String buildErrorMessage(Exception e, String originalFilename) {
|
||||
String safeFilename = HtmlUtils.htmlEscape(originalFilename);
|
||||
String exceptionMessage = e.getMessage();
|
||||
String safeExceptionMessage =
|
||||
exceptionMessage == null ? "Unknown error" : HtmlUtils.htmlEscape(exceptionMessage);
|
||||
String errorMessage;
|
||||
if (exceptionMessage != null && exceptionMessage.contains("Invalid EML")) {
|
||||
if (e.getMessage() != null && e.getMessage().contains("Invalid EML")) {
|
||||
errorMessage =
|
||||
"Invalid EML file format. Please ensure you've uploaded a valid email"
|
||||
+ " file ("
|
||||
+ safeFilename
|
||||
+ originalFilename
|
||||
+ ").";
|
||||
} else if (exceptionMessage != null && exceptionMessage.contains("WeasyPrint")) {
|
||||
} else if (e.getMessage() != null && e.getMessage().contains("WeasyPrint")) {
|
||||
errorMessage =
|
||||
"PDF generation failed for "
|
||||
+ safeFilename
|
||||
+ originalFilename
|
||||
+ ". This may be due to complex email formatting.";
|
||||
} else {
|
||||
errorMessage = "Conversion failed for " + safeFilename + ": " + safeExceptionMessage;
|
||||
errorMessage = "Conversion failed for " + originalFilename + ": " + e.getMessage();
|
||||
}
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
+3
-3
@@ -188,15 +188,15 @@ public class ConvertImgPDFController {
|
||||
bodyBytes = Files.readAllBytes(webpFilePath);
|
||||
} else {
|
||||
// Create a ZIP file containing all WebP images
|
||||
try (ByteArrayOutputStream zipOutputStream = new ByteArrayOutputStream();
|
||||
ZipOutputStream zos = new ZipOutputStream(zipOutputStream)) {
|
||||
ByteArrayOutputStream zipOutputStream = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(zipOutputStream)) {
|
||||
for (Path webpFile : webpFiles) {
|
||||
zos.putNextEntry(new ZipEntry(webpFile.getFileName().toString()));
|
||||
Files.copy(webpFile, zos);
|
||||
zos.closeEntry();
|
||||
}
|
||||
bodyBytes = zipOutputStream.toByteArray();
|
||||
}
|
||||
bodyBytes = zipOutputStream.toByteArray();
|
||||
}
|
||||
// Clean up the temporary files
|
||||
Files.deleteIfExists(tempFile);
|
||||
|
||||
+43
-41
@@ -36,8 +36,6 @@ import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
import stirling.software.common.util.TempDirectory;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@ConvertApi
|
||||
@@ -49,7 +47,6 @@ public class ConvertOfficeController {
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
private final CustomHtmlSanitizer customHtmlSanitizer;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
private boolean isUnoconvertAvailable() {
|
||||
return endpointConfiguration.isGroupEnabled("Unoconvert")
|
||||
@@ -71,24 +68,29 @@ public class ConvertOfficeController {
|
||||
}
|
||||
String extensionLower = extension.toLowerCase(Locale.ROOT);
|
||||
|
||||
// Create work directory - caller (processFileToPDF) is responsible for cleanup
|
||||
String baseName = FilenameUtils.getBaseName(originalFilename);
|
||||
if (baseName == null || baseName.isBlank()) {
|
||||
baseName = "input";
|
||||
}
|
||||
|
||||
// create temporary working directory
|
||||
Path workDir = Files.createTempDirectory("office2pdf_");
|
||||
Path inputPath = Files.createTempFile(workDir, "input_", "." + extensionLower);
|
||||
Path outputPath = Files.createTempFile(workDir, "output_", ".pdf");
|
||||
Path inputPath = workDir.resolve(baseName + "." + extensionLower);
|
||||
Path outputPath = workDir.resolve(baseName + ".pdf");
|
||||
|
||||
// Check if the file is HTML and apply sanitization if needed
|
||||
if ("html".equals(extensionLower) || "htm".equals(extensionLower)) {
|
||||
// Read and sanitize HTML content
|
||||
String htmlContent = new String(inputFile.getBytes(), StandardCharsets.UTF_8);
|
||||
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlContent);
|
||||
Files.writeString(inputPath, sanitizedHtml, StandardCharsets.UTF_8);
|
||||
} else {
|
||||
// copy file content
|
||||
Files.copy(inputFile.getInputStream(), inputPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
Path libreOfficeProfile = null;
|
||||
try {
|
||||
// Check if the file is HTML and apply sanitization if needed
|
||||
if ("html".equals(extensionLower) || "htm".equals(extensionLower)) {
|
||||
// Read and sanitize HTML content
|
||||
String htmlContent = new String(inputFile.getBytes(), StandardCharsets.UTF_8);
|
||||
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlContent);
|
||||
Files.writeString(inputPath, sanitizedHtml, StandardCharsets.UTF_8);
|
||||
} else {
|
||||
// copy file content
|
||||
Files.copy(
|
||||
inputFile.getInputStream(), inputPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
ProcessExecutorResult result;
|
||||
// Run Unoconvert command
|
||||
if (isUnoconvertAvailable()) {
|
||||
@@ -107,23 +109,21 @@ public class ConvertOfficeController {
|
||||
.runCommandWithOutputHandling(command);
|
||||
} // Run soffice command
|
||||
else {
|
||||
try (TempDirectory libreOfficeProfileManager = new TempDirectory(tempFileManager)) {
|
||||
Path libreOfficeProfile = libreOfficeProfileManager.getPath();
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(runtimePathConfig.getSOfficePath());
|
||||
command.add("-env:UserInstallation=" + libreOfficeProfile.toUri().toString());
|
||||
command.add("--headless");
|
||||
command.add("--nologo");
|
||||
command.add("--convert-to");
|
||||
command.add("pdf:writer_pdf_Export");
|
||||
command.add("--outdir");
|
||||
command.add(workDir.toString());
|
||||
command.add(inputPath.toString());
|
||||
libreOfficeProfile = Files.createTempDirectory("libreoffice_profile_");
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(runtimePathConfig.getSOfficePath());
|
||||
command.add("-env:UserInstallation=" + libreOfficeProfile.toUri().toString());
|
||||
command.add("--headless");
|
||||
command.add("--nologo");
|
||||
command.add("--convert-to");
|
||||
command.add("pdf:writer_pdf_Export");
|
||||
command.add("--outdir");
|
||||
command.add(workDir.toString());
|
||||
command.add(inputPath.toString());
|
||||
|
||||
result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
|
||||
.runCommandWithOutputHandling(command);
|
||||
}
|
||||
result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
|
||||
.runCommandWithOutputHandling(command);
|
||||
}
|
||||
|
||||
// Check the result
|
||||
@@ -162,12 +162,15 @@ public class ConvertOfficeController {
|
||||
|
||||
return outputPath.toFile();
|
||||
} finally {
|
||||
// Clean up the temporary input file (output and workDir cleaned by caller)
|
||||
// Clean up the temporary files
|
||||
try {
|
||||
Files.deleteIfExists(inputPath);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temp input file: {}", inputPath, e);
|
||||
}
|
||||
if (libreOfficeProfile != null) {
|
||||
FileUtils.deleteQuietly(libreOfficeProfile.toFile());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,12 +196,11 @@ public class ConvertOfficeController {
|
||||
try {
|
||||
file = convertToPdf(inputFile);
|
||||
|
||||
try (PDDocument doc = pdfDocumentFactory.load(file)) {
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
doc,
|
||||
GeneralUtils.generateFilename(
|
||||
inputFile.getOriginalFilename(), "_convertedToPDF.pdf"));
|
||||
}
|
||||
PDDocument doc = pdfDocumentFactory.load(file);
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
doc,
|
||||
GeneralUtils.generateFilename(
|
||||
inputFile.getOriginalFilename(), "_convertedToPDF.pdf"));
|
||||
} finally {
|
||||
if (file != null && file.getParent() != null) {
|
||||
FileUtils.deleteDirectory(file.getParentFile());
|
||||
|
||||
+28
-267
@@ -21,7 +21,6 @@ import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSBase;
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.cos.COSStream;
|
||||
import org.apache.pdfbox.io.RandomAccessRead;
|
||||
import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
|
||||
import org.apache.pdfbox.pdfwriter.compress.CompressParameters;
|
||||
@@ -52,7 +51,7 @@ import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.optionalcontent.PDOptionalContentProperties;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationHighlight;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationTextMarkup;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
|
||||
import org.apache.pdfbox.pdmodel.interactive.viewerpreferences.PDViewerPreferences;
|
||||
@@ -379,6 +378,7 @@ public class ConvertPDFToPDFA {
|
||||
command.add("-sOutputICCProfile=" + colorProfiles.rgb().toAbsolutePath());
|
||||
command.add("-sDefaultRGBProfile=" + colorProfiles.rgb().toAbsolutePath());
|
||||
command.add("-sDefaultGrayProfile=" + colorProfiles.gray().toAbsolutePath());
|
||||
command.add("-sDefaultCMYKProfile=" + colorProfiles.rgb().toAbsolutePath());
|
||||
|
||||
// Font handling optimized for PDF/A CIDSet compliance
|
||||
command.add("-dEmbedAllFonts=true");
|
||||
@@ -673,55 +673,25 @@ public class ConvertPDFToPDFA {
|
||||
if (descriptor == null) continue;
|
||||
|
||||
// Check if this is a Type1 font
|
||||
boolean isType1 =
|
||||
isType1Font(font)
|
||||
|| descriptor.getFontFile() != null
|
||||
|| (descriptor.getFontFile2() == null
|
||||
&& descriptor.getFontFile3() == null);
|
||||
if (fontNameStr.contains("Type1")
|
||||
|| descriptor.getFontFile() != null
|
||||
|| (descriptor.getFontFile2() == null
|
||||
&& descriptor.getFontFile3() == null)) {
|
||||
|
||||
if (isType1) {
|
||||
COSDictionary descDict = descriptor.getCOSObject();
|
||||
String existingCharSet = descDict.getString(COSName.CHAR_SET);
|
||||
String existingCharSet =
|
||||
descriptor.getCOSObject().getString(COSName.CHAR_SET);
|
||||
|
||||
// Check if font is embedded and if CharSet might be invalid
|
||||
boolean fontEmbedded = font.isEmbedded();
|
||||
boolean hasFontFile =
|
||||
descriptor.getFontFile() != null
|
||||
|| descriptor.getFontFile2() != null
|
||||
|| descriptor.getFontFile3() != null;
|
||||
|
||||
// For PDF/A compliance: if CharSet exists but font is subsetted or
|
||||
// we can't verify it matches the font file, remove it to avoid validation
|
||||
// errors
|
||||
if (existingCharSet != null && !existingCharSet.trim().isEmpty()) {
|
||||
// If the font appears to be subsetted (indicated by subset prefix in
|
||||
// name)
|
||||
// or if we can't verify the CharSet is correct, remove it
|
||||
if (fontNameStr.contains("+") || fontNameStr.contains("Subset")) {
|
||||
descDict.removeItem(COSName.CHAR_SET);
|
||||
String glyphSet = buildStandardType1GlyphSet();
|
||||
if (!glyphSet.isEmpty()) {
|
||||
if (existingCharSet == null
|
||||
|| existingCharSet.trim().isEmpty()
|
||||
|| countGlyphs(existingCharSet) < countGlyphs(glyphSet)) {
|
||||
descriptor.getCOSObject().setString(COSName.CHAR_SET, glyphSet);
|
||||
log.debug(
|
||||
"Removed potentially invalid CharSet from subsetted Type1 font: {}",
|
||||
fontNameStr);
|
||||
} else if (!hasFontFile && fontEmbedded) {
|
||||
// Font is embedded but we can't verify CharSet, remove it
|
||||
descDict.removeItem(COSName.CHAR_SET);
|
||||
log.debug(
|
||||
"Removed unverifiable CharSet from embedded Type1 font: {}",
|
||||
fontNameStr);
|
||||
}
|
||||
} else if (existingCharSet == null || existingCharSet.trim().isEmpty()) {
|
||||
// Only add CharSet if font is not subsetted and we can verify it
|
||||
if (!fontNameStr.contains("+")
|
||||
&& !fontNameStr.contains("Subset")
|
||||
&& hasFontFile) {
|
||||
String glyphSet = buildStandardType1GlyphSet();
|
||||
if (!glyphSet.isEmpty()) {
|
||||
descDict.setString(COSName.CHAR_SET, glyphSet);
|
||||
log.debug(
|
||||
"Added missing CharSet for Type1 font {} with {} glyphs",
|
||||
fontNameStr,
|
||||
countGlyphs(glyphSet));
|
||||
}
|
||||
"Fixed CharSet for Type1 font {} with {} glyphs (was: {})",
|
||||
fontNameStr,
|
||||
countGlyphs(glyphSet),
|
||||
existingCharSet != null ? countGlyphs(existingCharSet) : 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1241,7 +1211,7 @@ public class ConvertPDFToPDFA {
|
||||
List<PDAnnotation> annotations = page.getAnnotations();
|
||||
for (PDAnnotation annot : annotations) {
|
||||
if (ANNOTATION_HIGHLIGHT.equals(annot.getSubtype())
|
||||
&& annot instanceof PDAnnotationHighlight highlight) {
|
||||
&& annot instanceof PDAnnotationTextMarkup highlight) {
|
||||
float[] colorComponents =
|
||||
highlight.getColor() != null
|
||||
? highlight.getColor().getComponents()
|
||||
@@ -1379,22 +1349,13 @@ public class ConvertPDFToPDFA {
|
||||
|
||||
for (COSBase base : ocgArray) {
|
||||
if (base instanceof COSDictionary ocgDict) {
|
||||
// Ensure Name entry exists and is not empty
|
||||
String nameValue = ocgDict.getString(COSName.NAME);
|
||||
if (nameValue == null || nameValue.trim().isEmpty()) {
|
||||
if (!ocgDict.containsKey(COSName.NAME)) {
|
||||
String newName = "Layer " + unnamedCount++;
|
||||
ocgDict.setString(COSName.NAME, newName);
|
||||
log.debug("Fixed OCG missing or empty name, set to: {}", newName);
|
||||
log.debug("Fixed OCG missing name, set to: {}", newName);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (ocgs instanceof COSDictionary ocgDict) {
|
||||
// Handle case where OCGS is a single dictionary instead of array
|
||||
String nameValue = ocgDict.getString(COSName.NAME);
|
||||
if (nameValue == null || nameValue.trim().isEmpty()) {
|
||||
ocgDict.setString(COSName.NAME, "Layer 1");
|
||||
log.debug("Fixed single OCG missing or empty name");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1518,9 +1479,7 @@ public class ConvertPDFToPDFA {
|
||||
Path pdfaDefFile = createPdfaDefFile(workingDir, colorProfiles, profile);
|
||||
|
||||
// Preprocess PDF for PDF/A compliance using the sanitizer
|
||||
// We add a white background to ensure transparency is flattened correctly against white
|
||||
// instead of black, addressing common PDF/A conversion issues.
|
||||
Path sanitizedInputPdf = sanitizePdfWithPdfBox(inputPdf, true);
|
||||
Path sanitizedInputPdf = sanitizePdfWithPdfBox(inputPdf);
|
||||
Path preprocessedPdf = sanitizedInputPdf != null ? sanitizedInputPdf : inputPdf;
|
||||
|
||||
// For PDF/A-1, clean CIDSet issues that may cause validation failures
|
||||
@@ -1541,14 +1500,11 @@ public class ConvertPDFToPDFA {
|
||||
buildGhostscriptCommand(
|
||||
inputForGs, outputPdf, colorProfiles, workingDir, profile, pdfaDefFile);
|
||||
|
||||
log.info("Running Ghostscript command: {}", String.join(" ", command));
|
||||
|
||||
ProcessExecutorResult result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
|
||||
.runCommandWithOutputHandling(command);
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
log.error("Ghostscript failed with output: {}", result.getMessages());
|
||||
throw new IOException("Ghostscript exited with code " + result.getRc());
|
||||
}
|
||||
|
||||
@@ -1709,7 +1665,6 @@ public class ConvertPDFToPDFA {
|
||||
}
|
||||
|
||||
private byte[] convertWithPdfBoxMethod(Path inputPath, PdfaProfile profile) throws Exception {
|
||||
log.info("Starting PDFBox/LibreOffice conversion for PDF/A-{}", profile.getPart());
|
||||
Path tempInputFile = null;
|
||||
byte[] fileBytes;
|
||||
Path loPdfPath = null;
|
||||
@@ -1765,20 +1720,17 @@ public class ConvertPDFToPDFA {
|
||||
ColorProfiles colorProfiles = prepareColorProfiles(workingDir);
|
||||
|
||||
// Sanitize the PDF before PDF/X conversion for better Ghostscript compatibility
|
||||
Path sanitizedInputPdf = sanitizePdfWithPdfBox(inputPdf, true);
|
||||
Path sanitizedInputPdf = sanitizePdfWithPdfBox(inputPdf);
|
||||
Path inputForGs = sanitizedInputPdf != null ? sanitizedInputPdf : inputPdf;
|
||||
|
||||
List<String> command =
|
||||
buildGhostscriptCommandX(inputForGs, outputPdf, colorProfiles, workingDir, profile);
|
||||
|
||||
log.info("Running Ghostscript PDF/X command: {}", String.join(" ", command));
|
||||
|
||||
ProcessExecutorResult result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
|
||||
.runCommandWithOutputHandling(command);
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
log.error("Ghostscript PDF/X failed with output: {}", result.getMessages());
|
||||
throw new IOException("Ghostscript exited with code " + result.getRc());
|
||||
}
|
||||
|
||||
@@ -1844,12 +1796,12 @@ public class ConvertPDFToPDFA {
|
||||
}
|
||||
}
|
||||
|
||||
private Path sanitizePdfWithPdfBox(Path inputPdf, boolean addWhiteBackground) {
|
||||
private Path sanitizePdfWithPdfBox(Path inputPdf) {
|
||||
try {
|
||||
Path sanitizedPath =
|
||||
inputPdf.getParent().resolve("sanitized_" + inputPdf.getFileName().toString());
|
||||
|
||||
sanitizeDocument(inputPdf, sanitizedPath, addWhiteBackground);
|
||||
sanitizeDocument(inputPdf, sanitizedPath);
|
||||
|
||||
log.info("PDF sanitized with PDFBox for better Ghostscript compatibility");
|
||||
return sanitizedPath;
|
||||
@@ -1861,8 +1813,7 @@ public class ConvertPDFToPDFA {
|
||||
}
|
||||
}
|
||||
|
||||
private void sanitizeDocument(Path inputPath, Path outputPath, boolean addWhiteBackground)
|
||||
throws IOException {
|
||||
private void sanitizeDocument(Path inputPath, Path outputPath) throws IOException {
|
||||
try (PDDocument doc = Loader.loadPDF(inputPath.toFile())) {
|
||||
Map<String, DocumentSanitizer> sanitizers = new LinkedHashMap<>();
|
||||
sanitizers.put("Flatten highlight annotations", this::flattenHighlightsToContent);
|
||||
@@ -1873,11 +1824,6 @@ public class ConvertPDFToPDFA {
|
||||
sanitizers.put("Ensure embedded file compliance", this::ensureEmbeddedFileCompliance);
|
||||
sanitizers.put(
|
||||
"Fix optional content groups", ConvertPDFToPDFA::fixOptionalContentGroups);
|
||||
sanitizers.put("Fix separation color spaces", this::fixSeparationColorSpaces);
|
||||
|
||||
if (addWhiteBackground) {
|
||||
sanitizers.put("Add white background", this::addWhiteBackground);
|
||||
}
|
||||
|
||||
for (Map.Entry<String, DocumentSanitizer> entry : sanitizers.entrySet()) {
|
||||
try {
|
||||
@@ -1895,191 +1841,6 @@ public class ConvertPDFToPDFA {
|
||||
}
|
||||
}
|
||||
|
||||
private void fixSeparationColorSpaces(PDDocument doc) throws IOException {
|
||||
Map<String, COSBase> knownTintTransforms = new HashMap<>();
|
||||
Set<COSBase> visitedResources = new HashSet<>();
|
||||
|
||||
// Process all pages first to collect all separation color spaces
|
||||
for (PDPage page : doc.getPages()) {
|
||||
PDResources resources = page.getResources();
|
||||
processResourcesForSeparation(resources, knownTintTransforms, visitedResources);
|
||||
}
|
||||
|
||||
// Process document-level resources if they exist
|
||||
PDDocumentCatalog catalog = doc.getDocumentCatalog();
|
||||
if (catalog != null) {
|
||||
PDResources docResources =
|
||||
catalog.getAcroForm() != null
|
||||
? catalog.getAcroForm().getDefaultResources()
|
||||
: null;
|
||||
if (docResources != null) {
|
||||
processResourcesForSeparation(docResources, knownTintTransforms, visitedResources);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: ensure all separations with the same name use the same tintTransform
|
||||
visitedResources.clear();
|
||||
for (PDPage page : doc.getPages()) {
|
||||
PDResources resources = page.getResources();
|
||||
enforceSeparationConsistency(resources, knownTintTransforms, visitedResources);
|
||||
}
|
||||
}
|
||||
|
||||
private void processResourcesForSeparation(
|
||||
PDResources resources,
|
||||
Map<String, COSBase> knownTintTransforms,
|
||||
Set<COSBase> visitedResources) {
|
||||
if (resources == null) return;
|
||||
|
||||
// Prevent infinite recursion if resources are shared or cyclic
|
||||
if (!visitedResources.add(resources.getCOSObject())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check defined ColorSpaces
|
||||
COSDictionary csDict =
|
||||
(COSDictionary) resources.getCOSObject().getDictionaryObject(COSName.COLORSPACE);
|
||||
if (csDict != null) {
|
||||
for (COSName name : csDict.keySet()) {
|
||||
COSBase csVal = csDict.getDictionaryObject(name);
|
||||
checkAndFixSeparation(csVal, knownTintTransforms);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively check XObjects (Forms)
|
||||
COSDictionary xObjDict =
|
||||
(COSDictionary) resources.getCOSObject().getDictionaryObject(COSName.XOBJECT);
|
||||
if (xObjDict != null) {
|
||||
for (COSName name : xObjDict.keySet()) {
|
||||
COSBase xObj = xObjDict.getDictionaryObject(name);
|
||||
if (xObj instanceof COSStream stream) {
|
||||
COSName type = (COSName) stream.getDictionaryObject(COSName.SUBTYPE);
|
||||
if (COSName.FORM.equals(type)) {
|
||||
COSBase formRes = stream.getDictionaryObject(COSName.RESOURCES);
|
||||
if (formRes instanceof COSDictionary formResDict) {
|
||||
processResourcesForSeparation(
|
||||
new PDResources(formResDict),
|
||||
knownTintTransforms,
|
||||
visitedResources);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void checkAndFixSeparation(COSBase cs, Map<String, COSBase> knownTintTransforms) {
|
||||
if (cs instanceof COSArray arr && arr.size() >= 4) {
|
||||
COSBase type = arr.getObject(0);
|
||||
if (COSName.SEPARATION.equals(type)) {
|
||||
// Separation: [/Separation name altSpace tintTransform]
|
||||
COSBase nameBase = arr.getObject(1);
|
||||
if (nameBase instanceof COSName colorName) {
|
||||
String name = colorName.getName();
|
||||
COSBase tintTransform = arr.getObject(3);
|
||||
|
||||
if (knownTintTransforms.containsKey(name)) {
|
||||
COSBase known = knownTintTransforms.get(name);
|
||||
// If objects are not identical (same reference), unify them
|
||||
if (known != tintTransform) {
|
||||
arr.set(3, known);
|
||||
log.debug("Unified TintTransform for Separation color: {}", name);
|
||||
}
|
||||
} else {
|
||||
// Store the first encountered tintTransform for this color name
|
||||
knownTintTransforms.put(name, tintTransform);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void enforceSeparationConsistency(
|
||||
PDResources resources,
|
||||
Map<String, COSBase> knownTintTransforms,
|
||||
Set<COSBase> visitedResources) {
|
||||
if (resources == null) return;
|
||||
|
||||
// Prevent infinite recursion
|
||||
if (!visitedResources.add(resources.getCOSObject())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check defined ColorSpaces
|
||||
COSDictionary csDict =
|
||||
(COSDictionary) resources.getCOSObject().getDictionaryObject(COSName.COLORSPACE);
|
||||
if (csDict != null) {
|
||||
for (COSName name : csDict.keySet()) {
|
||||
COSBase csVal = csDict.getDictionaryObject(name);
|
||||
enforceSeparationTintTransform(csVal, knownTintTransforms);
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively check XObjects (Forms)
|
||||
COSDictionary xObjDict =
|
||||
(COSDictionary) resources.getCOSObject().getDictionaryObject(COSName.XOBJECT);
|
||||
if (xObjDict != null) {
|
||||
for (COSName name : xObjDict.keySet()) {
|
||||
COSBase xObj = xObjDict.getDictionaryObject(name);
|
||||
if (xObj instanceof COSStream stream) {
|
||||
COSName type = (COSName) stream.getDictionaryObject(COSName.SUBTYPE);
|
||||
if (COSName.FORM.equals(type)) {
|
||||
COSBase formRes = stream.getDictionaryObject(COSName.RESOURCES);
|
||||
if (formRes instanceof COSDictionary formResDict) {
|
||||
enforceSeparationConsistency(
|
||||
new PDResources(formResDict),
|
||||
knownTintTransforms,
|
||||
visitedResources);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void enforceSeparationTintTransform(
|
||||
COSBase cs, Map<String, COSBase> knownTintTransforms) {
|
||||
if (cs instanceof COSArray arr && arr.size() >= 4) {
|
||||
COSBase type = arr.getObject(0);
|
||||
if (COSName.SEPARATION.equals(type)) {
|
||||
COSBase nameBase = arr.getObject(1);
|
||||
if (nameBase instanceof COSName colorName) {
|
||||
String name = colorName.getName();
|
||||
COSBase tintTransform = arr.getObject(3);
|
||||
|
||||
// Ensure all separations with the same name use the same tintTransform
|
||||
// reference
|
||||
if (knownTintTransforms.containsKey(name)) {
|
||||
COSBase known = knownTintTransforms.get(name);
|
||||
if (known != tintTransform) {
|
||||
arr.set(3, known);
|
||||
log.debug(
|
||||
"Enforced consistent TintTransform for Separation color: {}",
|
||||
name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addWhiteBackground(PDDocument doc) throws IOException {
|
||||
for (PDPage page : doc.getPages()) {
|
||||
PDRectangle mediaBox = page.getMediaBox();
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(
|
||||
doc, page, PDPageContentStream.AppendMode.PREPEND, true, true)) {
|
||||
cs.setNonStrokingColor(Color.WHITE);
|
||||
cs.addRect(
|
||||
mediaBox.getLowerLeftX(),
|
||||
mediaBox.getLowerLeftY(),
|
||||
mediaBox.getWidth(),
|
||||
mediaBox.getHeight());
|
||||
cs.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void flattenHighlightsToContent(PDDocument doc) throws IOException {
|
||||
for (PDPage page : doc.getPages()) {
|
||||
List<PDAnnotation> annotations = new ArrayList<>(page.getAnnotations());
|
||||
@@ -2090,7 +1851,7 @@ public class ConvertPDFToPDFA {
|
||||
doc, page, PDPageContentStream.AppendMode.PREPEND, true, true)) {
|
||||
|
||||
for (PDAnnotation annot : annotations) {
|
||||
if (annot instanceof PDAnnotationHighlight highlight
|
||||
if (annot instanceof PDAnnotationTextMarkup highlight
|
||||
&& ANNOTATION_HIGHLIGHT.equals(annot.getSubtype())) {
|
||||
|
||||
PDColor color = highlight.getColor();
|
||||
@@ -2212,7 +1973,7 @@ public class ConvertPDFToPDFA {
|
||||
return annot.getAppearance() != null;
|
||||
}
|
||||
|
||||
if (annot instanceof PDAnnotationHighlight) {
|
||||
if (annot instanceof PDAnnotationTextMarkup) {
|
||||
return false; // Will be handled by flattening
|
||||
}
|
||||
|
||||
|
||||
+82
-85
@@ -47,105 +47,102 @@ public class AutoRenameController {
|
||||
MultipartFile file = request.getFileInput();
|
||||
boolean useFirstTextAsFallback = Boolean.TRUE.equals(request.getUseFirstTextAsFallback());
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
PDFTextStripper reader =
|
||||
new PDFTextStripper() {
|
||||
List<LineInfo> lineInfos = new ArrayList<>();
|
||||
StringBuilder lineBuilder = new StringBuilder();
|
||||
float lastY = -1;
|
||||
float maxFontSizeInLine = 0.0f;
|
||||
int lineCount = 0;
|
||||
PDDocument document = pdfDocumentFactory.load(file);
|
||||
PDFTextStripper reader =
|
||||
new PDFTextStripper() {
|
||||
List<LineInfo> lineInfos = new ArrayList<>();
|
||||
StringBuilder lineBuilder = new StringBuilder();
|
||||
float lastY = -1;
|
||||
float maxFontSizeInLine = 0.0f;
|
||||
int lineCount = 0;
|
||||
|
||||
@Override
|
||||
protected void processTextPosition(TextPosition text) {
|
||||
if (lastY != text.getY() && lineCount < LINE_LIMIT) {
|
||||
processLine();
|
||||
lineBuilder = new StringBuilder(text.getUnicode());
|
||||
@Override
|
||||
protected void processTextPosition(TextPosition text) {
|
||||
if (lastY != text.getY() && lineCount < LINE_LIMIT) {
|
||||
processLine();
|
||||
lineBuilder = new StringBuilder(text.getUnicode());
|
||||
maxFontSizeInLine = text.getFontSizeInPt();
|
||||
lastY = text.getY();
|
||||
lineCount++;
|
||||
} else if (lineCount < LINE_LIMIT) {
|
||||
lineBuilder.append(text.getUnicode());
|
||||
if (text.getFontSizeInPt() > maxFontSizeInLine) {
|
||||
maxFontSizeInLine = text.getFontSizeInPt();
|
||||
lastY = text.getY();
|
||||
lineCount++;
|
||||
} else if (lineCount < LINE_LIMIT) {
|
||||
lineBuilder.append(text.getUnicode());
|
||||
if (text.getFontSizeInPt() > maxFontSizeInLine) {
|
||||
maxFontSizeInLine = text.getFontSizeInPt();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void processLine() {
|
||||
if (!lineBuilder.isEmpty() && lineCount < LINE_LIMIT) {
|
||||
lineInfos.add(
|
||||
new LineInfo(lineBuilder.toString(), maxFontSizeInLine));
|
||||
private void processLine() {
|
||||
if (!lineBuilder.isEmpty() && lineCount < LINE_LIMIT) {
|
||||
lineInfos.add(new LineInfo(lineBuilder.toString(), maxFontSizeInLine));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getText(PDDocument doc) throws IOException {
|
||||
this.lineInfos.clear();
|
||||
this.lineBuilder = new StringBuilder();
|
||||
this.lastY = -1;
|
||||
this.maxFontSizeInLine = 0.0f;
|
||||
this.lineCount = 0;
|
||||
super.getText(doc);
|
||||
processLine(); // Process the last line
|
||||
|
||||
// Merge lines with same font size
|
||||
List<LineInfo> mergedLineInfos = new ArrayList<>();
|
||||
for (int i = 0; i < lineInfos.size(); i++) {
|
||||
StringBuilder mergedText = new StringBuilder(lineInfos.get(i).text);
|
||||
float fontSize = lineInfos.get(i).fontSize;
|
||||
while (i + 1 < lineInfos.size()
|
||||
&& lineInfos.get(i + 1).fontSize == fontSize) {
|
||||
mergedText.append(" ").append(lineInfos.get(i + 1).text);
|
||||
i++;
|
||||
}
|
||||
mergedLineInfos.add(new LineInfo(mergedText.toString(), fontSize));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getText(PDDocument doc) throws IOException {
|
||||
this.lineInfos.clear();
|
||||
this.lineBuilder = new StringBuilder();
|
||||
this.lastY = -1;
|
||||
this.maxFontSizeInLine = 0.0f;
|
||||
this.lineCount = 0;
|
||||
super.getText(doc);
|
||||
processLine(); // Process the last line
|
||||
// Sort lines by font size in descending order and get the first one
|
||||
mergedLineInfos.sort(
|
||||
Comparator.comparing((LineInfo li) -> li.fontSize).reversed());
|
||||
String title =
|
||||
mergedLineInfos.isEmpty() ? null : mergedLineInfos.get(0).text;
|
||||
|
||||
// Merge lines with same font size
|
||||
List<LineInfo> mergedLineInfos = new ArrayList<>();
|
||||
for (int i = 0; i < lineInfos.size(); i++) {
|
||||
StringBuilder mergedText = new StringBuilder(lineInfos.get(i).text);
|
||||
float fontSize = lineInfos.get(i).fontSize;
|
||||
while (i + 1 < lineInfos.size()
|
||||
&& lineInfos.get(i + 1).fontSize == fontSize) {
|
||||
mergedText.append(" ").append(lineInfos.get(i + 1).text);
|
||||
i++;
|
||||
}
|
||||
mergedLineInfos.add(new LineInfo(mergedText.toString(), fontSize));
|
||||
}
|
||||
return title != null
|
||||
? title
|
||||
: (useFirstTextAsFallback
|
||||
? (mergedLineInfos.isEmpty()
|
||||
? null
|
||||
: mergedLineInfos.get(mergedLineInfos.size() - 1)
|
||||
.text)
|
||||
: null);
|
||||
}
|
||||
|
||||
// Sort lines by font size in descending order and get the first one
|
||||
mergedLineInfos.sort(
|
||||
Comparator.comparing((LineInfo li) -> li.fontSize).reversed());
|
||||
String title =
|
||||
mergedLineInfos.isEmpty() ? null : mergedLineInfos.get(0).text;
|
||||
class LineInfo {
|
||||
String text;
|
||||
float fontSize;
|
||||
|
||||
return title != null
|
||||
? title
|
||||
: (useFirstTextAsFallback
|
||||
? (mergedLineInfos.isEmpty()
|
||||
? null
|
||||
: mergedLineInfos.get(
|
||||
mergedLineInfos.size() - 1)
|
||||
.text)
|
||||
: null);
|
||||
LineInfo(String text, float fontSize) {
|
||||
this.text = text;
|
||||
this.fontSize = fontSize;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
class LineInfo {
|
||||
String text;
|
||||
float fontSize;
|
||||
String header = reader.getText(document);
|
||||
|
||||
LineInfo(String text, float fontSize) {
|
||||
this.text = text;
|
||||
this.fontSize = fontSize;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
String header = reader.getText(document);
|
||||
|
||||
// Sanitize the header string by removing characters not allowed in a filename.
|
||||
if (header != null && header.length() < 255) {
|
||||
header =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getSafeFilenamePattern()
|
||||
.matcher(header)
|
||||
.replaceAll("")
|
||||
.trim();
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, header + ".pdf");
|
||||
} else {
|
||||
log.info("File has no good title to be found");
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document, Filenames.toSimpleFileName(file.getOriginalFilename()));
|
||||
}
|
||||
// Sanitize the header string by removing characters not allowed in a filename.
|
||||
if (header != null && header.length() < 255) {
|
||||
header =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getSafeFilenamePattern()
|
||||
.matcher(header)
|
||||
.replaceAll("")
|
||||
.trim();
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, header + ".pdf");
|
||||
} else {
|
||||
log.info("File has no good title to be found");
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document, Filenames.toSimpleFileName(file.getOriginalFilename()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -1100,9 +1100,8 @@ public class CompressController {
|
||||
inputFile.getOriginalFilename(), "_Optimized.pdf");
|
||||
|
||||
try {
|
||||
try (PDDocument document = pdfDocumentFactory.load(currentFile.toFile())) {
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, outputFilename);
|
||||
}
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
pdfDocumentFactory.load(currentFile.toFile()), outputFilename);
|
||||
} catch (IOException e) {
|
||||
throw ExceptionUtils.handlePdfException(e, "PDF optimization");
|
||||
}
|
||||
|
||||
+1
-16
@@ -75,25 +75,10 @@ public class ConfigController {
|
||||
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
||||
configData.put("frontendUrl", frontendUrl != null ? frontendUrl : "");
|
||||
|
||||
// Add mobile scanner settings
|
||||
// Add mobile scanner setting
|
||||
configData.put(
|
||||
"enableMobileScanner",
|
||||
applicationProperties.getSystem().isEnableMobileScanner());
|
||||
configData.put(
|
||||
"mobileScannerConvertToPdf",
|
||||
applicationProperties.getSystem().getMobileScannerSettings().isConvertToPdf());
|
||||
configData.put(
|
||||
"mobileScannerImageResolution",
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.getMobileScannerSettings()
|
||||
.getImageResolution());
|
||||
configData.put(
|
||||
"mobileScannerPageFormat",
|
||||
applicationProperties.getSystem().getMobileScannerSettings().getPageFormat());
|
||||
configData.put(
|
||||
"mobileScannerStretchToFit",
|
||||
applicationProperties.getSystem().getMobileScannerSettings().isStretchToFit());
|
||||
|
||||
// Extract values from ApplicationProperties
|
||||
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
|
||||
|
||||
+81
-91
@@ -49,103 +49,93 @@ public class FlattenController {
|
||||
public ResponseEntity<byte[]> flatten(@ModelAttribute FlattenRequest request) throws Exception {
|
||||
MultipartFile file = request.getFileInput();
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
Boolean flattenOnlyForms = request.getFlattenOnlyForms();
|
||||
PDDocument document = pdfDocumentFactory.load(file);
|
||||
Boolean flattenOnlyForms = request.getFlattenOnlyForms();
|
||||
|
||||
if (Boolean.TRUE.equals(flattenOnlyForms)) {
|
||||
PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm();
|
||||
if (acroForm != null) {
|
||||
acroForm.flatten();
|
||||
}
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document, Filenames.toSimpleFileName(file.getOriginalFilename()));
|
||||
} else {
|
||||
// flatten whole page aka convert each page to image and re-add it (making text
|
||||
// unselectable)
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(
|
||||
true); // Enable subsampling to reduce memory usage
|
||||
if (Boolean.TRUE.equals(flattenOnlyForms)) {
|
||||
PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm();
|
||||
if (acroForm != null) {
|
||||
acroForm.flatten();
|
||||
}
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document, Filenames.toSimpleFileName(file.getOriginalFilename()));
|
||||
} else {
|
||||
// flatten whole page aka convert each page to image and re-add it (making text
|
||||
// unselectable)
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document);
|
||||
|
||||
try (PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document)) {
|
||||
int defaultRenderDpi = 100; // Default fallback
|
||||
ApplicationProperties properties =
|
||||
ApplicationContextProvider.getBean(ApplicationProperties.class);
|
||||
Integer configuredMaxDpi = null;
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
configuredMaxDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
|
||||
int defaultRenderDpi = 100; // Default fallback
|
||||
ApplicationProperties properties =
|
||||
ApplicationContextProvider.getBean(ApplicationProperties.class);
|
||||
Integer configuredMaxDpi = null;
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
configuredMaxDpi = properties.getSystem().getMaxDPI();
|
||||
int maxDpi =
|
||||
(configuredMaxDpi != null && configuredMaxDpi > 0)
|
||||
? configuredMaxDpi
|
||||
: defaultRenderDpi;
|
||||
|
||||
Integer requestedDpi = request.getRenderDpi();
|
||||
int renderDpiTemp = maxDpi;
|
||||
if (requestedDpi != null) {
|
||||
renderDpiTemp = Math.min(requestedDpi, maxDpi);
|
||||
renderDpiTemp = Math.max(renderDpiTemp, 72);
|
||||
}
|
||||
final int renderDpi = renderDpiTemp;
|
||||
|
||||
int numPages = document.getNumberOfPages();
|
||||
for (int i = 0; i < numPages; i++) {
|
||||
final int pageIndex = i;
|
||||
BufferedImage image = null;
|
||||
try {
|
||||
// Validate dimensions BEFORE rendering to prevent OOM
|
||||
ExceptionUtils.validateRenderingDimensions(
|
||||
document.getPage(pageIndex), pageIndex + 1, renderDpi);
|
||||
|
||||
// Wrap entire rendering operation to catch OutOfMemoryError from any depth
|
||||
image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
renderDpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, renderDpi, ImageType.RGB));
|
||||
|
||||
PDPage page = new PDPage();
|
||||
page.setMediaBox(document.getPage(i).getMediaBox());
|
||||
newDocument.addPage(page);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(newDocument, page)) {
|
||||
PDImageXObject pdImage = JPEGFactory.createFromImage(newDocument, image);
|
||||
float pageWidth = page.getMediaBox().getWidth();
|
||||
float pageHeight = page.getMediaBox().getHeight();
|
||||
|
||||
contentStream.drawImage(pdImage, 0, 0, pageWidth, pageHeight);
|
||||
}
|
||||
|
||||
int maxDpi =
|
||||
(configuredMaxDpi != null && configuredMaxDpi > 0)
|
||||
? configuredMaxDpi
|
||||
: defaultRenderDpi;
|
||||
|
||||
Integer requestedDpi = request.getRenderDpi();
|
||||
int renderDpiTemp = maxDpi;
|
||||
if (requestedDpi != null) {
|
||||
renderDpiTemp = Math.min(requestedDpi, maxDpi);
|
||||
renderDpiTemp = Math.max(renderDpiTemp, 72);
|
||||
}
|
||||
final int renderDpi = renderDpiTemp;
|
||||
|
||||
int numPages = document.getNumberOfPages();
|
||||
for (int i = 0; i < numPages; i++) {
|
||||
final int pageIndex = i;
|
||||
BufferedImage image = null;
|
||||
try {
|
||||
// Validate dimensions BEFORE rendering to prevent OOM
|
||||
ExceptionUtils.validateRenderingDimensions(
|
||||
document.getPage(pageIndex), pageIndex + 1, renderDpi);
|
||||
|
||||
// Wrap entire rendering operation to catch OutOfMemoryError from any
|
||||
// depth
|
||||
image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
renderDpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, renderDpi, ImageType.RGB));
|
||||
|
||||
PDPage page = new PDPage();
|
||||
page.setMediaBox(document.getPage(i).getMediaBox());
|
||||
newDocument.addPage(page);
|
||||
// resetContext=true: Ensure clean graphics state when overwriting.
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
newDocument,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true,
|
||||
true)) {
|
||||
PDImageXObject pdImage =
|
||||
JPEGFactory.createFromImage(newDocument, image);
|
||||
float pageWidth = page.getMediaBox().getWidth();
|
||||
float pageHeight = page.getMediaBox().getHeight();
|
||||
|
||||
contentStream.drawImage(pdImage, 0, 0, pageWidth, pageHeight);
|
||||
}
|
||||
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
|
||||
// Re-throw OutOfMemoryDpiException to be handled by
|
||||
// GlobalExceptionHandler
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
log.error("IOException during page processing: ", e);
|
||||
// Continue processing other pages
|
||||
} catch (OutOfMemoryError e) {
|
||||
// Catch any OutOfMemoryError that escaped the inner try block
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e);
|
||||
} finally {
|
||||
// Help GC by clearing the image reference
|
||||
image = null;
|
||||
}
|
||||
}
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
newDocument, Filenames.toSimpleFileName(file.getOriginalFilename()));
|
||||
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
|
||||
// Re-throw OutOfMemoryDpiException to be handled by GlobalExceptionHandler
|
||||
newDocument.close();
|
||||
document.close();
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
log.error("IOException during page processing: ", e);
|
||||
// Continue processing other pages
|
||||
} catch (OutOfMemoryError e) {
|
||||
// Catch any OutOfMemoryError that escaped the inner try block
|
||||
newDocument.close();
|
||||
document.close();
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e);
|
||||
} finally {
|
||||
// Help GC by clearing the image reference
|
||||
image = null;
|
||||
}
|
||||
}
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
newDocument, Filenames.toSimpleFileName(file.getOriginalFilename()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+82
-91
@@ -84,102 +84,93 @@ public class MetadataController {
|
||||
if (allRequestParams == null) {
|
||||
allRequestParams = new java.util.HashMap<String, String>();
|
||||
}
|
||||
// Load the PDF file into a PDDocument with proper resource management
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile, true)) {
|
||||
// Load the PDF file into a PDDocument
|
||||
PDDocument document = pdfDocumentFactory.load(pdfFile, true);
|
||||
|
||||
// Get the document information from the PDF
|
||||
PDDocumentInformation info = document.getDocumentInformation();
|
||||
// Get the document information from the PDF
|
||||
PDDocumentInformation info = document.getDocumentInformation();
|
||||
|
||||
// Check if each metadata value is "undefined" and set it to null if it is
|
||||
author = checkUndefined(author);
|
||||
creationDate = checkUndefined(creationDate);
|
||||
creator = checkUndefined(creator);
|
||||
keywords = checkUndefined(keywords);
|
||||
modificationDate = checkUndefined(modificationDate);
|
||||
producer = checkUndefined(producer);
|
||||
subject = checkUndefined(subject);
|
||||
title = checkUndefined(title);
|
||||
trapped = checkUndefined(trapped);
|
||||
// Check if each metadata value is "undefined" and set it to null if it is
|
||||
author = checkUndefined(author);
|
||||
creationDate = checkUndefined(creationDate);
|
||||
creator = checkUndefined(creator);
|
||||
keywords = checkUndefined(keywords);
|
||||
modificationDate = checkUndefined(modificationDate);
|
||||
producer = checkUndefined(producer);
|
||||
subject = checkUndefined(subject);
|
||||
title = checkUndefined(title);
|
||||
trapped = checkUndefined(trapped);
|
||||
|
||||
// If the "deleteAll" flag is set, remove all metadata from the document
|
||||
// information
|
||||
if (deleteAll) {
|
||||
for (String key : info.getMetadataKeys()) {
|
||||
info.setCustomMetadataValue(key, null);
|
||||
}
|
||||
// Remove metadata from the PDF history
|
||||
document.getDocumentCatalog()
|
||||
.getCOSObject()
|
||||
.removeItem(COSName.getPDFName("Metadata"));
|
||||
document.getDocumentCatalog()
|
||||
.getCOSObject()
|
||||
.removeItem(COSName.getPDFName("PieceInfo"));
|
||||
author = null;
|
||||
creationDate = null;
|
||||
creator = null;
|
||||
keywords = null;
|
||||
modificationDate = null;
|
||||
producer = null;
|
||||
subject = null;
|
||||
title = null;
|
||||
trapped = null;
|
||||
} else {
|
||||
// Iterate through the request parameters and set the metadata values
|
||||
for (Entry<String, String> entry : allRequestParams.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
// Check if the key is a standard metadata key
|
||||
if (!"Author".equalsIgnoreCase(key)
|
||||
&& !"CreationDate".equalsIgnoreCase(key)
|
||||
&& !"Creator".equalsIgnoreCase(key)
|
||||
&& !"Keywords".equalsIgnoreCase(key)
|
||||
&& !"modificationDate".equalsIgnoreCase(key)
|
||||
&& !"Producer".equalsIgnoreCase(key)
|
||||
&& !"Subject".equalsIgnoreCase(key)
|
||||
&& !"Title".equalsIgnoreCase(key)
|
||||
&& !"Trapped".equalsIgnoreCase(key)
|
||||
&& !key.contains("customKey")
|
||||
&& !key.contains("customValue")) {
|
||||
info.setCustomMetadataValue(key, entry.getValue());
|
||||
} else if (key.contains("customKey")) {
|
||||
try {
|
||||
int number =
|
||||
Integer.parseInt(
|
||||
RegexPatternUtils.getInstance()
|
||||
.getNumericExtractionPattern()
|
||||
.matcher(key)
|
||||
.replaceAll(""));
|
||||
String customKey = entry.getValue();
|
||||
String customValue = allRequestParams.get("customValue" + number);
|
||||
info.setCustomMetadataValue(customKey, customValue);
|
||||
} catch (NumberFormatException e) {
|
||||
// Skip invalid custom key entries that don't have valid numeric
|
||||
// suffixes
|
||||
log.warn("Skipping invalid custom key '{}': {}", key, e.getMessage());
|
||||
}
|
||||
}
|
||||
// If the "deleteAll" flag is set, remove all metadata from the document
|
||||
// information
|
||||
if (deleteAll) {
|
||||
for (String key : info.getMetadataKeys()) {
|
||||
info.setCustomMetadataValue(key, null);
|
||||
}
|
||||
// Remove metadata from the PDF history
|
||||
document.getDocumentCatalog().getCOSObject().removeItem(COSName.getPDFName("Metadata"));
|
||||
document.getDocumentCatalog()
|
||||
.getCOSObject()
|
||||
.removeItem(COSName.getPDFName("PieceInfo"));
|
||||
author = null;
|
||||
creationDate = null;
|
||||
creator = null;
|
||||
keywords = null;
|
||||
modificationDate = null;
|
||||
producer = null;
|
||||
subject = null;
|
||||
title = null;
|
||||
trapped = null;
|
||||
} else {
|
||||
// Iterate through the request parameters and set the metadata values
|
||||
for (Entry<String, String> entry : allRequestParams.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
// Check if the key is a standard metadata key
|
||||
if (!"Author".equalsIgnoreCase(key)
|
||||
&& !"CreationDate".equalsIgnoreCase(key)
|
||||
&& !"Creator".equalsIgnoreCase(key)
|
||||
&& !"Keywords".equalsIgnoreCase(key)
|
||||
&& !"modificationDate".equalsIgnoreCase(key)
|
||||
&& !"Producer".equalsIgnoreCase(key)
|
||||
&& !"Subject".equalsIgnoreCase(key)
|
||||
&& !"Title".equalsIgnoreCase(key)
|
||||
&& !"Trapped".equalsIgnoreCase(key)
|
||||
&& !key.contains("customKey")
|
||||
&& !key.contains("customValue")) {
|
||||
info.setCustomMetadataValue(key, entry.getValue());
|
||||
} else if (key.contains("customKey")) {
|
||||
int number =
|
||||
Integer.parseInt(
|
||||
RegexPatternUtils.getInstance()
|
||||
.getNumericExtractionPattern()
|
||||
.matcher(key)
|
||||
.replaceAll(""));
|
||||
String customKey = entry.getValue();
|
||||
String customValue = allRequestParams.get("customValue" + number);
|
||||
info.setCustomMetadataValue(customKey, customValue);
|
||||
}
|
||||
}
|
||||
// Set creation date using utility method
|
||||
Calendar creationDateCal = PdfMetadataService.parseToCalendar(creationDate);
|
||||
info.setCreationDate(creationDateCal);
|
||||
|
||||
// Set modification date using utility method
|
||||
Calendar modificationDateCal = PdfMetadataService.parseToCalendar(modificationDate);
|
||||
info.setModificationDate(modificationDateCal);
|
||||
info.setCreator(creator);
|
||||
info.setKeywords(keywords);
|
||||
info.setAuthor(author);
|
||||
info.setProducer(producer);
|
||||
info.setSubject(subject);
|
||||
info.setTitle(title);
|
||||
info.setTrapped(trapped);
|
||||
|
||||
document.setDocumentInformation(info);
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.removeExtension(
|
||||
Filenames.toSimpleFileName(pdfFile.getOriginalFilename()))
|
||||
+ "_metadata.pdf");
|
||||
}
|
||||
// Set creation date using utility method
|
||||
Calendar creationDateCal = PdfMetadataService.parseToCalendar(creationDate);
|
||||
info.setCreationDate(creationDateCal);
|
||||
|
||||
// Set modification date using utility method
|
||||
Calendar modificationDateCal = PdfMetadataService.parseToCalendar(modificationDate);
|
||||
info.setModificationDate(modificationDateCal);
|
||||
info.setCreator(creator);
|
||||
info.setKeywords(keywords);
|
||||
info.setAuthor(author);
|
||||
info.setProducer(producer);
|
||||
info.setSubject(subject);
|
||||
info.setTitle(title);
|
||||
info.setTrapped(trapped);
|
||||
|
||||
document.setDocumentInformation(info);
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.removeExtension(
|
||||
Filenames.toSimpleFileName(pdfFile.getOriginalFilename()))
|
||||
+ "_metadata.pdf");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-59
@@ -31,7 +31,6 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.MobileScannerService;
|
||||
import stirling.software.common.service.MobileScannerService.FileMetadata;
|
||||
|
||||
@@ -52,31 +51,9 @@ import stirling.software.common.service.MobileScannerService.FileMetadata;
|
||||
public class MobileScannerController {
|
||||
|
||||
private final MobileScannerService mobileScannerService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public MobileScannerController(
|
||||
MobileScannerService mobileScannerService,
|
||||
ApplicationProperties applicationProperties) {
|
||||
public MobileScannerController(MobileScannerService mobileScannerService) {
|
||||
this.mobileScannerService = mobileScannerService;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if mobile scanner feature is enabled
|
||||
*
|
||||
* @return Error response if disabled, null if enabled
|
||||
*/
|
||||
private ResponseEntity<Map<String, Object>> checkFeatureEnabled() {
|
||||
if (!applicationProperties.getSystem().isEnableMobileScanner()) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Mobile scanner feature is not enabled",
|
||||
"enabled",
|
||||
false));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,16 +71,10 @@ public class MobileScannerController {
|
||||
description = "Session created successfully",
|
||||
content = @Content(schema = @Schema(implementation = SessionInfoResponse.class)))
|
||||
@ApiResponse(responseCode = "400", description = "Invalid session ID")
|
||||
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||
public ResponseEntity<Map<String, Object>> createSession(
|
||||
@Parameter(description = "Session ID for QR code", required = true) @PathVariable
|
||||
String sessionId) {
|
||||
|
||||
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
|
||||
if (featureCheck != null) {
|
||||
return featureCheck;
|
||||
}
|
||||
|
||||
try {
|
||||
MobileScannerService.SessionInfo sessionInfo =
|
||||
mobileScannerService.createSession(sessionId);
|
||||
@@ -138,16 +109,10 @@ public class MobileScannerController {
|
||||
description = "Session is valid",
|
||||
content = @Content(schema = @Schema(implementation = SessionInfoResponse.class)))
|
||||
@ApiResponse(responseCode = "404", description = "Session not found or expired")
|
||||
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||
public ResponseEntity<Map<String, Object>> validateSession(
|
||||
@Parameter(description = "Session ID to validate", required = true) @PathVariable
|
||||
String sessionId) {
|
||||
|
||||
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
|
||||
if (featureCheck != null) {
|
||||
return featureCheck;
|
||||
}
|
||||
|
||||
MobileScannerService.SessionInfo sessionInfo =
|
||||
mobileScannerService.validateSession(sessionId);
|
||||
|
||||
@@ -182,7 +147,6 @@ public class MobileScannerController {
|
||||
description = "Files uploaded successfully",
|
||||
content = @Content(schema = @Schema(implementation = UploadResponse.class)))
|
||||
@ApiResponse(responseCode = "400", description = "Invalid session ID or files")
|
||||
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||
@ApiResponse(responseCode = "500", description = "Upload failed")
|
||||
public ResponseEntity<Map<String, Object>> uploadFiles(
|
||||
@Parameter(description = "Session ID from QR code", required = true) @PathVariable
|
||||
@@ -190,11 +154,6 @@ public class MobileScannerController {
|
||||
@Parameter(description = "Files to upload", required = true) @RequestParam("files")
|
||||
List<MultipartFile> files) {
|
||||
|
||||
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
|
||||
if (featureCheck != null) {
|
||||
return featureCheck;
|
||||
}
|
||||
|
||||
try {
|
||||
if (files == null || files.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "No files provided"));
|
||||
@@ -235,16 +194,10 @@ public class MobileScannerController {
|
||||
responseCode = "200",
|
||||
description = "File list retrieved",
|
||||
content = @Content(schema = @Schema(implementation = FileListResponse.class)))
|
||||
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||
public ResponseEntity<Map<String, Object>> getSessionFiles(
|
||||
@Parameter(description = "Session ID", required = true) @PathVariable
|
||||
String sessionId) {
|
||||
|
||||
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
|
||||
if (featureCheck != null) {
|
||||
return featureCheck;
|
||||
}
|
||||
|
||||
List<FileMetadata> files = mobileScannerService.getSessionFiles(sessionId);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
@@ -268,17 +221,12 @@ public class MobileScannerController {
|
||||
description =
|
||||
"Download a file that was uploaded to a session. File is automatically deleted after download.")
|
||||
@ApiResponse(responseCode = "200", description = "File downloaded successfully")
|
||||
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||
@ApiResponse(responseCode = "404", description = "File or session not found")
|
||||
public ResponseEntity<Resource> downloadFile(
|
||||
@Parameter(description = "Session ID", required = true) @PathVariable String sessionId,
|
||||
@Parameter(description = "Filename to download", required = true) @PathVariable
|
||||
String filename) {
|
||||
|
||||
if (!applicationProperties.getSystem().isEnableMobileScanner()) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
|
||||
try {
|
||||
Path filePath = mobileScannerService.getFile(sessionId, filename);
|
||||
|
||||
@@ -320,16 +268,10 @@ public class MobileScannerController {
|
||||
summary = "Delete a session",
|
||||
description = "Manually delete a session and all its uploaded files")
|
||||
@ApiResponse(responseCode = "200", description = "Session deleted successfully")
|
||||
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||
public ResponseEntity<Map<String, Object>> deleteSession(
|
||||
@Parameter(description = "Session ID to delete", required = true) @PathVariable
|
||||
String sessionId) {
|
||||
|
||||
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
|
||||
if (featureCheck != null) {
|
||||
return featureCheck;
|
||||
}
|
||||
|
||||
mobileScannerService.deleteSession(sessionId);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
|
||||
+1
-4
@@ -15,7 +15,6 @@ import java.util.zip.ZipOutputStream;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.apache.pdfbox.io.IOUtils;
|
||||
import org.apache.pdfbox.multipdf.PDFMergerUtility;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
@@ -327,8 +326,6 @@ public class OCRController {
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(tempInputFile.toFile())) {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(
|
||||
true); // Enable subsampling to reduce memory usage
|
||||
int pageCount = document.getNumberOfPages();
|
||||
|
||||
for (int pageNum = 0; pageNum < pageCount; pageNum++) {
|
||||
@@ -418,7 +415,7 @@ public class OCRController {
|
||||
}
|
||||
|
||||
// Merge all pages into final PDF
|
||||
merger.mergeDocuments(IOUtils.createTempFileOnlyStreamCache());
|
||||
merger.mergeDocuments(null);
|
||||
|
||||
// Copy final output to the expected location
|
||||
Files.copy(
|
||||
|
||||
+103
-104
@@ -65,114 +65,113 @@ public class PageNumbersController {
|
||||
}
|
||||
}
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
float marginFactor =
|
||||
switch (customMargin == null ? "" : customMargin.toLowerCase(Locale.ROOT)) {
|
||||
case "small" -> 0.02f;
|
||||
case "large" -> 0.05f;
|
||||
case "x-large" -> 0.075f;
|
||||
case "medium" -> 0.035f;
|
||||
default -> 0.035f;
|
||||
PDDocument document = pdfDocumentFactory.load(file);
|
||||
|
||||
float marginFactor =
|
||||
switch (customMargin == null ? "" : customMargin.toLowerCase(Locale.ROOT)) {
|
||||
case "small" -> 0.02f;
|
||||
case "large" -> 0.05f;
|
||||
case "x-large" -> 0.075f;
|
||||
case "medium" -> 0.035f;
|
||||
default -> 0.035f;
|
||||
};
|
||||
|
||||
if (pagesToNumber == null || pagesToNumber.isEmpty()) {
|
||||
pagesToNumber = "all";
|
||||
}
|
||||
if (customText == null || customText.isEmpty()) {
|
||||
customText = "{n}";
|
||||
}
|
||||
|
||||
final String baseFilename =
|
||||
Filenames.toSimpleFileName(file.getOriginalFilename())
|
||||
.replaceFirst("[.][^.]+$", "");
|
||||
|
||||
List<Integer> pagesToNumberList =
|
||||
GeneralUtils.parsePageList(pagesToNumber.split(","), document.getNumberOfPages());
|
||||
|
||||
// Clamp position to 1..9 (1 = top-left, 9 = bottom-right)
|
||||
int pos = Math.max(1, Math.min(9, position));
|
||||
|
||||
for (int i : pagesToNumberList) {
|
||||
PDPage page = document.getPage(i);
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
|
||||
String text =
|
||||
customText
|
||||
.replace("{n}", String.valueOf(pageNumber))
|
||||
.replace("{total}", String.valueOf(document.getNumberOfPages()))
|
||||
.replace(
|
||||
"{filename}",
|
||||
GeneralUtils.removeExtension(
|
||||
Filenames.toSimpleFileName(
|
||||
file.getOriginalFilename())));
|
||||
|
||||
PDType1Font currentFont =
|
||||
switch (fontType == null ? "" : fontType.toLowerCase(Locale.ROOT)) {
|
||||
case "courier" -> new PDType1Font(Standard14Fonts.FontName.COURIER);
|
||||
case "times" -> new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN);
|
||||
default -> new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
};
|
||||
|
||||
if (pagesToNumber == null || pagesToNumber.isEmpty()) {
|
||||
pagesToNumber = "all";
|
||||
}
|
||||
if (customText == null || customText.isEmpty()) {
|
||||
customText = "{n}";
|
||||
// Text dimensions and font metrics
|
||||
float textWidth = currentFont.getStringWidth(text) / 1000f * fontSize;
|
||||
float ascent = currentFont.getFontDescriptor().getAscent() / 1000f * fontSize;
|
||||
float descent = currentFont.getFontDescriptor().getDescent() / 1000f * fontSize;
|
||||
|
||||
// Derive column/row in range 1..3 (1 = left/top, 2 = center/middle, 3 = right/bottom)
|
||||
int col = ((pos - 1) % 3) + 1; // 1 = left, 2 = center, 3 = right
|
||||
int row = ((pos - 1) / 3) + 1; // 1 = top, 2 = middle, 3 = bottom
|
||||
|
||||
// Anchor coordinates with margin
|
||||
float leftX = pageSize.getLowerLeftX() + marginFactor * pageSize.getWidth();
|
||||
float midX = pageSize.getLowerLeftX() + pageSize.getWidth() / 2f;
|
||||
float rightX = pageSize.getUpperRightX() - marginFactor * pageSize.getWidth();
|
||||
|
||||
float botY = pageSize.getLowerLeftY() + marginFactor * pageSize.getHeight();
|
||||
float midY = pageSize.getLowerLeftY() + pageSize.getHeight() / 2f;
|
||||
float topY = pageSize.getUpperRightY() - marginFactor * pageSize.getHeight();
|
||||
|
||||
// Horizontal alignment: left = anchor, center = centered, right = right-aligned
|
||||
float x =
|
||||
switch (col) {
|
||||
case 1 -> leftX;
|
||||
case 2 -> midX - textWidth / 2f;
|
||||
default -> rightX - textWidth;
|
||||
};
|
||||
|
||||
// Vertical alignment (baseline!):
|
||||
// top = align text top at topY,
|
||||
// middle = optical middle using ascent/descent,
|
||||
// bottom = baseline at botY
|
||||
float y =
|
||||
switch (row) {
|
||||
case 1 -> topY - ascent;
|
||||
case 2 -> midY - (ascent + descent) / 2f;
|
||||
default -> botY;
|
||||
};
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
|
||||
contentStream.beginText();
|
||||
contentStream.setFont(currentFont, fontSize);
|
||||
contentStream.setNonStrokingColor(color);
|
||||
contentStream.newLineAtOffset(x, y);
|
||||
contentStream.showText(text);
|
||||
contentStream.endText();
|
||||
}
|
||||
|
||||
List<Integer> pagesToNumberList =
|
||||
GeneralUtils.parsePageList(
|
||||
pagesToNumber.split(","), document.getNumberOfPages());
|
||||
|
||||
// Clamp position to 1..9 (1 = top-left, 9 = bottom-right)
|
||||
int pos = Math.max(1, Math.min(9, position));
|
||||
|
||||
for (int i : pagesToNumberList) {
|
||||
PDPage page = document.getPage(i);
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
|
||||
String text =
|
||||
customText
|
||||
.replace("{n}", String.valueOf(pageNumber))
|
||||
.replace("{total}", String.valueOf(document.getNumberOfPages()))
|
||||
.replace(
|
||||
"{filename}",
|
||||
GeneralUtils.removeExtension(
|
||||
Filenames.toSimpleFileName(
|
||||
file.getOriginalFilename())));
|
||||
|
||||
PDType1Font currentFont =
|
||||
switch (fontType == null ? "" : fontType.toLowerCase(Locale.ROOT)) {
|
||||
case "courier" -> new PDType1Font(Standard14Fonts.FontName.COURIER);
|
||||
case "times" -> new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN);
|
||||
default -> new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
};
|
||||
|
||||
// Text dimensions and font metrics
|
||||
float textWidth = currentFont.getStringWidth(text) / 1000f * fontSize;
|
||||
float ascent = currentFont.getFontDescriptor().getAscent() / 1000f * fontSize;
|
||||
float descent = currentFont.getFontDescriptor().getDescent() / 1000f * fontSize;
|
||||
|
||||
// Derive column/row in range 1..3 (1 = left/top, 2 = center/middle, 3 =
|
||||
// right/bottom)
|
||||
int col = ((pos - 1) % 3) + 1; // 1 = left, 2 = center, 3 = right
|
||||
int row = ((pos - 1) / 3) + 1; // 1 = top, 2 = middle, 3 = bottom
|
||||
|
||||
// Anchor coordinates with margin
|
||||
float leftX = pageSize.getLowerLeftX() + marginFactor * pageSize.getWidth();
|
||||
float midX = pageSize.getLowerLeftX() + pageSize.getWidth() / 2f;
|
||||
float rightX = pageSize.getUpperRightX() - marginFactor * pageSize.getWidth();
|
||||
|
||||
float botY = pageSize.getLowerLeftY() + marginFactor * pageSize.getHeight();
|
||||
float midY = pageSize.getLowerLeftY() + pageSize.getHeight() / 2f;
|
||||
float topY = pageSize.getUpperRightY() - marginFactor * pageSize.getHeight();
|
||||
|
||||
// Horizontal alignment: left = anchor, center = centered, right = right-aligned
|
||||
float x =
|
||||
switch (col) {
|
||||
case 1 -> leftX;
|
||||
case 2 -> midX - textWidth / 2f;
|
||||
default -> rightX - textWidth;
|
||||
};
|
||||
|
||||
// Vertical alignment (baseline!):
|
||||
// top = align text top at topY,
|
||||
// middle = optical middle using ascent/descent,
|
||||
// bottom = baseline at botY
|
||||
float y =
|
||||
switch (row) {
|
||||
case 1 -> topY - ascent;
|
||||
case 2 -> midY - (ascent + descent) / 2f;
|
||||
default -> botY;
|
||||
};
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true)) {
|
||||
contentStream.beginText();
|
||||
contentStream.setFont(currentFont, fontSize);
|
||||
contentStream.setNonStrokingColor(color);
|
||||
contentStream.newLineAtOffset(x, y);
|
||||
contentStream.showText(text);
|
||||
contentStream.endText();
|
||||
}
|
||||
|
||||
pageNumber++;
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
baos.toByteArray(),
|
||||
GeneralUtils.generateFilename(
|
||||
file.getOriginalFilename(), "_page_numbers_added.pdf"));
|
||||
pageNumber++;
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
document.close();
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
baos.toByteArray(),
|
||||
GeneralUtils.generateFilename(
|
||||
file.getOriginalFilename(), "_page_numbers_added.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
+5
-16
@@ -7,10 +7,7 @@ import java.awt.print.Printable;
|
||||
import java.awt.print.PrinterException;
|
||||
import java.awt.print.PrinterJob;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
|
||||
@@ -80,19 +77,11 @@ public class PrintFileController {
|
||||
log.info("Selected Printer: {}", selectedService.getName());
|
||||
|
||||
if (MediaType.APPLICATION_PDF_VALUE.equals(contentType)) {
|
||||
// Use Stream-to-File pattern: write to temp file first, then load from file
|
||||
Path tempFile = Files.createTempFile("print-", ".pdf");
|
||||
try {
|
||||
Files.copy(
|
||||
file.getInputStream(), tempFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
try (PDDocument document = Loader.loadPDF(tempFile.toFile())) {
|
||||
PrinterJob job = PrinterJob.getPrinterJob();
|
||||
job.setPrintService(selectedService);
|
||||
job.setPageable(new PDFPageable(document));
|
||||
job.print();
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(tempFile);
|
||||
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/")) {
|
||||
try (var inputStream = file.getInputStream()) {
|
||||
|
||||
+1
-7
@@ -463,13 +463,7 @@ public class ScannerEffectController {
|
||||
PDPage newPage = new PDPage(new PDRectangle(page.origW, page.origH));
|
||||
document.addPage(newPage);
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
newPage,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true,
|
||||
true)) {
|
||||
try (PDPageContentStream contentStream = new PDPageContentStream(document, newPage)) {
|
||||
PDImageXObject pdImage = LosslessFactory.createFromImage(document, page.image);
|
||||
contentStream.drawImage(
|
||||
pdImage, page.offsetX, page.offsetY, page.drawW, page.drawH);
|
||||
|
||||
+46
-51
@@ -134,65 +134,60 @@ public class StampController {
|
||||
};
|
||||
|
||||
// Load the input PDF
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
|
||||
PDDocument document = pdfDocumentFactory.load(pdfFile);
|
||||
|
||||
List<Integer> pageNumbers = request.getPageNumbersList(document, true);
|
||||
List<Integer> pageNumbers = request.getPageNumbersList(document, true);
|
||||
|
||||
for (int pageIndex : pageNumbers) {
|
||||
int zeroBasedIndex = pageIndex - 1;
|
||||
if (zeroBasedIndex >= 0 && zeroBasedIndex < document.getNumberOfPages()) {
|
||||
PDPage page = document.getPage(zeroBasedIndex);
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
float margin = marginFactor * (pageSize.getWidth() + pageSize.getHeight()) / 2;
|
||||
for (int pageIndex : pageNumbers) {
|
||||
int zeroBasedIndex = pageIndex - 1;
|
||||
if (zeroBasedIndex >= 0 && zeroBasedIndex < document.getNumberOfPages()) {
|
||||
PDPage page = document.getPage(zeroBasedIndex);
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
float margin = marginFactor * (pageSize.getWidth() + pageSize.getHeight()) / 2;
|
||||
|
||||
PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true);
|
||||
PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document, page, PDPageContentStream.AppendMode.APPEND, true, true);
|
||||
|
||||
PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState();
|
||||
graphicsState.setNonStrokingAlphaConstant(opacity);
|
||||
contentStream.setGraphicsStateParameters(graphicsState);
|
||||
PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState();
|
||||
graphicsState.setNonStrokingAlphaConstant(opacity);
|
||||
contentStream.setGraphicsStateParameters(graphicsState);
|
||||
|
||||
if ("text".equalsIgnoreCase(stampType)) {
|
||||
addTextStamp(
|
||||
contentStream,
|
||||
stampText,
|
||||
document,
|
||||
page,
|
||||
rotation,
|
||||
position,
|
||||
fontSize,
|
||||
alphabet,
|
||||
overrideX,
|
||||
overrideY,
|
||||
margin,
|
||||
customColor);
|
||||
} else if ("image".equalsIgnoreCase(stampType)) {
|
||||
addImageStamp(
|
||||
contentStream,
|
||||
stampImage,
|
||||
document,
|
||||
page,
|
||||
rotation,
|
||||
position,
|
||||
fontSize,
|
||||
overrideX,
|
||||
overrideY,
|
||||
margin);
|
||||
}
|
||||
|
||||
contentStream.close();
|
||||
if ("text".equalsIgnoreCase(stampType)) {
|
||||
addTextStamp(
|
||||
contentStream,
|
||||
stampText,
|
||||
document,
|
||||
page,
|
||||
rotation,
|
||||
position,
|
||||
fontSize,
|
||||
alphabet,
|
||||
overrideX,
|
||||
overrideY,
|
||||
margin,
|
||||
customColor);
|
||||
} else if ("image".equalsIgnoreCase(stampType)) {
|
||||
addImageStamp(
|
||||
contentStream,
|
||||
stampImage,
|
||||
document,
|
||||
page,
|
||||
rotation,
|
||||
position,
|
||||
fontSize,
|
||||
overrideX,
|
||||
overrideY,
|
||||
margin);
|
||||
}
|
||||
|
||||
contentStream.close();
|
||||
}
|
||||
// Return the stamped PDF as a response
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_stamped.pdf"));
|
||||
}
|
||||
// Return the stamped PDF as a response
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_stamped.pdf"));
|
||||
}
|
||||
|
||||
private void addTextStamp(
|
||||
|
||||
+1
-2
@@ -139,12 +139,11 @@ public class CertSignController {
|
||||
signatureOptions.setPage(pageNumber);
|
||||
|
||||
doc.addSignature(signature, instance, signatureOptions);
|
||||
doc.saveIncremental(output);
|
||||
}
|
||||
} else {
|
||||
doc.addSignature(signature, instance);
|
||||
doc.saveIncremental(output);
|
||||
}
|
||||
doc.saveIncremental(output);
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("PDF signing", e);
|
||||
}
|
||||
|
||||
+4
-30
@@ -3,8 +3,6 @@ package stirling.software.SPDF.controller.api.security;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
@@ -16,8 +14,7 @@ import java.util.regex.Pattern;
|
||||
import org.apache.pdfbox.cos.COSInputStream;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.cos.COSString;
|
||||
import org.apache.pdfbox.io.RandomAccessRead;
|
||||
import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
|
||||
import org.apache.pdfbox.io.RandomAccessReadBuffer;
|
||||
import org.apache.pdfbox.pdmodel.*;
|
||||
import org.apache.pdfbox.pdmodel.common.PDMetadata;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
@@ -201,20 +198,10 @@ public class GetInfoOnPDF {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use Stream-to-File pattern: save to temp file instead of loading into memory
|
||||
// This prevents OutOfMemoryError on large PDFs
|
||||
Path tempFile = null;
|
||||
try {
|
||||
tempFile = Files.createTempFile("preflight-", ".pdf");
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
document.save(baos);
|
||||
|
||||
// Save document to temp file (avoids loading entire document into memory)
|
||||
try (var outputStream = Files.newOutputStream(tempFile)) {
|
||||
document.save(outputStream);
|
||||
}
|
||||
|
||||
// Use RandomAccessReadBufferedFile for efficient file-based reading
|
||||
// This avoids Windows file locking issues that occur with memory-mapped files
|
||||
try (RandomAccessRead source = new RandomAccessReadBufferedFile(tempFile.toFile())) {
|
||||
try (RandomAccessReadBuffer source = new RandomAccessReadBuffer(baos.toByteArray())) {
|
||||
PreflightParser parser = new PreflightParser(source);
|
||||
|
||||
try (PDDocument parsedDocument = parser.parse()) {
|
||||
@@ -256,19 +243,6 @@ public class GetInfoOnPDF {
|
||||
log.debug("IOException during PDF/A validation: {}", e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.debug("Unexpected error during PDF/A validation: {}", e.getMessage());
|
||||
} finally {
|
||||
// Explicitly clean up temp file to prevent disk exhaustion
|
||||
// This must be in finally block to ensure cleanup even on exceptions
|
||||
if (tempFile != null) {
|
||||
try {
|
||||
Files.deleteIfExists(tempFile);
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Failed to delete temp file during PDF/A validation cleanup: {}",
|
||||
tempFile,
|
||||
e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
+34
-31
@@ -42,17 +42,25 @@ public class PasswordController {
|
||||
MultipartFile fileInput = request.getFileInput();
|
||||
String password = request.getPassword();
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(fileInput, password)) {
|
||||
PDDocument document;
|
||||
try {
|
||||
document = pdfDocumentFactory.load(fileInput, password);
|
||||
} catch (IOException e) {
|
||||
// Handle password errors specifically
|
||||
if (ExceptionUtils.isPasswordError(e)) {
|
||||
throw ExceptionUtils.createPdfPasswordException(e);
|
||||
}
|
||||
throw ExceptionUtils.handlePdfException(e);
|
||||
}
|
||||
|
||||
try {
|
||||
document.setAllSecurityToBeRemoved(true);
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
fileInput.getOriginalFilename(), "_password_removed.pdf"));
|
||||
} catch (IOException e) {
|
||||
// Handle password errors specifically
|
||||
if (ExceptionUtils.isPasswordError(e)) {
|
||||
throw ExceptionUtils.createPdfPasswordException(e);
|
||||
}
|
||||
document.close();
|
||||
ExceptionUtils.logException("password removal", e);
|
||||
throw ExceptionUtils.handlePdfException(e);
|
||||
}
|
||||
@@ -83,36 +91,31 @@ public class PasswordController {
|
||||
boolean preventPrinting = Boolean.TRUE.equals(request.getPreventPrinting());
|
||||
boolean preventPrintingFaithful = Boolean.TRUE.equals(request.getPreventPrintingFaithful());
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(fileInput)) {
|
||||
AccessPermission ap = new AccessPermission();
|
||||
ap.setCanAssembleDocument(!preventAssembly);
|
||||
ap.setCanExtractContent(!preventExtractContent);
|
||||
ap.setCanExtractForAccessibility(!preventExtractForAccessibility);
|
||||
ap.setCanFillInForm(!preventFillInForm);
|
||||
ap.setCanModify(!preventModify);
|
||||
ap.setCanModifyAnnotations(!preventModifyAnnotations);
|
||||
ap.setCanPrint(!preventPrinting);
|
||||
ap.setCanPrintFaithful(!preventPrintingFaithful);
|
||||
StandardProtectionPolicy spp =
|
||||
new StandardProtectionPolicy(ownerPassword, password, ap);
|
||||
PDDocument document = pdfDocumentFactory.load(fileInput);
|
||||
AccessPermission ap = new AccessPermission();
|
||||
ap.setCanAssembleDocument(!preventAssembly);
|
||||
ap.setCanExtractContent(!preventExtractContent);
|
||||
ap.setCanExtractForAccessibility(!preventExtractForAccessibility);
|
||||
ap.setCanFillInForm(!preventFillInForm);
|
||||
ap.setCanModify(!preventModify);
|
||||
ap.setCanModifyAnnotations(!preventModifyAnnotations);
|
||||
ap.setCanPrint(!preventPrinting);
|
||||
ap.setCanPrintFaithful(!preventPrintingFaithful);
|
||||
StandardProtectionPolicy spp = new StandardProtectionPolicy(ownerPassword, password, ap);
|
||||
|
||||
if ((ownerPassword != null && ownerPassword.length() > 0)
|
||||
|| (password != null && password.length() > 0)) {
|
||||
spp.setEncryptionKeyLength(keyLength);
|
||||
}
|
||||
spp.setPermissions(ap);
|
||||
document.protect(spp);
|
||||
if (!"".equals(ownerPassword) || !"".equals(password)) {
|
||||
spp.setEncryptionKeyLength(keyLength);
|
||||
}
|
||||
spp.setPermissions(ap);
|
||||
document.protect(spp);
|
||||
|
||||
if ((ownerPassword == null || ownerPassword.length() == 0)
|
||||
&& (password == null || password.length() == 0))
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
fileInput.getOriginalFilename(), "_permissions.pdf"));
|
||||
if ("".equals(ownerPassword) && "".equals(password))
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
fileInput.getOriginalFilename(), "_passworded.pdf"));
|
||||
}
|
||||
fileInput.getOriginalFilename(), "_permissions.pdf"));
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(fileInput.getOriginalFilename(), "_passworded.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
+18
-19
@@ -41,29 +41,28 @@ public class RemoveCertSignController {
|
||||
throws Exception {
|
||||
MultipartFile pdf = request.getFileInput();
|
||||
|
||||
// Load the PDF document with proper resource management
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdf)) {
|
||||
// Load the PDF document
|
||||
PDDocument document = pdfDocumentFactory.load(pdf);
|
||||
|
||||
// Get the document catalog
|
||||
PDDocumentCatalog catalog = document.getDocumentCatalog();
|
||||
// Get the document catalog
|
||||
PDDocumentCatalog catalog = document.getDocumentCatalog();
|
||||
|
||||
// Get the AcroForm
|
||||
PDAcroForm acroForm = catalog.getAcroForm();
|
||||
if (acroForm != null) {
|
||||
// Remove signature fields safely
|
||||
List<PDField> fieldsToRemove =
|
||||
acroForm.getFields().stream()
|
||||
.filter(field -> field instanceof PDSignatureField)
|
||||
.toList();
|
||||
// Get the AcroForm
|
||||
PDAcroForm acroForm = catalog.getAcroForm();
|
||||
if (acroForm != null) {
|
||||
// Remove signature fields safely
|
||||
List<PDField> fieldsToRemove =
|
||||
acroForm.getFields().stream()
|
||||
.filter(field -> field instanceof PDSignatureField)
|
||||
.toList();
|
||||
|
||||
if (!fieldsToRemove.isEmpty()) {
|
||||
acroForm.flatten(fieldsToRemove, false);
|
||||
}
|
||||
if (!fieldsToRemove.isEmpty()) {
|
||||
acroForm.flatten(fieldsToRemove, false);
|
||||
}
|
||||
// Return the modified PDF as a response
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(pdf.getOriginalFilename(), "_unsigned.pdf"));
|
||||
}
|
||||
// Return the modified PDF as a response
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(pdf.getOriginalFilename(), "_unsigned.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
+32
-33
@@ -67,40 +67,39 @@ public class SanitizeController {
|
||||
boolean removeLinks = Boolean.TRUE.equals(request.getRemoveLinks());
|
||||
boolean removeFonts = Boolean.TRUE.equals(request.getRemoveFonts());
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(inputFile, true)) {
|
||||
if (removeJavaScript) {
|
||||
sanitizeJavaScript(document);
|
||||
}
|
||||
|
||||
if (removeEmbeddedFiles) {
|
||||
sanitizeEmbeddedFiles(document);
|
||||
}
|
||||
|
||||
if (removeXMPMetadata) {
|
||||
sanitizeXMPMetadata(document);
|
||||
}
|
||||
|
||||
if (removeMetadata) {
|
||||
sanitizeDocumentInfoMetadata(document);
|
||||
}
|
||||
|
||||
if (removeLinks) {
|
||||
sanitizeLinks(document);
|
||||
}
|
||||
|
||||
if (removeFonts) {
|
||||
sanitizeFonts(document);
|
||||
}
|
||||
|
||||
// Save the sanitized document to output stream
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
document.save(outputStream);
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
outputStream.toByteArray(),
|
||||
GeneralUtils.generateFilename(
|
||||
inputFile.getOriginalFilename(), "_sanitized.pdf"));
|
||||
PDDocument document = pdfDocumentFactory.load(inputFile, true);
|
||||
if (removeJavaScript) {
|
||||
sanitizeJavaScript(document);
|
||||
}
|
||||
|
||||
if (removeEmbeddedFiles) {
|
||||
sanitizeEmbeddedFiles(document);
|
||||
}
|
||||
|
||||
if (removeXMPMetadata) {
|
||||
sanitizeXMPMetadata(document);
|
||||
}
|
||||
|
||||
if (removeMetadata) {
|
||||
sanitizeDocumentInfoMetadata(document);
|
||||
}
|
||||
|
||||
if (removeLinks) {
|
||||
sanitizeLinks(document);
|
||||
}
|
||||
|
||||
if (removeFonts) {
|
||||
sanitizeFonts(document);
|
||||
}
|
||||
|
||||
// Save the sanitized document to output stream
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
document.save(outputStream);
|
||||
document.close();
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
outputStream.toByteArray(),
|
||||
GeneralUtils.generateFilename(inputFile.getOriginalFilename(), "_sanitized.pdf"));
|
||||
}
|
||||
|
||||
private static void sanitizeJavaScript(PDDocument document) throws IOException {
|
||||
|
||||
+47
-54
@@ -98,67 +98,60 @@ public class WatermarkController {
|
||||
String customColor = request.getCustomColor();
|
||||
boolean convertPdfToImage = Boolean.TRUE.equals(request.getConvertPDFToImage());
|
||||
|
||||
// Load the input PDF with proper resource management
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
|
||||
// Load the input PDF
|
||||
PDDocument document = pdfDocumentFactory.load(pdfFile);
|
||||
|
||||
// Create a page in the document
|
||||
for (PDPage page : document.getPages()) {
|
||||
// Get the page's content stream
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true)) {
|
||||
// Create a page in the document
|
||||
for (PDPage page : document.getPages()) {
|
||||
|
||||
// Set transparency
|
||||
PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState();
|
||||
graphicsState.setNonStrokingAlphaConstant(opacity);
|
||||
contentStream.setGraphicsStateParameters(graphicsState);
|
||||
// Get the page's content stream
|
||||
PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document, page, PDPageContentStream.AppendMode.APPEND, true, true);
|
||||
|
||||
if ("text".equalsIgnoreCase(watermarkType)) {
|
||||
addTextWatermark(
|
||||
contentStream,
|
||||
watermarkText,
|
||||
document,
|
||||
page,
|
||||
rotation,
|
||||
widthSpacer,
|
||||
heightSpacer,
|
||||
fontSize,
|
||||
alphabet,
|
||||
customColor);
|
||||
} else if ("image".equalsIgnoreCase(watermarkType)) {
|
||||
addImageWatermark(
|
||||
contentStream,
|
||||
watermarkImage,
|
||||
document,
|
||||
page,
|
||||
rotation,
|
||||
widthSpacer,
|
||||
heightSpacer,
|
||||
fontSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Set transparency
|
||||
PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState();
|
||||
graphicsState.setNonStrokingAlphaConstant(opacity);
|
||||
contentStream.setGraphicsStateParameters(graphicsState);
|
||||
|
||||
if (convertPdfToImage) {
|
||||
try (PDDocument convertedPdf = PdfUtils.convertPdfToPdfImage(document)) {
|
||||
// Return the watermarked PDF as a response
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
convertedPdf,
|
||||
GeneralUtils.generateFilename(
|
||||
pdfFile.getOriginalFilename(), "_watermarked.pdf"));
|
||||
}
|
||||
} else {
|
||||
// Return the watermarked PDF as a response
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
if ("text".equalsIgnoreCase(watermarkType)) {
|
||||
addTextWatermark(
|
||||
contentStream,
|
||||
watermarkText,
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
pdfFile.getOriginalFilename(), "_watermarked.pdf"));
|
||||
page,
|
||||
rotation,
|
||||
widthSpacer,
|
||||
heightSpacer,
|
||||
fontSize,
|
||||
alphabet,
|
||||
customColor);
|
||||
} else if ("image".equalsIgnoreCase(watermarkType)) {
|
||||
addImageWatermark(
|
||||
contentStream,
|
||||
watermarkImage,
|
||||
document,
|
||||
page,
|
||||
rotation,
|
||||
widthSpacer,
|
||||
heightSpacer,
|
||||
fontSize);
|
||||
}
|
||||
|
||||
// Close the content stream
|
||||
contentStream.close();
|
||||
}
|
||||
|
||||
if (convertPdfToImage) {
|
||||
PDDocument convertedPdf = PdfUtils.convertPdfToPdfImage(document);
|
||||
document.close();
|
||||
document = convertedPdf;
|
||||
}
|
||||
|
||||
// Return the watermarked PDF as a response
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_watermarked.pdf"));
|
||||
}
|
||||
|
||||
private void addTextWatermark(
|
||||
|
||||
+49
-392
@@ -15,106 +15,84 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.util.HtmlUtils;
|
||||
import org.springframework.web.util.JavaScriptUtils;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
|
||||
@Slf4j
|
||||
@Controller
|
||||
public class ReactRoutingController {
|
||||
|
||||
private static final org.slf4j.Logger log =
|
||||
org.slf4j.LoggerFactory.getLogger(ReactRoutingController.class);
|
||||
|
||||
@Value("${server.servlet.context-path:/}")
|
||||
private String contextPath;
|
||||
|
||||
private String cachedIndexHtml;
|
||||
private String cachedCallbackHtml;
|
||||
private boolean indexHtmlExists = false;
|
||||
private boolean useExternalIndexHtml = false;
|
||||
private boolean loggedMissingIndex = false;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
log.info("Static files custom path: {}", InstallationPathConfig.getStaticPath());
|
||||
|
||||
// Always initialize callback HTML (used for OAuth desktop flow)
|
||||
this.cachedCallbackHtml = buildCallbackHtml();
|
||||
|
||||
// Check for external index.html first (customFiles/static/)
|
||||
Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html");
|
||||
log.debug("Checking for custom index.html at: {}", externalIndexPath);
|
||||
if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) {
|
||||
log.info("Using custom index.html from: {}", externalIndexPath);
|
||||
this.cachedIndexHtml = processIndexHtml();
|
||||
this.indexHtmlExists = true;
|
||||
this.useExternalIndexHtml = true;
|
||||
return;
|
||||
try {
|
||||
this.cachedIndexHtml = processIndexHtml();
|
||||
this.indexHtmlExists = true;
|
||||
this.useExternalIndexHtml = true;
|
||||
return;
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to load custom index.html, falling back to classpath", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to classpath index.html
|
||||
ClassPathResource resource = new ClassPathResource("static/index.html");
|
||||
if (resource.exists()) {
|
||||
this.cachedIndexHtml = processIndexHtml();
|
||||
this.indexHtmlExists = true;
|
||||
this.useExternalIndexHtml = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Neither external nor classpath index.html exists - cache fallback once
|
||||
this.cachedIndexHtml = buildFallbackHtml();
|
||||
this.indexHtmlExists = true;
|
||||
this.useExternalIndexHtml = false;
|
||||
this.loggedMissingIndex = true;
|
||||
log.warn(
|
||||
"index.html not found in classpath or custom path; using lightweight fallback page");
|
||||
}
|
||||
|
||||
private String processIndexHtml() {
|
||||
try {
|
||||
Resource resource = getIndexHtmlResource();
|
||||
|
||||
if (!resource.exists()) {
|
||||
if (!loggedMissingIndex) {
|
||||
log.warn("index.html not found, using lightweight fallback page");
|
||||
loggedMissingIndex = true;
|
||||
}
|
||||
return buildFallbackHtml();
|
||||
try {
|
||||
this.cachedIndexHtml = processIndexHtml();
|
||||
this.indexHtmlExists = true;
|
||||
this.useExternalIndexHtml = false;
|
||||
} catch (IOException e) {
|
||||
// Failed to cache, will process on each request
|
||||
log.warn("Failed to cache index.html", e);
|
||||
this.indexHtmlExists = false;
|
||||
}
|
||||
|
||||
try (InputStream inputStream = resource.getInputStream()) {
|
||||
String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
|
||||
// Replace %BASE_URL% with the actual context path for base href
|
||||
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
|
||||
html = html.replace("%BASE_URL%", baseUrl);
|
||||
// Also rewrite any existing <base> tag (Vite may have baked one in)
|
||||
html =
|
||||
html.replaceFirst(
|
||||
"<base href=\\\"[^\\\"]*\\\"\\s*/?>",
|
||||
"<base href=\\\"" + baseUrl + "\\\" />");
|
||||
|
||||
// Inject context path as a global variable for API calls
|
||||
String contextPathScript =
|
||||
"<script>window.STIRLING_PDF_API_BASE_URL = '" + baseUrl + "';</script>";
|
||||
html = html.replace("</head>", contextPathScript + "</head>");
|
||||
|
||||
return html;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
if (!loggedMissingIndex) {
|
||||
log.warn("index.html not found, using lightweight fallback page", ex);
|
||||
loggedMissingIndex = true;
|
||||
}
|
||||
return buildFallbackHtml();
|
||||
}
|
||||
}
|
||||
|
||||
private Resource getIndexHtmlResource() {
|
||||
private String processIndexHtml() throws IOException {
|
||||
Resource resource = getIndexHtmlResource();
|
||||
|
||||
try (InputStream inputStream = resource.getInputStream()) {
|
||||
String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
|
||||
// Replace %BASE_URL% with the actual context path for base href
|
||||
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
|
||||
html = html.replace("%BASE_URL%", baseUrl);
|
||||
// Also rewrite any existing <base> tag (Vite may have baked one in)
|
||||
html =
|
||||
html.replaceFirst(
|
||||
"<base href=\\\"[^\\\"]*\\\"\\s*/?>",
|
||||
"<base href=\\\"" + baseUrl + "\\\" />");
|
||||
|
||||
// Inject context path as a global variable for API calls
|
||||
String contextPathScript =
|
||||
"<script>window.STIRLING_PDF_API_BASE_URL = '" + baseUrl + "';</script>";
|
||||
html = html.replace("</head>", contextPathScript + "</head>");
|
||||
|
||||
return html;
|
||||
}
|
||||
}
|
||||
|
||||
private Resource getIndexHtmlResource() throws IOException {
|
||||
// Check external location first
|
||||
Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html");
|
||||
if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) {
|
||||
@@ -128,28 +106,12 @@ public class ReactRoutingController {
|
||||
@GetMapping(
|
||||
value = {"/", "/index.html"},
|
||||
produces = MediaType.TEXT_HTML_VALUE)
|
||||
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request) {
|
||||
try {
|
||||
if (indexHtmlExists && cachedIndexHtml != null) {
|
||||
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml);
|
||||
}
|
||||
// Fallback: process on each request (dev mode or cache failed)
|
||||
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(processIndexHtml());
|
||||
} catch (Exception ex) {
|
||||
log.error("Failed to serve index.html, returning fallback", ex);
|
||||
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(buildFallbackHtml());
|
||||
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request) throws IOException {
|
||||
if (indexHtmlExists && cachedIndexHtml != null) {
|
||||
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping(value = "/auth/callback", produces = MediaType.TEXT_HTML_VALUE)
|
||||
public ResponseEntity<String> serveAuthCallback(HttpServletRequest request) {
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/auth/callback/tauri", produces = MediaType.TEXT_HTML_VALUE)
|
||||
public ResponseEntity<String> serveTauriAuthCallback(HttpServletRequest request) {
|
||||
// cachedCallbackHtml is always initialized in @PostConstruct
|
||||
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedCallbackHtml);
|
||||
// Fallback: process on each request (dev mode or cache failed)
|
||||
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(processIndexHtml());
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
@@ -164,309 +126,4 @@ public class ReactRoutingController {
|
||||
throws IOException {
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
|
||||
private String buildFallbackHtml() {
|
||||
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
|
||||
|
||||
// Escape for HTML attribute context
|
||||
String escapedBaseUrlHtml = HtmlUtils.htmlEscape(baseUrl);
|
||||
|
||||
// Escape for JavaScript string context
|
||||
String escapedBaseUrlJs = JavaScriptUtils.javaScriptEscape(baseUrl);
|
||||
|
||||
String serverUrl = "(window.location.origin + '" + escapedBaseUrlJs + "')";
|
||||
return """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<base href="%s" />
|
||||
<title>Stirling PDF</title>
|
||||
<script>
|
||||
// Minimal handler for SSO callback when index.html is missing (desktop fallback)
|
||||
(function() {
|
||||
const baseUrl = '%s';
|
||||
window.STIRLING_PDF_API_BASE_URL = baseUrl;
|
||||
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const token = hashParams.get('access_token') || hashParams.get('token') || searchParams.get('access_token');
|
||||
const serverUrl = %s;
|
||||
|
||||
if (token) {
|
||||
// Extract nonce from URL to send back to desktop app for validation
|
||||
const nonceFromUrl = hashParams.get('nonce') || searchParams.get('nonce');
|
||||
|
||||
console.log('[Fallback Auth] Token received, sending to desktop app via deep link');
|
||||
|
||||
// Send token + nonce via deep link to desktop app
|
||||
// Desktop app will validate nonce before accepting token
|
||||
try {
|
||||
const encodedToken = encodeURIComponent(token);
|
||||
const encodedServer = encodeURIComponent(serverUrl);
|
||||
const encodedNonce = nonceFromUrl ? encodeURIComponent(nonceFromUrl) : '';
|
||||
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#access_token=${encodedToken}&nonce=${encodedNonce}&type=sso-selfhosted`;
|
||||
window.location.href = deepLink;
|
||||
return;
|
||||
} catch (_) {
|
||||
// ignore deep link errors
|
||||
}
|
||||
}
|
||||
|
||||
// No redirect to avoid loops when index.html is missing
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<p>Stirling PDF is running.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
.formatted(escapedBaseUrlHtml, escapedBaseUrlJs, serverUrl);
|
||||
}
|
||||
|
||||
private String buildCallbackHtml() {
|
||||
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
|
||||
|
||||
// Escape for HTML attribute context
|
||||
String escapedBaseUrlHtml = HtmlUtils.htmlEscape(baseUrl);
|
||||
|
||||
// Escape for JavaScript string context
|
||||
String escapedBaseUrlJs = JavaScriptUtils.javaScriptEscape(baseUrl);
|
||||
|
||||
String serverUrl = "(window.location.origin + '" + escapedBaseUrlJs + "')";
|
||||
return """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<base href="%s" />
|
||||
<title>Authentication Complete</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
text-align: center;
|
||||
padding: 50px 20px;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
max-width: 420px;
|
||||
width: 100%%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e5e7eb;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.icon.error {
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.error-details {
|
||||
background: #ffebee;
|
||||
border: 1px solid #ffcdd2;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
color: #c62828;
|
||||
word-break: break-word;
|
||||
text-align: left;
|
||||
line-height: 1.5;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: #2d2d2d;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border-color: #374151;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: #66bb6a;
|
||||
}
|
||||
|
||||
.icon.error {
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
.error-details {
|
||||
background: #3d2020;
|
||||
border: 1px solid #5d3030;
|
||||
color: #ef9a9a;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
body {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 40px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function() {
|
||||
const run = () => {
|
||||
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const token = hashParams.get('access_token') || hashParams.get('token') || searchParams.get('access_token');
|
||||
const errorCode = searchParams.get('errorOAuth')
|
||||
|| searchParams.get('error')
|
||||
|| hashParams.get('error')
|
||||
|| searchParams.get('error_description')
|
||||
|| hashParams.get('error_description');
|
||||
const serverUrl = %s;
|
||||
const iconEl = document.getElementById('auth-icon');
|
||||
const titleEl = document.getElementById('auth-title');
|
||||
const messageEl = document.getElementById('auth-message');
|
||||
const detailsEl = document.getElementById('auth-error-details');
|
||||
|
||||
const sendDeepLink = (type, value, key) => {
|
||||
try {
|
||||
const encodedValue = encodeURIComponent(value || '');
|
||||
const encodedServer = encodeURIComponent(serverUrl);
|
||||
const hashKey = key || 'access_token';
|
||||
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#${hashKey}=${encodedValue}&type=${type}`;
|
||||
window.location.href = deepLink;
|
||||
} catch (_) {
|
||||
// ignore deep link errors
|
||||
}
|
||||
};
|
||||
|
||||
const showError = (message, details) => {
|
||||
if (iconEl) {
|
||||
iconEl.textContent = '✗';
|
||||
iconEl.classList.add('error');
|
||||
}
|
||||
if (titleEl) {
|
||||
titleEl.textContent = 'Authentication failed';
|
||||
}
|
||||
if (messageEl) {
|
||||
messageEl.textContent = message;
|
||||
}
|
||||
if (detailsEl && details) {
|
||||
detailsEl.textContent = details;
|
||||
detailsEl.style.display = 'block';
|
||||
}
|
||||
};
|
||||
|
||||
if (token) {
|
||||
// Extract nonce from URL to send back to desktop app for validation
|
||||
// (System browser doesn't have access to desktop app's sessionStorage)
|
||||
const nonceFromUrl = hashParams.get('nonce') || searchParams.get('nonce');
|
||||
|
||||
console.log('[Auth Callback] Token received, sending to desktop app via deep link');
|
||||
|
||||
// Send token + nonce via deep link to desktop app
|
||||
// Desktop app will validate nonce before accepting token
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const encodedToken = encodeURIComponent(token);
|
||||
const encodedServer = encodeURIComponent(serverUrl);
|
||||
const encodedNonce = nonceFromUrl ? encodeURIComponent(nonceFromUrl) : '';
|
||||
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#access_token=${encodedToken}&nonce=${encodedNonce}&type=sso-selfhosted`;
|
||||
window.location.href = deepLink;
|
||||
} catch (err) {
|
||||
console.error('[Auth Callback] Failed to trigger deep link:', err);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorCode) {
|
||||
const isCancelled = errorCode === 'access_denied';
|
||||
sendDeepLink('sso-error', errorCode, 'error');
|
||||
showError(
|
||||
isCancelled
|
||||
? 'Authentication was cancelled. You can close this window and return to the app.'
|
||||
: 'Authentication was not successful. You can close this window and return to the app.',
|
||||
errorCode
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showError(
|
||||
'Authentication did not complete. You can close this window and try again.',
|
||||
'missing_token'
|
||||
);
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', run);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon" id="auth-icon">✓</div>
|
||||
<h1 id="auth-title">Authentication complete</h1>
|
||||
<p id="auth-message">You can close this window and return to Stirling PDF.</p>
|
||||
<div class="error-details" id="auth-error-details"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
.formatted(escapedBaseUrlHtml, serverUrl);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-40
@@ -80,7 +80,7 @@ import stirling.software.common.util.RegexPatternUtils;
|
||||
* <pre>{@code
|
||||
* // In controllers/services - use ExceptionUtils to create typed exceptions:
|
||||
* try {
|
||||
* PDDocument doc = Loader.loadPDF(file);
|
||||
* PDDocument doc = PDDocument.load(file);
|
||||
* } catch (IOException e) {
|
||||
* throw ExceptionUtils.createPdfCorruptedException("during load", e);
|
||||
* }
|
||||
@@ -1117,34 +1117,6 @@ public class GlobalExceptionHandler {
|
||||
return handleBaseApp((BaseAppException) processedException, request);
|
||||
}
|
||||
|
||||
// Check if this is a NoSuchFileException (temp file was deleted prematurely)
|
||||
if (ex instanceof java.nio.file.NoSuchFileException) {
|
||||
log.error(
|
||||
"Temporary file not found at {}: {}",
|
||||
request.getRequestURI(),
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
|
||||
String message =
|
||||
getLocalizedMessage(
|
||||
"error.tempFileNotFound.detail",
|
||||
"The temporary file was not found. This may indicate a processing error or cleanup issue. Please try again.");
|
||||
String title =
|
||||
getLocalizedMessage("error.tempFileNotFound.title", "Temporary File Not Found");
|
||||
|
||||
ProblemDetail problemDetail =
|
||||
createBaseProblemDetail(HttpStatus.INTERNAL_SERVER_ERROR, message, request);
|
||||
problemDetail.setType(URI.create("https://stirlingpdf.com/errors/temp-file-not-found"));
|
||||
problemDetail.setTitle(title);
|
||||
problemDetail.setProperty("title", title);
|
||||
problemDetail.setProperty("errorCode", "E999");
|
||||
problemDetail.setProperty(
|
||||
"hint.1",
|
||||
"This error usually occurs when temporary files are cleaned up before processing completes.");
|
||||
problemDetail.setProperty("hint.2", "Try submitting your request again.");
|
||||
return new ResponseEntity<>(problemDetail, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
log.error("IO error at {}: {}", request.getRequestURI(), ex.getMessage(), ex);
|
||||
|
||||
String message =
|
||||
@@ -1189,19 +1161,9 @@ public class GlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ProblemDetail> handleGenericException(
|
||||
Exception ex, HttpServletRequest request, HttpServletResponse response) {
|
||||
Exception ex, HttpServletRequest request) {
|
||||
log.error("Unexpected error at {}: {}", request.getRequestURI(), ex.getMessage(), ex);
|
||||
|
||||
// If response is already committed (e.g., during streaming), we can't send an error
|
||||
// response
|
||||
// Log the error and return null to let Spring handle it gracefully
|
||||
if (response.isCommitted()) {
|
||||
log.warn(
|
||||
"Cannot send error response because response is already committed for URI: {}",
|
||||
request.getRequestURI());
|
||||
return null; // Spring will handle gracefully
|
||||
}
|
||||
|
||||
String userMessage =
|
||||
getLocalizedMessage(
|
||||
"error.unexpected",
|
||||
|
||||
@@ -204,65 +204,11 @@ public class VeraPDFService {
|
||||
detectedFlavours = detectionParser.getFlavours();
|
||||
}
|
||||
|
||||
// For PDF/A flavours, we need to validate first to check if PDF/A identification exists in
|
||||
// XMP
|
||||
// If declaredFlavour is PDF/A, do a quick validation to check for PDF/A identification
|
||||
// schema
|
||||
boolean hasValidPdfaMetadata = false;
|
||||
if (isPdfaFlavour(declaredFlavour)) {
|
||||
try (PDFAParser quickParser =
|
||||
Foundries.defaultInstance()
|
||||
.createParser(new ByteArrayInputStream(pdfBytes), declaredFlavour)) {
|
||||
PDFAValidator quickValidator =
|
||||
Foundries.defaultInstance().createValidator(declaredFlavour, false);
|
||||
ValidationResult quickResult = quickValidator.validate(quickParser);
|
||||
|
||||
// Check if the document has the PDF/A Identification extension schema (clause
|
||||
// 6.7.11, test 1)
|
||||
// OR if it lacks XMP metadata entirely (clause 6.7.2, test 1)
|
||||
// If either of these errors is present, the document is NOT a declared PDF/A
|
||||
hasValidPdfaMetadata = true;
|
||||
for (TestAssertion assertion : quickResult.getTestAssertions()) {
|
||||
if (assertion.getStatus() == TestAssertion.Status.FAILED
|
||||
&& assertion.getRuleId() != null) {
|
||||
String clause = assertion.getRuleId().getClause();
|
||||
int testNumber = assertion.getRuleId().getTestNumber();
|
||||
|
||||
// Missing XMP metadata entirely (clause 6.7.2, test 1)
|
||||
if ("6.7.2".equals(clause) && testNumber == 1) {
|
||||
hasValidPdfaMetadata = false;
|
||||
log.debug(
|
||||
"Document lacks XMP metadata (6.7.2): {}",
|
||||
assertion.getMessage());
|
||||
break;
|
||||
}
|
||||
|
||||
// Missing PDF/A identification schema in XMP (clause 6.7.11, test 1)
|
||||
if ("6.7.11".equals(clause) && testNumber == 1) {
|
||||
hasValidPdfaMetadata = false;
|
||||
log.debug(
|
||||
"Document lacks PDF/A identification in XMP (6.7.11): {}",
|
||||
assertion.getMessage());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Error checking for PDF/A identification: {}", e.getMessage());
|
||||
hasValidPdfaMetadata = false;
|
||||
}
|
||||
}
|
||||
|
||||
List<PDFAFlavour> flavoursToValidate = new ArrayList<>();
|
||||
boolean hasPdfaDeclaration = isPdfaFlavour(declaredFlavour) && hasValidPdfaMetadata;
|
||||
boolean hasPdfaDeclaration = isPdfaFlavour(declaredFlavour);
|
||||
|
||||
if (declaredFlavour != null) {
|
||||
boolean isDeclaredPdfa = isPdfaFlavour(declaredFlavour);
|
||||
if (isDeclaredPdfa && hasPdfaDeclaration) {
|
||||
flavoursToValidate.add(declaredFlavour);
|
||||
} else if (!isDeclaredPdfa) {
|
||||
flavoursToValidate.add(declaredFlavour);
|
||||
}
|
||||
flavoursToValidate.add(declaredFlavour);
|
||||
}
|
||||
|
||||
for (PDFAFlavour flavour : detectedFlavours) {
|
||||
|
||||
-3
@@ -220,9 +220,6 @@ public class Type3FontLibrary {
|
||||
}
|
||||
|
||||
private byte[] loadResourceBytes(String location) throws IOException {
|
||||
if (location == null || location.isBlank()) {
|
||||
throw new IOException("Resource location is null or blank");
|
||||
}
|
||||
String resolved = resolveLocation(location);
|
||||
Resource resource = resourceLoader.getResource(resolved);
|
||||
if (!resource.exists()) {
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package stirling.software.SPDF.service.telegram;
|
||||
|
||||
/**
|
||||
* Enumeration representing different feedback types for Telegram service.
|
||||
*
|
||||
* @since 2.2.x
|
||||
*/
|
||||
public enum FeedbackEnum {
|
||||
/** Indicates that the provided document is not valid. */
|
||||
NO_VALID_DOCUMENT,
|
||||
|
||||
/** Represents a generic error message. */
|
||||
ERROR_MESSAGE,
|
||||
|
||||
/** Indicates that an error occurred during processing. */
|
||||
ERROR_PROCESSING,
|
||||
|
||||
/** Indicates that processing is ongoing. */
|
||||
PROCESSING
|
||||
}
|
||||
-524
@@ -1,524 +0,0 @@
|
||||
package stirling.software.SPDF.service.telegram;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.telegram.telegrambots.bots.TelegramLongPollingBot;
|
||||
import org.telegram.telegrambots.meta.TelegramBotsApi;
|
||||
import org.telegram.telegrambots.meta.api.methods.GetFile;
|
||||
import org.telegram.telegrambots.meta.api.methods.send.SendDocument;
|
||||
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
|
||||
import org.telegram.telegrambots.meta.api.objects.Chat;
|
||||
import org.telegram.telegrambots.meta.api.objects.Document;
|
||||
import org.telegram.telegrambots.meta.api.objects.File;
|
||||
import org.telegram.telegrambots.meta.api.objects.InputFile;
|
||||
import org.telegram.telegrambots.meta.api.objects.Message;
|
||||
import org.telegram.telegrambots.meta.api.objects.Update;
|
||||
import org.telegram.telegrambots.meta.api.objects.User;
|
||||
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Telegram bot that processes incoming files through a defined pipeline.
|
||||
*
|
||||
* @since 2.2.x
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(prefix = "telegram", name = "enabled", havingValue = "true")
|
||||
public class TelegramPipelineBot extends TelegramLongPollingBot {
|
||||
|
||||
private static final String CHAT_PRIVATE = "private";
|
||||
private static final String CHAT_GROUP = "group";
|
||||
private static final String CHAT_SUPERGROUP = "supergroup";
|
||||
private static final String CHAT_CHANNEL = "channel";
|
||||
|
||||
private static final Set<String> SUPPORTED_CHAT_TYPES =
|
||||
Set.of(CHAT_PRIVATE, CHAT_GROUP, CHAT_SUPERGROUP, CHAT_CHANNEL);
|
||||
|
||||
private static final Set<String> ALLOWED_MIME_TYPES = Set.of("application/pdf");
|
||||
|
||||
private final Object pipelinePollMonitor = new Object();
|
||||
|
||||
private final ApplicationProperties.Telegram telegramProperties;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
private final TelegramBotsApi telegramBotsApi;
|
||||
|
||||
public TelegramPipelineBot(
|
||||
ApplicationProperties applicationProperties,
|
||||
RuntimePathConfig runtimePathConfig,
|
||||
TelegramBotsApi telegramBotsApi) {
|
||||
|
||||
super(applicationProperties.getTelegram().getBotToken());
|
||||
this.telegramProperties = applicationProperties.getTelegram();
|
||||
this.runtimePathConfig = runtimePathConfig;
|
||||
this.telegramBotsApi = telegramBotsApi;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void register() {
|
||||
if (StringUtils.isAnyBlank(getBotUsername(), getBotToken())) {
|
||||
log.warn("Telegram bot disabled because botToken or botUsername is not configured");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
telegramBotsApi.registerBot(this);
|
||||
log.info("Telegram pipeline bot registered as {}", getBotUsername());
|
||||
} catch (TelegramApiException e) {
|
||||
log.error("Failed to register Telegram bot", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUpdateReceived(Update update) {
|
||||
Message message = extractMessage(update);
|
||||
if (message == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Chat chat = message.getChat();
|
||||
if (chat == null || !isSupportedChatType(chat.getType())) {
|
||||
log.info(
|
||||
"Ignoring message {}, unsupported chat type {}",
|
||||
message.getMessageId(),
|
||||
chat != null ? chat.getType() : "null");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAuthorized(message, chat)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.hasMessage() && update.getMessage().hasText()) {
|
||||
String messageText = update.getMessage().getText();
|
||||
long chatId = update.getMessage().getChatId();
|
||||
if ("/start".equals(messageText)) {
|
||||
sendMessage(
|
||||
chatId,
|
||||
"""
|
||||
Welcome to the SPDF Telegram Bot!
|
||||
|
||||
To get started, please send me a PDF document that you would like to process.
|
||||
Make sure the document is in PDF format.
|
||||
|
||||
Once I receive your document, I'll begin processing it through the pipeline.
|
||||
""");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (message.hasDocument()) {
|
||||
handleIncomingFile(message);
|
||||
return;
|
||||
}
|
||||
if (feedback(FeedbackEnum.NO_VALID_DOCUMENT, chat.getType())) {
|
||||
sendMessage(
|
||||
chat.getId(),
|
||||
"No valid file found in the message. Please send a document to process.");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean feedback(FeedbackEnum feedbackEnum, String chatType) {
|
||||
return switch (feedbackEnum) {
|
||||
case NO_VALID_DOCUMENT ->
|
||||
switch (chatType) {
|
||||
case CHAT_CHANNEL ->
|
||||
telegramProperties.getFeedback().getChannel().getNoValidDocument();
|
||||
case CHAT_PRIVATE ->
|
||||
telegramProperties.getFeedback().getUser().getNoValidDocument();
|
||||
default -> true;
|
||||
};
|
||||
case ERROR_MESSAGE ->
|
||||
switch (chatType) {
|
||||
case CHAT_CHANNEL ->
|
||||
telegramProperties.getFeedback().getChannel().getErrorMessage();
|
||||
case CHAT_PRIVATE ->
|
||||
telegramProperties.getFeedback().getUser().getErrorMessage();
|
||||
default -> true;
|
||||
};
|
||||
case ERROR_PROCESSING ->
|
||||
switch (chatType) {
|
||||
case CHAT_CHANNEL ->
|
||||
telegramProperties.getFeedback().getChannel().getErrorProcessing();
|
||||
case CHAT_PRIVATE ->
|
||||
telegramProperties.getFeedback().getUser().getErrorProcessing();
|
||||
default -> true;
|
||||
};
|
||||
case PROCESSING ->
|
||||
switch (chatType) {
|
||||
case CHAT_CHANNEL ->
|
||||
telegramProperties.getFeedback().getChannel().getProcessing();
|
||||
case CHAT_PRIVATE ->
|
||||
telegramProperties.getFeedback().getUser().getProcessing();
|
||||
default -> true;
|
||||
};
|
||||
default -> true;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Message Extraction / Chat Type
|
||||
// ---------------------------
|
||||
|
||||
private Message extractMessage(Update update) {
|
||||
if (update.hasMessage()) return update.getMessage();
|
||||
if (update.hasChannelPost()) return update.getChannelPost();
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isSupportedChatType(String type) {
|
||||
return type != null && SUPPORTED_CHAT_TYPES.contains(type);
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Authorization
|
||||
// ---------------------------
|
||||
|
||||
private boolean isAuthorized(Message message, Chat chat) {
|
||||
if (!(telegramProperties.getEnableAllowUserIDs()
|
||||
|| telegramProperties.getEnableAllowChannelIDs())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return switch (chat.getType()) {
|
||||
case CHAT_CHANNEL -> checkChannelAccess(message, chat);
|
||||
case CHAT_PRIVATE -> checkUserAccess(message, chat);
|
||||
case CHAT_GROUP, CHAT_SUPERGROUP -> true; // groups allowed by default
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private boolean checkUserAccess(Message message, Chat chat) {
|
||||
if (!telegramProperties.getEnableAllowUserIDs()) return true;
|
||||
|
||||
User from = message.getFrom();
|
||||
List<Long> allow = telegramProperties.getAllowUserIDs();
|
||||
|
||||
if (allow.isEmpty()) {
|
||||
log.warn("No allowed user IDs configured - allowing all users.");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (from == null || !allow.contains(from.getId())) {
|
||||
log.info(
|
||||
"Rejecting user {} in private chat {}",
|
||||
from != null ? from.getId() : "unknown",
|
||||
chat.getId());
|
||||
if (feedback(FeedbackEnum.ERROR_MESSAGE, chat.getType())) {
|
||||
sendMessage(chat.getId(), "You are not authorized to use this bot.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean checkChannelAccess(Message message, Chat chat) {
|
||||
if (!telegramProperties.getEnableAllowChannelIDs()) return true;
|
||||
|
||||
Chat senderChat = message.getSenderChat();
|
||||
List<Long> allow = telegramProperties.getAllowChannelIDs();
|
||||
|
||||
if (allow.isEmpty()) {
|
||||
log.warn("No allowed channel IDs configured - allowing all channels.");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (senderChat == null || !allow.contains(senderChat.getId())) {
|
||||
log.info(
|
||||
"Rejecting channel {} in chat {}",
|
||||
senderChat != null ? senderChat.getId() : "unknown",
|
||||
chat.getId());
|
||||
if (feedback(FeedbackEnum.ERROR_MESSAGE, chat.getType())) {
|
||||
sendMessage(chat.getId(), "This channel is not authorized to use this bot.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// File Handling
|
||||
// ---------------------------
|
||||
|
||||
private void handleIncomingFile(Message message) {
|
||||
Long chatId = message.getChatId();
|
||||
Document doc = message.getDocument();
|
||||
String chatType = message.getChat().getType();
|
||||
|
||||
if (doc == null) {
|
||||
if (feedback(FeedbackEnum.NO_VALID_DOCUMENT, chatType)) {
|
||||
sendMessage(chatId, "No document found.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (doc.getMimeType() != null
|
||||
&& !ALLOWED_MIME_TYPES.contains(doc.getMimeType().toLowerCase())) {
|
||||
if (feedback(FeedbackEnum.NO_VALID_DOCUMENT, chatType)) {
|
||||
sendMessage(
|
||||
chatId,
|
||||
"Unsupported MIME type: "
|
||||
+ doc.getMimeType()
|
||||
+ "\nAllowed: "
|
||||
+ String.join(", ", ALLOWED_MIME_TYPES));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasJsonConfig(chatId)) {
|
||||
if (feedback(FeedbackEnum.ERROR_PROCESSING, chatType)) {
|
||||
sendMessage(
|
||||
chatId,
|
||||
"No JSON configuration file found in the pipeline inbox folder. Please"
|
||||
+ " contact the administrator.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!CHAT_CHANNEL.equalsIgnoreCase(chatType)
|
||||
&& feedback(FeedbackEnum.PROCESSING, chatType)) {
|
||||
sendMessage(chatId, "File received. Starting processing...");
|
||||
}
|
||||
|
||||
PipelineFileInfo info = downloadMessageFile(message);
|
||||
List<Path> outputs = waitForPipelineOutputs(info);
|
||||
|
||||
if (outputs.isEmpty()) {
|
||||
if (feedback(FeedbackEnum.ERROR_PROCESSING, chatType)) {
|
||||
sendMessage(
|
||||
chatId,
|
||||
"No results were found in the pipeline output folder. Check"
|
||||
+ " configuration.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (Path file : outputs) {
|
||||
SendDocument out = new SendDocument();
|
||||
out.setChatId(chatId);
|
||||
out.setDocument(new InputFile(file.toFile(), file.getFileName().toString()));
|
||||
execute(out);
|
||||
}
|
||||
|
||||
} catch (TelegramApiException e) {
|
||||
log.error("Telegram API error", e);
|
||||
if (feedback(FeedbackEnum.ERROR_MESSAGE, chatType)) {
|
||||
sendMessage(chatId, "Telegram API error occurred.");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("IO error", e);
|
||||
if (feedback(FeedbackEnum.ERROR_MESSAGE, chatType)) {
|
||||
sendMessage(chatId, "An IO error occurred.");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Unexpected error", e);
|
||||
if (feedback(FeedbackEnum.ERROR_MESSAGE, chatType)) {
|
||||
sendMessage(chatId, "Unexpected error occurred.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private PipelineFileInfo downloadMessageFile(Message message)
|
||||
throws TelegramApiException, IOException {
|
||||
Document document = message.getDocument();
|
||||
String filename = document.getFileName();
|
||||
String name =
|
||||
StringUtils.isNotBlank(filename) ? filename : document.getFileUniqueId() + ".bin";
|
||||
|
||||
return downloadFile(document.getFileId(), name, message);
|
||||
}
|
||||
|
||||
private PipelineFileInfo downloadFile(String fileId, String originalName, Message message)
|
||||
throws TelegramApiException, IOException {
|
||||
|
||||
Long chatId = message.getChatId();
|
||||
|
||||
Path inboxFolder = getInboxFolder(chatId);
|
||||
|
||||
GetFile getFile = new GetFile(fileId);
|
||||
File tgFile = execute(getFile);
|
||||
|
||||
if (tgFile == null || StringUtils.isBlank(tgFile.getFilePath())) {
|
||||
throw new IOException("Telegram did not return a file path.");
|
||||
}
|
||||
|
||||
URL url = buildDownloadUrl(tgFile.getFilePath());
|
||||
|
||||
String base = FilenameUtils.getBaseName(originalName) + "-" + UUID.randomUUID();
|
||||
String ext = FilenameUtils.getExtension(originalName);
|
||||
String outFile = ext.isBlank() ? base : base + "." + ext;
|
||||
|
||||
Path targetFile = inboxFolder.resolve(outFile);
|
||||
|
||||
try (InputStream in = url.openStream()) {
|
||||
Files.copy(in, targetFile);
|
||||
}
|
||||
|
||||
log.info("Saved Telegram file {} to {}", originalName, targetFile);
|
||||
return new PipelineFileInfo(targetFile, base, Instant.now());
|
||||
}
|
||||
|
||||
private URL buildDownloadUrl(String filePath) throws MalformedURLException {
|
||||
try {
|
||||
URI uri =
|
||||
new URI(
|
||||
"https",
|
||||
"api.telegram.org",
|
||||
"/file/bot" + getBotToken() + "/" + filePath,
|
||||
null);
|
||||
return uri.toURL();
|
||||
} catch (URISyntaxException e) {
|
||||
throw new MalformedURLException("Failed to build Telegram download URL");
|
||||
} catch (MalformedURLException e) {
|
||||
MalformedURLException sanitized =
|
||||
new MalformedURLException("Failed to build Telegram download URL");
|
||||
sanitized.initCause(e);
|
||||
throw sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Inbox-Ordner & JSON-Check
|
||||
// ---------------------------
|
||||
|
||||
private Path getInboxFolder(Long chatId) throws IOException {
|
||||
Path baseInbox =
|
||||
Paths.get(
|
||||
runtimePathConfig.getPipelineWatchedFoldersPath(),
|
||||
telegramProperties.getPipelineInboxFolder());
|
||||
|
||||
Files.createDirectories(baseInbox);
|
||||
|
||||
Path inboxFolder =
|
||||
telegramProperties.getCustomFolderSuffix()
|
||||
? baseInbox.resolve(chatId.toString())
|
||||
: baseInbox;
|
||||
|
||||
Files.createDirectories(inboxFolder);
|
||||
|
||||
return inboxFolder;
|
||||
}
|
||||
|
||||
private boolean hasJsonConfig(Long chatId) {
|
||||
try {
|
||||
Path inboxFolder = getInboxFolder(chatId);
|
||||
try (Stream<Path> s = Files.list(inboxFolder)) {
|
||||
return s.anyMatch(p -> p.toString().endsWith(".json"));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to check JSON config for chat {}", chatId, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Pipeline polling
|
||||
// ---------------------------
|
||||
|
||||
private List<Path> waitForPipelineOutputs(PipelineFileInfo info) throws IOException {
|
||||
|
||||
Path finishedDir = Paths.get(runtimePathConfig.getPipelineFinishedFoldersPath());
|
||||
Files.createDirectories(finishedDir);
|
||||
|
||||
Instant start = info.savedAt();
|
||||
Duration timeout = Duration.ofSeconds(telegramProperties.getProcessingTimeoutSeconds());
|
||||
Duration poll = Duration.ofMillis(telegramProperties.getPollingIntervalMillis());
|
||||
List<Path> results = new ArrayList<>();
|
||||
|
||||
while (Duration.between(start, Instant.now()).compareTo(timeout) <= 0) {
|
||||
try (Stream<Path> s = Files.list(finishedDir)) {
|
||||
results =
|
||||
s.filter(Files::isRegularFile)
|
||||
.filter(path -> matchesBaseName(info.uniqueBaseName(), path))
|
||||
.filter(path -> isNewerThan(path, start))
|
||||
.sorted(Comparator.comparing(Path::toString))
|
||||
.toList();
|
||||
}
|
||||
|
||||
if (!results.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
synchronized (pipelinePollMonitor) {
|
||||
try {
|
||||
pipelinePollMonitor.wait(poll.toMillis());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private boolean matchesBaseName(String base, Path file) {
|
||||
return file.getFileName().toString().contains(base);
|
||||
}
|
||||
|
||||
private boolean isNewerThan(Path path, Instant since) {
|
||||
try {
|
||||
return Files.getLastModifiedTime(path).toInstant().isAfter(since);
|
||||
} catch (IOException e) {
|
||||
log.info("Could not read modification time for {}", path);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Messaging
|
||||
// ---------------------------
|
||||
|
||||
private void sendMessage(Long chatId, String text) {
|
||||
if (chatId == null) return;
|
||||
|
||||
SendMessage msg = new SendMessage();
|
||||
msg.setChatId(chatId);
|
||||
msg.setText(text);
|
||||
try {
|
||||
execute(msg);
|
||||
} catch (TelegramApiException e) {
|
||||
log.warn("Failed to send message to {}", chatId, e);
|
||||
}
|
||||
}
|
||||
|
||||
private record PipelineFileInfo(Path originalFile, String uniqueBaseName, Instant savedAt) {}
|
||||
|
||||
@Override
|
||||
public String getBotUsername() {
|
||||
return telegramProperties.getBotUsername();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getBotToken() {
|
||||
return telegramProperties.getBotToken();
|
||||
}
|
||||
}
|
||||
@@ -16,30 +16,30 @@ security:
|
||||
loginResetTimeMinutes: 120 # lock account for 2 hours after x attempts
|
||||
loginMethod: all # Accepts values like 'all' and 'normal'(only Login with Username/Password), 'oauth2'(only Login with OAuth2) or 'saml2'(only Login with SAML2)
|
||||
initialLogin:
|
||||
username: "" # initial username for the first login
|
||||
password: "" # initial password for the first login
|
||||
username: '' # initial username for the first login
|
||||
password: '' # initial password for the first login
|
||||
oauth2:
|
||||
enabled: false # set to 'true' to enable login (Note: enableLogin must also be 'true' for this to work)
|
||||
client:
|
||||
keycloak:
|
||||
issuer: "" # URL of the Keycloak realm's OpenID Connect Discovery endpoint
|
||||
clientId: "" # client ID for Keycloak OAuth2
|
||||
clientSecret: "" # client secret for Keycloak OAuth2
|
||||
issuer: '' # URL of the Keycloak realm's OpenID Connect Discovery endpoint
|
||||
clientId: '' # client ID for Keycloak OAuth2
|
||||
clientSecret: '' # client secret for Keycloak OAuth2
|
||||
scopes: openid, profile, email # scopes for Keycloak OAuth2
|
||||
useAsUsername: preferred_username # field to use as the username for Keycloak OAuth2. Available options are: [email | name | given_name | family_name | preferred_name]
|
||||
google:
|
||||
clientId: "" # client ID for Google OAuth2
|
||||
clientSecret: "" # client secret for Google OAuth2
|
||||
clientId: '' # client ID for Google OAuth2
|
||||
clientSecret: '' # client secret for Google OAuth2
|
||||
scopes: email, profile # scopes for Google OAuth2
|
||||
useAsUsername: email # field to use as the username for Google OAuth2. Available options are: [email | name | given_name | family_name]
|
||||
github:
|
||||
clientId: "" # client ID for GitHub OAuth2
|
||||
clientSecret: "" # client secret for GitHub OAuth2
|
||||
clientId: '' # client ID for GitHub OAuth2
|
||||
clientSecret: '' # client secret for GitHub OAuth2
|
||||
scopes: read:user # scope for GitHub OAuth2
|
||||
useAsUsername: login # field to use as the username for GitHub OAuth2. Available options are: [email | login | name]
|
||||
issuer: "" # set to any Provider that supports OpenID Connect Discovery (/.well-known/openid-configuration) endpoint
|
||||
clientId: "" # client ID from your Provider
|
||||
clientSecret: "" # client secret from your Provider
|
||||
issuer: '' # set to any Provider that supports OpenID Connect Discovery (/.well-known/openid-configuration) endpoint
|
||||
clientId: '' # client ID from your Provider
|
||||
clientSecret: '' # client secret from your Provider
|
||||
autoCreateUser: true # set to 'true' to allow auto-creation of non-existing users
|
||||
blockRegistration: false # set to 'true' to deny login with SSO without prior registration by an admin
|
||||
useAsUsername: email # default is 'email'; custom fields can be used as the username
|
||||
@@ -47,14 +47,14 @@ security:
|
||||
provider: google # set this to your OAuth Provider's name, e.g., 'google' or 'keycloak'
|
||||
saml2:
|
||||
enabled: false # Only enabled for paid enterprise clients (enterpriseEdition.enabled must be true)
|
||||
provider: "" # The name of your Provider
|
||||
provider: '' # The name of your Provider
|
||||
autoCreateUser: true # set to 'true' to allow auto-creation of non-existing users
|
||||
blockRegistration: false # set to 'true' to deny login with SSO without prior registration by an admin
|
||||
registrationId: stirling # The name of your Service Provider (SP) app name. Should match the name in the path for your SSO & SLO URLs
|
||||
idpMetadataUri: https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata # The uri for your Provider's metadata
|
||||
idpSingleLoginUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml # The URL for initiating SSO. Provided by your Provider
|
||||
idpSingleLogoutUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml # The URL for initiating SLO. Provided by your Provider
|
||||
idpIssuer: "" # The ID of your Provider
|
||||
idpIssuer: '' # The ID of your Provider
|
||||
idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider
|
||||
privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair
|
||||
spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair
|
||||
@@ -110,45 +110,21 @@ mail:
|
||||
enableInvites: false # set to 'true' to enable email invites for user management (requires mail.enabled and security.enableLogin)
|
||||
host: smtp.example.com # SMTP server hostname
|
||||
port: 587 # SMTP server port
|
||||
username: "" # SMTP server username
|
||||
password: "" # SMTP server password
|
||||
from: "" # sender email address
|
||||
username: '' # SMTP server username
|
||||
password: '' # SMTP server password
|
||||
from: '' # sender email address
|
||||
startTlsEnable: true # enable STARTTLS (explicit TLS upgrade after connecting) when supported by the SMTP server
|
||||
startTlsRequired: false # require STARTTLS; connection fails if the upgrade command is not supported
|
||||
sslEnable: false # enable SSL/TLS wrapper for implicit TLS (typically used with port 465)
|
||||
sslTrust: "" # optional trusted host override, e.g. "smtp.example.com" or "*"; defaults to "*" (trust all) when empty
|
||||
sslTrust: '' # optional trusted host override, e.g. "smtp.example.com" or "*"; defaults to "*" (trust all) when empty
|
||||
sslCheckServerIdentity: false # enable hostname verification when using SSL/TLS
|
||||
|
||||
telegram:
|
||||
enabled: false # set to 'true' to enable Telegram bot integration
|
||||
botToken: "" # Telegram bot token obtained from BotFather
|
||||
botUsername: "" # Telegram bot username (without @)
|
||||
pipelineInboxFolder: telegram # Name of the pipeline inbox folder for Telegram uploads
|
||||
customFolderSuffix: true # set to 'true' to allow users to specify custom target folders via UserID
|
||||
enableAllowUserIDs: true # set to 'true' to restrict access to specific Telegram user IDs
|
||||
allowUserIDs: [] # List of allowed Telegram user IDs (e.g. [123456789, 987654321]). Leave empty to allow all users.
|
||||
enableAllowChannelIDs: true # set to 'true' to restrict access to specific Telegram channel IDs
|
||||
allowChannelIDs: [] # List of allowed Telegram channel IDs (e.g. [-1001234567890, -1009876543210]). Leave empty to allow all channels.
|
||||
processingTimeoutSeconds: 180 # Maximum time in seconds to wait for processing a Telegram request
|
||||
pollingIntervalMillis: 2000 # Interval in milliseconds between polling for new messages
|
||||
feedback:
|
||||
channel:
|
||||
noValidDocument: true # set to 'false' to hide/suppress feedback messages in channels (to avoid spam)
|
||||
errorProcessing: true # set to 'false' to hide/suppress feedback messages in channels (to avoid spam)
|
||||
errorMessage: true # set to 'false' to hide/suppress error messages in channels (to avoid spam)
|
||||
processing: true # set to 'false' to hide/suppress processing messages in channels (to avoid spam)
|
||||
user:
|
||||
noValidDocument: true # set to 'false' to hide/suppress feedback messages to users (to avoid spam)
|
||||
errorProcessing: true # set to 'false' to hide/suppress feedback messages to users (to avoid spam)
|
||||
errorMessage: true # set to 'false' to hide/suppress error messages to users (to avoid spam)
|
||||
processing: true # set to 'false' to hide/suppress processing messages to users (to avoid spam)
|
||||
|
||||
legal:
|
||||
termsAndConditions: https://www.stirling.com/legal/terms-of-service # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder
|
||||
privacyPolicy: https://www.stirling.com/legal/privacy-policy # URL to the privacy policy of your application (e.g. https://example.com/privacy). Empty string to disable or filename to load from local file in static folder
|
||||
accessibilityStatement: "" # URL to the accessibility statement of your application (e.g. https://example.com/accessibility). Empty string to disable or filename to load from local file in static folder
|
||||
cookiePolicy: "" # URL to the cookie policy of your application (e.g. https://example.com/cookie). Empty string to disable or filename to load from local file in static folder
|
||||
impressum: "" # URL to the impressum of your application (e.g. https://example.com/impressum). Empty string to disable or filename to load from local file in static folder
|
||||
accessibilityStatement: '' # URL to the accessibility statement of your application (e.g. https://example.com/accessibility). Empty string to disable or filename to load from local file in static folder
|
||||
cookiePolicy: '' # URL to the cookie policy of your application (e.g. https://example.com/cookie). Empty string to disable or filename to load from local file in static folder
|
||||
impressum: '' # URL to the impressum of your application (e.g. https://example.com/impressum). Empty string to disable or filename to load from local file in static folder
|
||||
|
||||
system:
|
||||
defaultLocale: en-US # set the default language (e.g. 'de-DE', 'fr-FR', etc)
|
||||
@@ -167,14 +143,9 @@ system:
|
||||
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
|
||||
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
|
||||
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.
|
||||
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.
|
||||
mobileScannerSettings:
|
||||
convertToPdf: true # Automatically convert uploaded images to PDF format. If false, images are kept as-is.
|
||||
imageResolution: full # Image resolution for mobile uploads: 'full' (original size) or 'reduced' (max 1200px on longest side). Only applies when convertToPdf is true.
|
||||
pageFormat: A4 # Page format for converted PDFs: 'keep' (original image dimensions), 'A4' (A4 page size), or 'letter' (US Letter page size). Only applies when convertToPdf is true.
|
||||
stretchToFit: false # Whether to stretch images to fill the entire page (may distort aspect ratio). If false, images are centered with preserved aspect ratio. Only applies when convertToPdf is true.
|
||||
serverCertificate:
|
||||
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
|
||||
organizationName: Stirling-PDF # Organization name for generated certificates
|
||||
@@ -186,14 +157,14 @@ system:
|
||||
level: MEDIUM # Security level: MAX (whitelist only), MEDIUM (block internal networks), OFF (no restrictions)
|
||||
allowedDomains: [] # Whitelist of allowed domains (e.g. ['cdn.example.com', 'images.google.com'])
|
||||
blockedDomains: [] # Additional domains to block (e.g. ['evil.com', 'malicious.org'])
|
||||
internalTlds: [".local", ".internal", ".corp", ".home"] # Block domains with these TLD patterns
|
||||
internalTlds: ['.local', '.internal', '.corp', '.home'] # Block domains with these TLD patterns
|
||||
blockPrivateNetworks: true # Block RFC 1918 private networks (10.x.x.x, 192.168.x.x, 172.16-31.x.x)
|
||||
blockLocalhost: true # Block localhost and loopback addresses (127.x.x.x, ::1)
|
||||
blockLinkLocal: true # Block link-local addresses (169.254.x.x, fe80::/10)
|
||||
blockCloudMetadata: true # Block cloud provider metadata endpoints (169.254.169.254)
|
||||
datasource:
|
||||
enableCustomDatabase: false # Enterprise users ONLY, set this property to 'true' if you would like to use your own custom database configuration
|
||||
customDatabaseUrl: "" # eg jdbc:postgresql://localhost:5432/postgres, set the url for your own custom database connection. If provided, the type, hostName, port and name are not necessary and will not be used
|
||||
customDatabaseUrl: '' # eg jdbc:postgresql://localhost:5432/postgres, set the url for your own custom database connection. If provided, the type, hostName, port and name are not necessary and will not be used
|
||||
username: postgres # set the database username
|
||||
password: postgres # set the database password
|
||||
type: postgresql # the type of the database to set (e.g. 'h2', 'postgresql')
|
||||
@@ -202,29 +173,29 @@ system:
|
||||
name: postgres # set the name of your database. Should match the name of the database you create
|
||||
customPaths:
|
||||
pipeline:
|
||||
watchedFoldersDir: "" # Defaults to /pipeline/watchedFolders
|
||||
finishedFoldersDir: "" # Defaults to /pipeline/finishedFolders
|
||||
watchedFoldersDir: '' # Defaults to /pipeline/watchedFolders
|
||||
finishedFoldersDir: '' # Defaults to /pipeline/finishedFolders
|
||||
operations:
|
||||
weasyprint: "" # Defaults to /opt/venv/bin/weasyprint
|
||||
unoconvert: "" # Defaults to /opt/venv/bin/unoconvert
|
||||
calibre: "" # Defaults to /usr/bin/ebook-convert
|
||||
ocrmypdf: "" # Defaults to /usr/bin/ocrmypdf
|
||||
soffice: "" # Defaults to /usr/bin/soffice
|
||||
fileUploadLimit: "" # Defaults to "". No limit when string is empty. Set a number, between 0 and 999, followed by one of the following strings to set a limit. "KB", "MB", "GB".
|
||||
weasyprint: '' # Defaults to /opt/venv/bin/weasyprint
|
||||
unoconvert: '' # Defaults to /opt/venv/bin/unoconvert
|
||||
calibre: '' # Defaults to /usr/bin/ebook-convert
|
||||
ocrmypdf: '' # Defaults to /usr/bin/ocrmypdf
|
||||
soffice: '' # Defaults to /usr/bin/soffice
|
||||
fileUploadLimit: '' # Defaults to "". No limit when string is empty. Set a number, between 0 and 999, followed by one of the following strings to set a limit. "KB", "MB", "GB".
|
||||
tempFileManagement:
|
||||
baseTmpDir: "" # Defaults to java.io.tmpdir/stirling-pdf
|
||||
libreofficeDir: "" # Defaults to tempFileManagement.baseTmpDir/libreoffice
|
||||
systemTempDir: "" # Only used if cleanupSystemTemp is true
|
||||
baseTmpDir: '' # Defaults to java.io.tmpdir/stirling-pdf
|
||||
libreofficeDir: '' # Defaults to tempFileManagement.baseTmpDir/libreoffice
|
||||
systemTempDir: '' # Only used if cleanupSystemTemp is true
|
||||
prefix: stirling-pdf- # Prefix for temp file names
|
||||
maxAgeHours: 24 # Maximum age in hours before temp files are cleaned up
|
||||
cleanupIntervalMinutes: 30 # How often to run cleanup (in minutes)
|
||||
startupCleanup: true # Clean up old temp files on startup
|
||||
cleanupSystemTemp: false # Whether to clean broader system temp directory
|
||||
databaseBackup:
|
||||
cron: "0 0 0 * * ?" # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight
|
||||
cron: '0 0 0 * * ?' # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight
|
||||
|
||||
ui:
|
||||
appNameNavbar: "" # name displayed on the navigation bar
|
||||
appNameNavbar: '' # name displayed on the navigation bar
|
||||
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
|
||||
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
|
||||
|
||||
|
||||
@@ -7,13 +7,6 @@
|
||||
"moduleLicense": "GNU Lesser General Public License",
|
||||
"moduleLicenseUrl": "http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "ch.qos.logback:logback-classic",
|
||||
"moduleUrl": "http://www.qos.ch",
|
||||
"moduleVersion": "1.5.23",
|
||||
"moduleLicense": "GNU Lesser General Public License",
|
||||
"moduleLicenseUrl": "http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "ch.qos.logback:logback-core",
|
||||
"moduleUrl": "http://www.qos.ch",
|
||||
@@ -21,13 +14,6 @@
|
||||
"moduleLicense": "GNU Lesser General Public License",
|
||||
"moduleLicenseUrl": "http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "ch.qos.logback:logback-core",
|
||||
"moduleUrl": "http://www.qos.ch",
|
||||
"moduleVersion": "1.5.23",
|
||||
"moduleLicense": "GNU Lesser General Public License",
|
||||
"moduleLicenseUrl": "http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.adobe.xmp:xmpcore",
|
||||
"moduleUrl": "https://www.adobe.com/devnet/xmp/library/eula-xmp-library-java.html",
|
||||
@@ -112,20 +98,6 @@
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.fasterxml.jackson.jaxrs:jackson-jaxrs-base",
|
||||
"moduleUrl": "https://github.com/FasterXML/jackson-jaxrs-providers/jackson-jaxrs-base",
|
||||
"moduleVersion": "2.19.2",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.fasterxml.jackson.jaxrs:jackson-jaxrs-json-provider",
|
||||
"moduleUrl": "https://github.com/FasterXML/jackson-jaxrs-providers/jackson-jaxrs-json-provider",
|
||||
"moduleVersion": "2.19.2",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.fasterxml.jackson.module:jackson-module-jakarta-xmlbind-annotations",
|
||||
"moduleUrl": "https://github.com/FasterXML/jackson-modules-base",
|
||||
@@ -133,13 +105,6 @@
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.fasterxml.jackson.module:jackson-module-jaxb-annotations",
|
||||
"moduleUrl": "https://github.com/FasterXML/jackson-modules-base",
|
||||
"moduleVersion": "2.19.2",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.fasterxml.jackson.module:jackson-module-parameter-names",
|
||||
"moduleUrl": "https://github.com/FasterXML/jackson-modules-java8/jackson-module-parameter-names",
|
||||
@@ -167,20 +132,6 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.github.bbottema:jetbrains-runtime-annotations",
|
||||
"moduleUrl": "https://github.com/bbottema/jetbrains-runtime-nullability-annotations",
|
||||
"moduleVersion": "1.0.2",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.github.bbottema:rtf-to-html",
|
||||
"moduleUrl": "http:///github.com/bbottema/rtf-to-html",
|
||||
"moduleVersion": "1.1.1",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.github.ben-manes.caffeine:caffeine",
|
||||
"moduleUrl": "https://github.com/ben-manes/caffeine",
|
||||
@@ -221,6 +172,13 @@
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.google.code.gson:gson",
|
||||
"moduleUrl": "https://github.com/google/gson",
|
||||
"moduleVersion": "2.13.2",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.google.errorprone:error_prone_annotations",
|
||||
"moduleUrl": "https://errorprone.info/error_prone_annotations",
|
||||
@@ -228,6 +186,13 @@
|
||||
"moduleLicense": "Apache 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.google.errorprone:error_prone_annotations",
|
||||
"moduleUrl": "https://errorprone.info/error_prone_annotations",
|
||||
"moduleVersion": "2.41.0",
|
||||
"moduleLicense": "Apache 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.google.guava:failureaccess",
|
||||
"moduleUrl": "https://github.com/google/guava/",
|
||||
@@ -258,7 +223,7 @@
|
||||
{
|
||||
"moduleName": "com.google.zxing:core",
|
||||
"moduleUrl": "https://github.com/zxing/zxing/core",
|
||||
"moduleVersion": "3.5.4",
|
||||
"moduleVersion": "3.5.3",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
@@ -330,13 +295,6 @@
|
||||
"moduleLicense": "Apache 2",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.pivovarit:throwing-function",
|
||||
"moduleUrl": "https://github.com/pivovarit/throwing-function",
|
||||
"moduleVersion": "1.6.1",
|
||||
"moduleLicense": "The Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.posthog.java:posthog",
|
||||
"moduleUrl": "http://github.com/PostHog/posthog-java",
|
||||
@@ -344,20 +302,6 @@
|
||||
"moduleLicense": "MIT License",
|
||||
"moduleLicenseUrl": "http://www.opensource.org/licenses/mit-license.php"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.sanctionco.jmail:jmail",
|
||||
"moduleUrl": "https://github.com/RohanNagar/jmail",
|
||||
"moduleVersion": "1.6.3",
|
||||
"moduleLicense": "MIT License",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/mit-license.php"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.sun.activation:jakarta.activation",
|
||||
"moduleUrl": "https://www.eclipse.org",
|
||||
"moduleVersion": "1.2.2",
|
||||
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.sun.istack:istack-commons-runtime",
|
||||
"moduleUrl": "https://www.eclipse.org",
|
||||
@@ -365,97 +309,69 @@
|
||||
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.sun.xml.bind:jaxb-core",
|
||||
"moduleUrl": "http://www.oracle.com/",
|
||||
"moduleVersion": "2.3.0.1",
|
||||
"moduleLicense": "CDDL+GPL License",
|
||||
"moduleLicenseUrl": "http://glassfish.java.net/public/CDDL+GPL_1_1.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.sun.xml.bind:jaxb-core",
|
||||
"moduleUrl": "https://www.eclipse.org",
|
||||
"moduleVersion": "4.0.6",
|
||||
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.sun.xml.bind:jaxb-impl",
|
||||
"moduleUrl": "https://www.eclipse.org",
|
||||
"moduleVersion": "2.3.9",
|
||||
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.sun.xml.bind:jaxb-impl",
|
||||
"moduleUrl": "https://www.eclipse.org",
|
||||
"moduleVersion": "4.0.6",
|
||||
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.twelvemonkeys.common:common-image",
|
||||
"moduleVersion": "3.13.0",
|
||||
"moduleVersion": "3.12.0",
|
||||
"moduleLicense": "The BSD License",
|
||||
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.twelvemonkeys.common:common-io",
|
||||
"moduleVersion": "3.13.0",
|
||||
"moduleVersion": "3.12.0",
|
||||
"moduleLicense": "The BSD License",
|
||||
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.twelvemonkeys.common:common-lang",
|
||||
"moduleVersion": "3.13.0",
|
||||
"moduleVersion": "3.12.0",
|
||||
"moduleLicense": "The BSD License",
|
||||
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.twelvemonkeys.imageio:imageio-batik",
|
||||
"moduleVersion": "3.13.0",
|
||||
"moduleVersion": "3.12.0",
|
||||
"moduleLicense": "The BSD License",
|
||||
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.twelvemonkeys.imageio:imageio-bmp",
|
||||
"moduleVersion": "3.13.0",
|
||||
"moduleVersion": "3.12.0",
|
||||
"moduleLicense": "The BSD License",
|
||||
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.twelvemonkeys.imageio:imageio-core",
|
||||
"moduleVersion": "3.13.0",
|
||||
"moduleVersion": "3.12.0",
|
||||
"moduleLicense": "The BSD License",
|
||||
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.twelvemonkeys.imageio:imageio-jpeg",
|
||||
"moduleVersion": "3.13.0",
|
||||
"moduleVersion": "3.12.0",
|
||||
"moduleLicense": "The BSD License",
|
||||
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.twelvemonkeys.imageio:imageio-metadata",
|
||||
"moduleVersion": "3.13.0",
|
||||
"moduleVersion": "3.12.0",
|
||||
"moduleLicense": "The BSD License",
|
||||
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.twelvemonkeys.imageio:imageio-psd",
|
||||
"moduleVersion": "3.13.0",
|
||||
"moduleVersion": "3.12.0",
|
||||
"moduleLicense": "The BSD License",
|
||||
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.twelvemonkeys.imageio:imageio-tiff",
|
||||
"moduleVersion": "3.13.0",
|
||||
"moduleVersion": "3.12.0",
|
||||
"moduleLicense": "The BSD License",
|
||||
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.twelvemonkeys.imageio:imageio-webp",
|
||||
"moduleVersion": "3.13.0",
|
||||
"moduleVersion": "3.12.0",
|
||||
"moduleLicense": "The BSD License",
|
||||
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
|
||||
},
|
||||
@@ -606,13 +522,6 @@
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.zaxxer:SparseBitSet",
|
||||
"moduleUrl": "https://github.com/brettwooldridge/SparseBitSet",
|
||||
"moduleVersion": "1.3",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "commons-beanutils:commons-beanutils",
|
||||
"moduleUrl": "https://commons.apache.org/proper/commons-beanutils",
|
||||
@@ -651,7 +560,7 @@
|
||||
{
|
||||
"moduleName": "commons-io:commons-io",
|
||||
"moduleUrl": "https://commons.apache.org/proper/commons-io/",
|
||||
"moduleVersion": "2.21.0",
|
||||
"moduleVersion": "2.20.0",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
@@ -703,13 +612,6 @@
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.micrometer:micrometer-core",
|
||||
"moduleUrl": "https://github.com/micrometer-metrics/micrometer",
|
||||
"moduleVersion": "1.16.1",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.micrometer:micrometer-jakarta9",
|
||||
"moduleUrl": "https://github.com/micrometer-metrics/micrometer",
|
||||
@@ -776,42 +678,42 @@
|
||||
{
|
||||
"moduleName": "io.swagger.core.v3:swagger-annotations-jakarta",
|
||||
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations",
|
||||
"moduleVersion": "2.2.38",
|
||||
"moduleVersion": "2.2.36",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.swagger.core.v3:swagger-annotations-jakarta",
|
||||
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations",
|
||||
"moduleVersion": "2.2.41",
|
||||
"moduleVersion": "2.2.40",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.swagger.core.v3:swagger-core-jakarta",
|
||||
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core",
|
||||
"moduleVersion": "2.2.38",
|
||||
"moduleVersion": "2.2.36",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.swagger.core.v3:swagger-core-jakarta",
|
||||
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core",
|
||||
"moduleVersion": "2.2.41",
|
||||
"moduleVersion": "2.2.40",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.swagger.core.v3:swagger-models-jakarta",
|
||||
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models",
|
||||
"moduleVersion": "2.2.38",
|
||||
"moduleVersion": "2.2.36",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.swagger.core.v3:swagger-models-jakarta",
|
||||
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models",
|
||||
"moduleVersion": "2.2.41",
|
||||
"moduleVersion": "2.2.40",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
@@ -913,13 +815,6 @@
|
||||
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "jakarta.ws.rs:jakarta.ws.rs-api",
|
||||
"moduleUrl": "https://www.eclipse.org/org/foundation/",
|
||||
"moduleVersion": "3.1.0",
|
||||
"moduleLicense": "GPL-2.0-with-classpath-exception",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "jakarta.xml.bind:jakarta.xml.bind-api",
|
||||
"moduleUrl": "https://www.eclipse.org",
|
||||
@@ -928,25 +823,32 @@
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "javax.activation:javax.activation-api",
|
||||
"moduleUrl": "http://www.oracle.com",
|
||||
"moduleVersion": "1.2.0",
|
||||
"moduleLicense": "COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.0",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/CDDL-1.0"
|
||||
"moduleName": "me.friwi:gluegen-rt",
|
||||
"moduleUrl": "http://jogamp.org/gluegen/www/",
|
||||
"moduleVersion": "v2.4.0-rc-20210111",
|
||||
"moduleLicense": "BSD-4 License",
|
||||
"moduleLicenseUrl": "http://www.spdx.org/licenses/BSD-4-Clause"
|
||||
},
|
||||
{
|
||||
"moduleName": "javax.xml.bind:jaxb-api",
|
||||
"moduleUrl": "http://www.oracle.com/",
|
||||
"moduleVersion": "2.3.1",
|
||||
"moduleLicense": "GPL2 w/ CPE",
|
||||
"moduleLicenseUrl": "https://oss.oracle.com/licenses/CDDL+GPL-1.1"
|
||||
"moduleName": "me.friwi:jcef-api",
|
||||
"moduleUrl": "https://bitbucket.org/chromiumembedded/java-cef/",
|
||||
"moduleVersion": "jcef-1770317+cef-132.3.1+g144febe+chromium-132.0.6834.83",
|
||||
"moduleLicense": "BSD License",
|
||||
"moduleLicenseUrl": "https://bitbucket.org/chromiumembedded/java-cef/src/master/LICENSE.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "javax.xml.bind:jaxb-api",
|
||||
"moduleUrl": "http://www.oracle.com/",
|
||||
"moduleVersion": "2.4.0-b180830.0359",
|
||||
"moduleLicense": "GPL2 w/ CPE",
|
||||
"moduleLicenseUrl": "https://oss.oracle.com/licenses/CDDL+GPL-1.1"
|
||||
"moduleName": "me.friwi:jcefmaven",
|
||||
"moduleUrl": "https://github.com/jcefmaven/jcefmaven/",
|
||||
"moduleVersion": "132.3.1",
|
||||
"moduleLicense": "Apache-2.0 License",
|
||||
"moduleLicenseUrl": "https://github.com/jcefmaven/jcefmaven/blob/master/LICENSE"
|
||||
},
|
||||
{
|
||||
"moduleName": "me.friwi:jogl-all",
|
||||
"moduleUrl": "http://jogamp.org/jogl/www/",
|
||||
"moduleVersion": "v2.4.0-rc-20210111",
|
||||
"moduleLicense": "Ubuntu Font Licence 1.0",
|
||||
"moduleLicenseUrl": "http://font.ubuntu.com/ufl/ubuntu-font-licence-1.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "net.bytebuddy:byte-buddy",
|
||||
@@ -954,13 +856,6 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "net.java.dev.stax-utils:stax-utils",
|
||||
"moduleUrl": "http://java.net/projects/stax-utils/",
|
||||
"moduleVersion": "20070216",
|
||||
"moduleLicense": "BSD",
|
||||
"moduleLicenseUrl": "http://www.opensource.org/licenses/bsd-license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "net.minidev:accessors-smart",
|
||||
"moduleUrl": "https://urielch.github.io/",
|
||||
@@ -991,14 +886,14 @@
|
||||
{
|
||||
"moduleName": "org.apache.commons:commons-collections4",
|
||||
"moduleUrl": "https://commons.apache.org/proper/commons-collections/",
|
||||
"moduleVersion": "4.4",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleVersion": "4.5.0",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.apache.commons:commons-collections4",
|
||||
"moduleUrl": "https://commons.apache.org/proper/commons-collections/",
|
||||
"moduleVersion": "4.5.0",
|
||||
"moduleName": "org.apache.commons:commons-compress",
|
||||
"moduleUrl": "https://commons.apache.org/proper/commons-compress/",
|
||||
"moduleVersion": "1.27.1",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
@@ -1019,17 +914,10 @@
|
||||
{
|
||||
"moduleName": "org.apache.commons:commons-lang3",
|
||||
"moduleUrl": "https://commons.apache.org/proper/commons-lang/",
|
||||
"moduleVersion": "3.20.0",
|
||||
"moduleVersion": "3.19.0",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.apache.commons:commons-math3",
|
||||
"moduleUrl": "http://commons.apache.org/proper/commons-math/",
|
||||
"moduleVersion": "3.6.1",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.apache.commons:commons-text",
|
||||
"moduleUrl": "https://commons.apache.org/proper/commons-text",
|
||||
@@ -1058,13 +946,6 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.apache.httpcomponents:httpmime",
|
||||
"moduleUrl": "http://hc.apache.org/httpcomponents-client-ga",
|
||||
"moduleVersion": "4.5.14",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.apache.logging.log4j:log4j-api",
|
||||
"moduleVersion": "2.24.3",
|
||||
@@ -1118,20 +999,6 @@
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.apache.poi:poi",
|
||||
"moduleUrl": "https://poi.apache.org/",
|
||||
"moduleVersion": "5.2.5",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.apache.poi:poi-scratchpad",
|
||||
"moduleUrl": "https://poi.apache.org/",
|
||||
"moduleVersion": "5.2.5",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.apache.santuario:xmlsec",
|
||||
"moduleUrl": "https://www.apache.org/",
|
||||
@@ -1190,14 +1057,14 @@
|
||||
{
|
||||
"moduleName": "org.bouncycastle:bcpkix-jdk18on",
|
||||
"moduleUrl": "https://www.bouncycastle.org/download/bouncy-castle-java/",
|
||||
"moduleVersion": "1.83",
|
||||
"moduleVersion": "1.82",
|
||||
"moduleLicense": "Bouncy Castle Licence",
|
||||
"moduleLicenseUrl": "https://www.bouncycastle.org/licence.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.bouncycastle:bcprov-jdk18on",
|
||||
"moduleUrl": "https://www.bouncycastle.org/download/bouncy-castle-java/",
|
||||
"moduleVersion": "1.83",
|
||||
"moduleVersion": "1.82",
|
||||
"moduleLicense": "Bouncy Castle Licence",
|
||||
"moduleLicenseUrl": "https://www.bouncycastle.org/licence.html"
|
||||
},
|
||||
@@ -1211,7 +1078,7 @@
|
||||
{
|
||||
"moduleName": "org.bouncycastle:bcutil-jdk18on",
|
||||
"moduleUrl": "https://www.bouncycastle.org/download/bouncy-castle-java/",
|
||||
"moduleVersion": "1.83",
|
||||
"moduleVersion": "1.82",
|
||||
"moduleLicense": "Bouncy Castle Licence",
|
||||
"moduleLicenseUrl": "https://www.bouncycastle.org/licence.html"
|
||||
},
|
||||
@@ -1444,62 +1311,6 @@
|
||||
"moduleLicense": "Eclipse Public License - Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.glassfish.grizzly:grizzly-framework",
|
||||
"moduleUrl": "http://www.oracle.com",
|
||||
"moduleVersion": "4.0.2",
|
||||
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.glassfish.grizzly:grizzly-http",
|
||||
"moduleUrl": "http://www.oracle.com",
|
||||
"moduleVersion": "4.0.2",
|
||||
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.glassfish.grizzly:grizzly-http-server",
|
||||
"moduleUrl": "http://www.oracle.com",
|
||||
"moduleVersion": "4.0.2",
|
||||
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.glassfish.hk2.external:aopalliance-repackaged",
|
||||
"moduleUrl": "http://www.oracle.com",
|
||||
"moduleVersion": "3.0.6",
|
||||
"moduleLicense": "GPL2 w/ CPE",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.glassfish.hk2:hk2-api",
|
||||
"moduleUrl": "http://www.oracle.com",
|
||||
"moduleVersion": "3.0.6",
|
||||
"moduleLicense": "GPL2 w/ CPE",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.glassfish.hk2:hk2-locator",
|
||||
"moduleUrl": "http://www.oracle.com",
|
||||
"moduleVersion": "3.0.6",
|
||||
"moduleLicense": "GPL2 w/ CPE",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.glassfish.hk2:hk2-utils",
|
||||
"moduleUrl": "http://www.oracle.com",
|
||||
"moduleVersion": "3.0.6",
|
||||
"moduleLicense": "GPL2 w/ CPE",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.glassfish.hk2:osgi-resource-locator",
|
||||
"moduleUrl": "https://www.eclipse.org",
|
||||
"moduleVersion": "1.0.3",
|
||||
"moduleLicense": "GPL2 w/ CPE",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.glassfish.jaxb:jaxb-core",
|
||||
"moduleUrl": "https://www.eclipse.org",
|
||||
@@ -1521,55 +1332,6 @@
|
||||
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.glassfish.jersey.containers:jersey-container-grizzly2-http",
|
||||
"moduleUrl": "https://www.eclipse.org/org/foundation/",
|
||||
"moduleVersion": "3.1.11",
|
||||
"moduleLicense": "jQuery license",
|
||||
"moduleLicenseUrl": "jquery.org/license"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.glassfish.jersey.core:jersey-client",
|
||||
"moduleUrl": "https://www.eclipse.org/org/foundation/",
|
||||
"moduleVersion": "3.1.11",
|
||||
"moduleLicense": "jQuery license",
|
||||
"moduleLicenseUrl": "jquery.org/license"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.glassfish.jersey.core:jersey-common",
|
||||
"moduleUrl": "https://www.eclipse.org/org/foundation/",
|
||||
"moduleVersion": "3.1.11",
|
||||
"moduleLicense": "jQuery license",
|
||||
"moduleLicenseUrl": "jquery.org/license"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.glassfish.jersey.core:jersey-server",
|
||||
"moduleUrl": "https://www.eclipse.org/org/foundation/",
|
||||
"moduleVersion": "3.1.11",
|
||||
"moduleLicense": "jQuery license",
|
||||
"moduleLicenseUrl": "jquery.org/license"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.glassfish.jersey.ext:jersey-entity-filtering",
|
||||
"moduleUrl": "https://www.eclipse.org/org/foundation/",
|
||||
"moduleVersion": "3.1.11",
|
||||
"moduleLicense": "jQuery license",
|
||||
"moduleLicenseUrl": "jquery.org/license"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.glassfish.jersey.inject:jersey-hk2",
|
||||
"moduleUrl": "https://www.eclipse.org/org/foundation/",
|
||||
"moduleVersion": "3.1.11",
|
||||
"moduleLicense": "jQuery license",
|
||||
"moduleLicenseUrl": "jquery.org/license"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.glassfish.jersey.media:jersey-media-json-jackson",
|
||||
"moduleUrl": "https://www.eclipse.org/org/foundation/",
|
||||
"moduleVersion": "3.1.11",
|
||||
"moduleLicense": "jQuery license",
|
||||
"moduleLicenseUrl": "jquery.org/license"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.hdrhistogram:HdrHistogram",
|
||||
"moduleUrl": "http://hdrhistogram.github.io/HdrHistogram/",
|
||||
@@ -1598,13 +1360,6 @@
|
||||
"moduleLicense": "Apache License 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.javassist:javassist",
|
||||
"moduleUrl": "https://www.javassist.org/",
|
||||
"moduleVersion": "3.30.2-GA",
|
||||
"moduleLicense": "MPL 1.1",
|
||||
"moduleLicenseUrl": "https://www.mozilla.org/en-US/MPL/1.1/"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.jboss.logging:jboss-logging",
|
||||
"moduleUrl": "http://www.jboss.org",
|
||||
@@ -1646,13 +1401,6 @@
|
||||
"moduleLicense": "Eclipse Public License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://github.com/locationtech/jts/blob/master/LICENSE_EPLv2.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.mozilla:rhino",
|
||||
"moduleUrl": "https://developer.mozilla.org/en/Rhino",
|
||||
"moduleVersion": "1.7.13",
|
||||
"moduleLicense": "Mozilla Public License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.mozilla.org/MPL/2.0/index.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.openjfx:javafx-base",
|
||||
"moduleVersion": "21",
|
||||
@@ -1777,31 +1525,6 @@
|
||||
"moduleLicense": "BSD-2-Clause",
|
||||
"moduleLicenseUrl": "https://jdbc.postgresql.org/about/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.simplejavamail:core-module",
|
||||
"moduleVersion": "8.12.6",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.simplejavamail:outlook-message-parser",
|
||||
"moduleUrl": "https://github.com/bbottema/outlook-message-parser",
|
||||
"moduleVersion": "1.14.1",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.simplejavamail:outlook-module",
|
||||
"moduleVersion": "8.12.6",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.simplejavamail:simple-java-mail",
|
||||
"moduleVersion": "8.12.6",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.slf4j:jul-to-slf4j",
|
||||
"moduleUrl": "http://www.slf4j.org",
|
||||
@@ -1825,19 +1548,19 @@
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springdoc:springdoc-openapi-starter-common",
|
||||
"moduleVersion": "2.8.14",
|
||||
"moduleVersion": "2.8.13",
|
||||
"moduleLicense": "The Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springdoc:springdoc-openapi-starter-webmvc-api",
|
||||
"moduleVersion": "2.8.14",
|
||||
"moduleVersion": "2.8.13",
|
||||
"moduleLicense": "The Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springdoc:springdoc-openapi-starter-webmvc-ui",
|
||||
"moduleVersion": "2.8.14",
|
||||
"moduleVersion": "2.8.13",
|
||||
"moduleLicense": "The Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
@@ -1960,6 +1683,13 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter-thymeleaf",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
"moduleVersion": "3.5.7",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.springframework.boot:spring-boot-starter-validation",
|
||||
"moduleUrl": "https://spring.io/projects/spring-boot",
|
||||
@@ -2142,20 +1872,6 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.telegram:telegrambots",
|
||||
"moduleUrl": "https://github.com/rubenlagus/TelegramBots",
|
||||
"moduleVersion": "6.9.7.1",
|
||||
"moduleLicense": "MIT License",
|
||||
"moduleLicenseUrl": "http://www.opensource.org/licenses/mit-license.php"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.telegram:telegrambots-meta",
|
||||
"moduleUrl": "https://github.com/rubenlagus/TelegramBots",
|
||||
"moduleVersion": "6.9.7.1",
|
||||
"moduleLicense": "MIT License",
|
||||
"moduleLicenseUrl": "http://www.opensource.org/licenses/mit-license.php"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.thymeleaf.extras:thymeleaf-extras-springsecurity5",
|
||||
"moduleVersion": "3.1.3.RELEASE",
|
||||
@@ -2174,6 +1890,12 @@
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.thymeleaf:thymeleaf-spring6",
|
||||
"moduleVersion": "3.1.3.RELEASE",
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.unbescape:unbescape",
|
||||
"moduleUrl": "http://www.unbescape.org",
|
||||
@@ -2181,53 +1903,10 @@
|
||||
"moduleLicense": "The Apache Software License, Version 2.0",
|
||||
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.verapdf:core",
|
||||
"moduleVersion": "1.28.2",
|
||||
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MPL-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.verapdf:feature-reporting",
|
||||
"moduleVersion": "1.28.2",
|
||||
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MPL-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.verapdf:metadata-fixer",
|
||||
"moduleVersion": "1.28.2",
|
||||
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MPL-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.verapdf:parser",
|
||||
"moduleVersion": "1.28.2",
|
||||
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MPL-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.verapdf:pdf-model",
|
||||
"moduleUrl": "https://github.com/veraPDF/veraPDF-model/",
|
||||
"moduleVersion": "1.28.2",
|
||||
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MPL-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.verapdf:validation-model",
|
||||
"moduleVersion": "1.28.2",
|
||||
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MPL-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.verapdf:verapdf-xmp-core",
|
||||
"moduleVersion": "1.28.2",
|
||||
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)",
|
||||
"moduleLicenseUrl": "https://opensource.org/licenses/MPL-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.webjars:swagger-ui",
|
||||
"moduleUrl": "https://www.webjars.org",
|
||||
"moduleVersion": "5.30.1",
|
||||
"moduleVersion": "5.28.1",
|
||||
"moduleLicense": "Apache-2.0"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -55,7 +55,7 @@ public class SPDFApplicationTest {
|
||||
|
||||
sPDFApplication.init();
|
||||
|
||||
assertEquals("http://localhost:8080", SPDFApplication.getStaticBaseUrl());
|
||||
assertEquals("http://localhost", SPDFApplication.getStaticBaseUrl());
|
||||
assertEquals("/app", SPDFApplication.getStaticContextPath());
|
||||
assertEquals("8080", SPDFApplication.getStaticPort());
|
||||
}
|
||||
|
||||
+1
-5
@@ -169,10 +169,7 @@ public class ProprietaryUIDataController {
|
||||
|
||||
OAUTH2 oauth = securityProps.getOauth2();
|
||||
|
||||
// Only add OAuth2 providers if loginMethod allows it
|
||||
if (oauth != null
|
||||
&& oauth.getEnabled()
|
||||
&& securityProps.isOauth2Active()) { // This checks loginMethod
|
||||
if (oauth != null && oauth.getEnabled()) {
|
||||
if (oauth.isSettingsValid()) {
|
||||
String firstChar = String.valueOf(oauth.getProvider().charAt(0));
|
||||
String clientName =
|
||||
@@ -204,7 +201,6 @@ public class ProprietaryUIDataController {
|
||||
}
|
||||
|
||||
SAML2 saml2 = securityProps.getSaml2();
|
||||
// Only add SAML2 providers if loginMethod allows it
|
||||
if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) {
|
||||
String samlIdp = saml2.getProvider();
|
||||
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
|
||||
|
||||
-14
@@ -18,7 +18,6 @@ import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider;
|
||||
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
|
||||
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver;
|
||||
@@ -47,7 +46,6 @@ import stirling.software.proprietary.security.filter.JwtAuthenticationFilter;
|
||||
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
|
||||
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationFailureHandler;
|
||||
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationSuccessHandler;
|
||||
import stirling.software.proprietary.security.oauth2.TauriAuthorizationRequestResolver;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationSuccessHandler;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2ResponseAuthenticationConverter;
|
||||
@@ -84,7 +82,6 @@ public class SecurityConfiguration {
|
||||
private final OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver;
|
||||
private final stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService;
|
||||
private final ClientRegistrationRepository clientRegistrationRepository;
|
||||
|
||||
public SecurityConfiguration(
|
||||
PersistentLoginRepository persistentLoginRepository,
|
||||
@@ -105,7 +102,6 @@ public class SecurityConfiguration {
|
||||
RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations,
|
||||
@Autowired(required = false)
|
||||
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver,
|
||||
@Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository,
|
||||
stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService) {
|
||||
this.userDetailsService = userDetailsService;
|
||||
@@ -124,7 +120,6 @@ public class SecurityConfiguration {
|
||||
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
|
||||
this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations;
|
||||
this.saml2AuthenticationRequestResolver = saml2AuthenticationRequestResolver;
|
||||
this.clientRegistrationRepository = clientRegistrationRepository;
|
||||
this.licenseSettingsService = licenseSettingsService;
|
||||
}
|
||||
|
||||
@@ -295,15 +290,6 @@ public class SecurityConfiguration {
|
||||
http.oauth2Login(
|
||||
oauth2 -> {
|
||||
oauth2.loginPage("/login")
|
||||
.authorizationEndpoint(
|
||||
authorizationEndpoint -> {
|
||||
if (clientRegistrationRepository != null) {
|
||||
authorizationEndpoint
|
||||
.authorizationRequestResolver(
|
||||
new TauriAuthorizationRequestResolver(
|
||||
clientRegistrationRepository));
|
||||
}
|
||||
})
|
||||
.successHandler(
|
||||
new CustomOAuth2AuthenticationSuccessHandler(
|
||||
loginAttemptService,
|
||||
|
||||
+30
-62
@@ -163,13 +163,12 @@ public class AdminSettingsController {
|
||||
responseCode = "500",
|
||||
description = "Failed to save settings to configuration file")
|
||||
})
|
||||
public ResponseEntity<Map<String, Object>> updateSettings(
|
||||
public ResponseEntity<String> updateSettings(
|
||||
@Valid @RequestBody UpdateSettingsRequest request) {
|
||||
try {
|
||||
Map<String, Object> settings = request.getSettings();
|
||||
if (settings == null || settings.isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "No settings provided to update"));
|
||||
return ResponseEntity.badRequest().body("No settings provided to update");
|
||||
}
|
||||
|
||||
int updatedCount = 0;
|
||||
@@ -179,11 +178,7 @@ public class AdminSettingsController {
|
||||
|
||||
if (!isValidSettingKey(key)) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Invalid setting key format: "
|
||||
+ HtmlUtils.htmlEscape(key)));
|
||||
.body("Invalid setting key format: " + HtmlUtils.htmlEscape(key));
|
||||
}
|
||||
|
||||
log.info("Admin updating setting: {} = {}", key, value);
|
||||
@@ -196,26 +191,22 @@ public class AdminSettingsController {
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"message",
|
||||
String.format(
|
||||
"Successfully updated %d setting(s). Changes will take effect on"
|
||||
+ " application restart.",
|
||||
updatedCount)));
|
||||
String.format(
|
||||
"Successfully updated %d setting(s). Changes will take effect on"
|
||||
+ " application restart.",
|
||||
updatedCount));
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to save settings to file: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", GENERIC_FILE_ERROR));
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(GENERIC_FILE_ERROR);
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.error("Invalid setting key or value: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", GENERIC_INVALID_SETTING));
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(GENERIC_INVALID_SETTING);
|
||||
} catch (Exception e) {
|
||||
log.error("Unexpected error while updating settings: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", GENERIC_SERVER_ERROR));
|
||||
.body(GENERIC_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,23 +283,20 @@ public class AdminSettingsController {
|
||||
description = "Access denied - Admin role required"),
|
||||
@ApiResponse(responseCode = "500", description = "Failed to save settings")
|
||||
})
|
||||
public ResponseEntity<Map<String, Object>> updateSettingsSection(
|
||||
public ResponseEntity<String> updateSettingsSection(
|
||||
@PathVariable String sectionName, @Valid @RequestBody Map<String, Object> sectionData) {
|
||||
try {
|
||||
if (sectionData == null || sectionData.isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "No section data provided to update"));
|
||||
return ResponseEntity.badRequest().body("No section data provided to update");
|
||||
}
|
||||
|
||||
if (!isValidSectionName(sectionName)) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Invalid section name: "
|
||||
+ HtmlUtils.htmlEscape(sectionName)
|
||||
+ ". Valid sections: "
|
||||
+ String.join(", ", VALID_SECTION_NAMES)));
|
||||
"Invalid section name: "
|
||||
+ HtmlUtils.htmlEscape(sectionName)
|
||||
+ ". Valid sections: "
|
||||
+ String.join(", ", VALID_SECTION_NAMES));
|
||||
}
|
||||
|
||||
// Auto-enable premium features if license key is provided
|
||||
@@ -329,11 +317,7 @@ public class AdminSettingsController {
|
||||
|
||||
if (!isValidSettingKey(fullKey)) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Invalid setting key format: "
|
||||
+ HtmlUtils.htmlEscape(fullKey)));
|
||||
.body("Invalid setting key format: " + HtmlUtils.htmlEscape(fullKey));
|
||||
}
|
||||
|
||||
log.info("Admin updating section setting: {} = {}", fullKey, value);
|
||||
@@ -347,25 +331,21 @@ public class AdminSettingsController {
|
||||
|
||||
String escapedSectionName = HtmlUtils.htmlEscape(sectionName);
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"message",
|
||||
String.format(
|
||||
"Successfully updated %d setting(s) in section '%s'. Changes will take"
|
||||
+ " effect on application restart.",
|
||||
updatedCount, escapedSectionName)));
|
||||
String.format(
|
||||
"Successfully updated %d setting(s) in section '%s'. Changes will take"
|
||||
+ " effect on application restart.",
|
||||
updatedCount, escapedSectionName));
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to save section settings to file: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", GENERIC_FILE_ERROR));
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(GENERIC_FILE_ERROR);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.error("Invalid section data: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", GENERIC_INVALID_SECTION));
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(GENERIC_INVALID_SECTION);
|
||||
} catch (Exception e) {
|
||||
log.error("Unexpected error while updating section settings: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", GENERIC_SERVER_ERROR));
|
||||
.body(GENERIC_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,7 +453,7 @@ public class AdminSettingsController {
|
||||
description = "Access denied - Admin role required"),
|
||||
@ApiResponse(responseCode = "500", description = "Failed to initiate restart")
|
||||
})
|
||||
public ResponseEntity<Map<String, Object>> restartApplication() {
|
||||
public ResponseEntity<String> restartApplication() {
|
||||
try {
|
||||
log.warn("Admin initiated application restart");
|
||||
|
||||
@@ -485,18 +465,13 @@ public class AdminSettingsController {
|
||||
log.error("Cannot restart: not running from JAR (likely development mode)");
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Restart not available in development mode. Please restart the application manually."));
|
||||
"Restart not available in development mode. Please restart the application manually.");
|
||||
}
|
||||
|
||||
if (helperJar == null || !Files.isRegularFile(helperJar)) {
|
||||
log.error("Cannot restart: restart-helper.jar not found at expected location");
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Restart helper not found. Cannot perform application restart."));
|
||||
.body("Restart helper not found. Please restart the application manually.");
|
||||
}
|
||||
|
||||
// Get current application arguments
|
||||
@@ -551,17 +526,12 @@ public class AdminSettingsController {
|
||||
.start();
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"message",
|
||||
"Application restart initiated. The server will be back online shortly."));
|
||||
"Application restart initiated. The server will be back online shortly.");
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to initiate restart: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Failed to initiate application restart: " + e.getMessage()));
|
||||
.body("Failed to initiate application restart: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,7 +551,6 @@ public class AdminSettingsController {
|
||||
case "processexecutor", "processExecutor" -> applicationProperties.getProcessExecutor();
|
||||
case "autopipeline", "autoPipeline" -> applicationProperties.getAutoPipeline();
|
||||
case "legal" -> applicationProperties.getLegal();
|
||||
case "telegram" -> applicationProperties.getTelegram();
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
@@ -603,8 +572,7 @@ public class AdminSettingsController {
|
||||
"processexecutor",
|
||||
"autoPipeline",
|
||||
"autopipeline",
|
||||
"legal",
|
||||
"telegram");
|
||||
"legal");
|
||||
|
||||
// Pattern to validate safe property paths - only alphanumeric, dots, and underscores
|
||||
private static final Pattern SAFE_KEY_PATTERN =
|
||||
|
||||
-13
@@ -21,7 +21,6 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
@@ -45,7 +44,6 @@ public class AuthController {
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
|
||||
/**
|
||||
* Login endpoint - replaces Supabase signInWithPassword
|
||||
@@ -62,17 +60,6 @@ public class AuthController {
|
||||
HttpServletRequest httpRequest,
|
||||
HttpServletResponse response) {
|
||||
try {
|
||||
// Check if username/password authentication is allowed
|
||||
if (!securityProperties.isUserPass()) {
|
||||
log.warn(
|
||||
"Username/password login attempted but not allowed by current login method configuration");
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Username/password authentication is not enabled. Please use the configured authentication method."));
|
||||
}
|
||||
|
||||
// Validate input parameters
|
||||
if (request.getUsername() == null || request.getUsername().trim().isEmpty()) {
|
||||
log.warn("Login attempt with null or empty username");
|
||||
|
||||
+8
-12
@@ -741,35 +741,31 @@ public class UserController {
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/get-api-key")
|
||||
public ResponseEntity<Map<String, String>> getApiKey(Principal principal) {
|
||||
public ResponseEntity<String> getApiKey(Principal principal) {
|
||||
if (principal == null) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("error", "User not authenticated."));
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("User not authenticated.");
|
||||
}
|
||||
String username = principal.getName();
|
||||
String apiKey = userService.getApiKeyForUser(username);
|
||||
if (apiKey == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "API key not found for user."));
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("API key not found for user.");
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("apiKey", apiKey));
|
||||
return ResponseEntity.ok(apiKey);
|
||||
}
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/update-api-key")
|
||||
public ResponseEntity<Map<String, String>> updateApiKey(Principal principal) {
|
||||
public ResponseEntity<String> updateApiKey(Principal principal) {
|
||||
if (principal == null) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("error", "User not authenticated."));
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("User not authenticated.");
|
||||
}
|
||||
String username = principal.getName();
|
||||
User user = userService.refreshApiKeyForUser(username);
|
||||
String apiKey = user.getApiKey();
|
||||
if (apiKey == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "API key not found for user."));
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("API key not found for user.");
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("apiKey", apiKey));
|
||||
return ResponseEntity.ok(apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-62
@@ -1,11 +1,7 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.DisabledException;
|
||||
import org.springframework.security.authentication.LockedException;
|
||||
@@ -64,66 +60,9 @@ public class CustomOAuth2AuthenticationFailureHandler
|
||||
"OAuth2 Authentication error: {}",
|
||||
errorCode != null ? errorCode : exception.getMessage(),
|
||||
exception);
|
||||
String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError";
|
||||
clearRedirectCookie(response);
|
||||
boolean tauriState = TauriOAuthUtils.isTauriState(request);
|
||||
String redirectUrl;
|
||||
if (tauriState) {
|
||||
String basePath =
|
||||
TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath());
|
||||
redirectUrl = basePath;
|
||||
String stateParam = request.getParameter("state");
|
||||
if (stateParam != null && !stateParam.isBlank()) {
|
||||
redirectUrl = appendQueryParam(redirectUrl, "state", stateParam);
|
||||
// Extract and pass nonce for CSRF validation
|
||||
String nonce = TauriOAuthUtils.extractNonceFromState(stateParam);
|
||||
if (nonce != null) {
|
||||
redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce);
|
||||
}
|
||||
}
|
||||
redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue);
|
||||
} else {
|
||||
redirectUrl = buildFailureRedirectUrl(request, errorValue);
|
||||
}
|
||||
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
|
||||
return;
|
||||
getRedirectStrategy().sendRedirect(request, response, "/login?errorOAuth=" + errorCode);
|
||||
}
|
||||
log.error("Unhandled authentication exception", exception);
|
||||
super.onAuthenticationFailure(request, response, exception);
|
||||
}
|
||||
|
||||
private String buildFailureRedirectUrl(HttpServletRequest request, String errorValue) {
|
||||
String contextPath = request.getContextPath();
|
||||
String cookiePath = TauriOAuthUtils.extractRedirectPathFromCookie(request);
|
||||
String redirectPath =
|
||||
cookiePath != null ? cookiePath : TauriOAuthUtils.defaultCallbackPath(contextPath);
|
||||
if (TauriOAuthUtils.isTauriState(request)) {
|
||||
redirectPath = appendQueryParam(redirectPath, "tauri", "1");
|
||||
}
|
||||
String resolvedPath =
|
||||
redirectPath.startsWith("/")
|
||||
? TauriOAuthUtils.normalizeContextPath(contextPath) + redirectPath
|
||||
: TauriOAuthUtils.normalizeContextPath(contextPath) + "/" + redirectPath;
|
||||
return appendQueryParam(resolvedPath, "errorOAuth", errorValue);
|
||||
}
|
||||
|
||||
private void clearRedirectCookie(HttpServletResponse response) {
|
||||
ResponseCookie cookie =
|
||||
ResponseCookie.from(TauriOAuthUtils.SPA_REDIRECT_COOKIE, "")
|
||||
.path("/")
|
||||
.sameSite("Lax")
|
||||
.maxAge(0)
|
||||
.build();
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
|
||||
}
|
||||
|
||||
private String appendQueryParam(String path, String key, String value) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return path;
|
||||
}
|
||||
String separator = path.contains("?") ? "&" : "?";
|
||||
String encodedKey = URLEncoder.encode(key, StandardCharsets.UTF_8);
|
||||
String encodedValue = value == null ? "" : URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
return path + separator + encodedKey + "=" + encodedValue;
|
||||
}
|
||||
}
|
||||
|
||||
+35
-18
@@ -4,6 +4,8 @@ import static stirling.software.proprietary.security.model.AuthenticationType.OA
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.SSO;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -19,6 +21,7 @@ import org.springframework.security.web.authentication.SavedRequestAwareAuthenti
|
||||
import org.springframework.security.web.savedrequest.SavedRequest;
|
||||
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
@@ -42,6 +45,9 @@ import stirling.software.proprietary.security.service.UserService;
|
||||
public class CustomOAuth2AuthenticationSuccessHandler
|
||||
extends SavedRequestAwareAuthenticationSuccessHandler {
|
||||
|
||||
private static final String SPA_REDIRECT_COOKIE = "stirling_redirect_path";
|
||||
private static final String DEFAULT_CALLBACK_PATH = "/auth/callback";
|
||||
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
private final ApplicationProperties.Security.OAUTH2 oauth2Properties;
|
||||
private final UserService userService;
|
||||
@@ -204,28 +210,39 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
resolveOriginFromReferer(request)
|
||||
.orElseGet(() -> buildOriginFromRequest(request)));
|
||||
clearRedirectCookie(response);
|
||||
|
||||
// Extract nonce from state for CSRF validation in callback
|
||||
String nonce = TauriOAuthUtils.extractNonceFromRequest(request);
|
||||
String url = origin + redirectPath + "#access_token=" + jwt;
|
||||
if (nonce != null) {
|
||||
url +=
|
||||
"&nonce="
|
||||
+ java.net.URLEncoder.encode(
|
||||
nonce, java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
return url;
|
||||
return origin + redirectPath + "#access_token=" + jwt;
|
||||
}
|
||||
|
||||
private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
|
||||
if (TauriOAuthUtils.isTauriState(request)) {
|
||||
return TauriOAuthUtils.defaultTauriCallbackPath(contextPath);
|
||||
return extractRedirectPathFromCookie(request)
|
||||
.filter(path -> path.startsWith("/"))
|
||||
.orElseGet(() -> defaultCallbackPath(contextPath));
|
||||
}
|
||||
|
||||
private Optional<String> extractRedirectPathFromCookie(HttpServletRequest request) {
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String cookiePath = TauriOAuthUtils.extractRedirectPathFromCookie(request);
|
||||
if (cookiePath != null && cookiePath.startsWith("/")) {
|
||||
return cookiePath;
|
||||
for (Cookie cookie : cookies) {
|
||||
if (SPA_REDIRECT_COOKIE.equals(cookie.getName())) {
|
||||
String value = URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8).trim();
|
||||
if (!value.isEmpty()) {
|
||||
return Optional.of(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return TauriOAuthUtils.defaultCallbackPath(contextPath);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private String defaultCallbackPath(String contextPath) {
|
||||
if (contextPath == null
|
||||
|| contextPath.isBlank()
|
||||
|| "/".equals(contextPath)
|
||||
|| "\\".equals(contextPath)) {
|
||||
return DEFAULT_CALLBACK_PATH;
|
||||
}
|
||||
return contextPath + DEFAULT_CALLBACK_PATH;
|
||||
}
|
||||
|
||||
private Optional<String> resolveForwardedOrigin(HttpServletRequest request) {
|
||||
@@ -309,7 +326,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
|
||||
private void clearRedirectCookie(HttpServletResponse response) {
|
||||
ResponseCookie cookie =
|
||||
ResponseCookie.from(TauriOAuthUtils.SPA_REDIRECT_COOKIE, "")
|
||||
ResponseCookie.from(SPA_REDIRECT_COOKIE, "")
|
||||
.path("/")
|
||||
.sameSite("Lax")
|
||||
.maxAge(0)
|
||||
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
|
||||
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
public class TauriAuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver {
|
||||
|
||||
private static final String TAURI_STATE_PREFIX = "tauri:";
|
||||
|
||||
private final OAuth2AuthorizationRequestResolver delegate;
|
||||
|
||||
public TauriAuthorizationRequestResolver(
|
||||
ClientRegistrationRepository clientRegistrationRepository) {
|
||||
this.delegate =
|
||||
new DefaultOAuth2AuthorizationRequestResolver(
|
||||
clientRegistrationRepository, "/oauth2/authorization");
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
|
||||
return customize(request, delegate.resolve(request));
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2AuthorizationRequest resolve(
|
||||
HttpServletRequest request, String clientRegistrationId) {
|
||||
return customize(request, delegate.resolve(request, clientRegistrationId));
|
||||
}
|
||||
|
||||
private OAuth2AuthorizationRequest customize(
|
||||
HttpServletRequest request, OAuth2AuthorizationRequest authorizationRequest) {
|
||||
if (authorizationRequest == null) {
|
||||
return null;
|
||||
}
|
||||
String tauriParam = request.getParameter("tauri");
|
||||
if (!"1".equals(tauriParam)) {
|
||||
return authorizationRequest;
|
||||
}
|
||||
|
||||
String state = authorizationRequest.getState();
|
||||
if (state == null || state.startsWith(TAURI_STATE_PREFIX)) {
|
||||
return authorizationRequest;
|
||||
}
|
||||
|
||||
// Extract nonce from request for CSRF protection
|
||||
String nonce = request.getParameter("nonce");
|
||||
String customState = TAURI_STATE_PREFIX + state;
|
||||
if (nonce != null && !nonce.isBlank()) {
|
||||
customState = customState + ":" + nonce;
|
||||
}
|
||||
|
||||
return OAuth2AuthorizationRequest.from(authorizationRequest).state(customState).build();
|
||||
}
|
||||
}
|
||||
-122
@@ -1,122 +0,0 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* Utility class for Tauri desktop OAuth flow handling. Centralizes common logic for OAuth state
|
||||
* management, nonce validation, and callback path construction.
|
||||
*/
|
||||
public final class TauriOAuthUtils {
|
||||
|
||||
public static final String TAURI_STATE_PREFIX = "tauri:";
|
||||
public static final String SPA_REDIRECT_COOKIE = "stirling_redirect_path";
|
||||
public static final String DEFAULT_CALLBACK_PATH = "/auth/callback";
|
||||
public static final String TAURI_CALLBACK_SUFFIX = "/tauri";
|
||||
|
||||
private TauriOAuthUtils() {
|
||||
// Utility class - prevent instantiation
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts nonce from OAuth state parameter for CSRF validation. State format:
|
||||
* tauri:<original-state>:<nonce>
|
||||
*
|
||||
* @param state The state parameter value
|
||||
* @return The nonce if present, null otherwise
|
||||
*/
|
||||
public static String extractNonceFromState(String state) {
|
||||
if (state == null || !state.startsWith(TAURI_STATE_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
// Split by ':' and get the last part (nonce)
|
||||
String[] parts = state.split(":");
|
||||
if (parts.length >= 3) {
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts nonce from request's state parameter.
|
||||
*
|
||||
* @param request The HTTP request
|
||||
* @return The nonce if present, null otherwise
|
||||
*/
|
||||
public static String extractNonceFromRequest(HttpServletRequest request) {
|
||||
String state = request.getParameter("state");
|
||||
return extractNonceFromState(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the request has a Tauri state parameter (desktop OAuth flow).
|
||||
*
|
||||
* @param request The HTTP request
|
||||
* @return true if this is a Tauri desktop OAuth flow, false otherwise
|
||||
*/
|
||||
public static boolean isTauriState(HttpServletRequest request) {
|
||||
String state = request.getParameter("state");
|
||||
return state != null && state.startsWith(TAURI_STATE_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the default callback path for the given context path.
|
||||
*
|
||||
* @param contextPath The application context path
|
||||
* @return The full callback path
|
||||
*/
|
||||
public static String defaultCallbackPath(String contextPath) {
|
||||
if (contextPath == null
|
||||
|| contextPath.isBlank()
|
||||
|| "/".equals(contextPath)
|
||||
|| "\\".equals(contextPath)) {
|
||||
return DEFAULT_CALLBACK_PATH;
|
||||
}
|
||||
return contextPath + DEFAULT_CALLBACK_PATH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Tauri-specific callback path (includes /tauri suffix).
|
||||
*
|
||||
* @param contextPath The application context path
|
||||
* @return The full Tauri callback path
|
||||
*/
|
||||
public static String defaultTauriCallbackPath(String contextPath) {
|
||||
return defaultCallbackPath(contextPath) + TAURI_CALLBACK_SUFFIX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes context path by removing trailing slashes and handling empty/root paths.
|
||||
*
|
||||
* @param contextPath The context path to normalize
|
||||
* @return Normalized context path (empty string for root)
|
||||
*/
|
||||
public static String normalizeContextPath(String contextPath) {
|
||||
if (contextPath == null || contextPath.isBlank() || "/".equals(contextPath)) {
|
||||
return "";
|
||||
}
|
||||
return contextPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the SPA redirect cookie value from the request.
|
||||
*
|
||||
* @param request The HTTP request
|
||||
* @return The redirect path from cookie, or null if not found
|
||||
*/
|
||||
public static String extractRedirectPathFromCookie(HttpServletRequest request) {
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null) {
|
||||
return null;
|
||||
}
|
||||
for (Cookie cookie : cookies) {
|
||||
if (SPA_REDIRECT_COOKIE.equals(cookie.getName())) {
|
||||
String value =
|
||||
java.net.URLDecoder.decode(
|
||||
cookie.getValue(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
return value.trim().isEmpty() ? null : value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -292,7 +292,6 @@ public class FormUtils {
|
||||
}
|
||||
|
||||
PDFRenderer renderer = new PDFRenderer(document);
|
||||
renderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
|
||||
ApplicationProperties properties =
|
||||
ApplicationContextProvider.getBean(ApplicationProperties.class);
|
||||
|
||||
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
|
||||
class CustomOAuth2AuthenticationFailureHandlerTest {
|
||||
|
||||
@Test
|
||||
void redirectsToTauriCallbackWhenStateMarked() throws Exception {
|
||||
CustomOAuth2AuthenticationFailureHandler handler =
|
||||
new CustomOAuth2AuthenticationFailureHandler();
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setContextPath("");
|
||||
request.setParameter("state", "tauri:abc");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
handler.onAuthenticationFailure(
|
||||
request,
|
||||
response,
|
||||
new OAuth2AuthenticationException(new OAuth2Error("access_denied")));
|
||||
|
||||
assertEquals(
|
||||
"/auth/callback/tauri?state=tauri%3Aabc&errorOAuth=access_denied",
|
||||
response.getRedirectedUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void redirectsToDefaultCallbackWithoutTauriState() throws Exception {
|
||||
CustomOAuth2AuthenticationFailureHandler handler =
|
||||
new CustomOAuth2AuthenticationFailureHandler();
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setContextPath("");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
handler.onAuthenticationFailure(
|
||||
request,
|
||||
response,
|
||||
new OAuth2AuthenticationException(new OAuth2Error("access_denied")));
|
||||
|
||||
assertEquals("/auth/callback?errorOAuth=access_denied", response.getRedirectedUrl());
|
||||
}
|
||||
}
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CustomOAuth2AuthenticationSuccessHandlerTest {
|
||||
|
||||
@Test
|
||||
void redirectsToTauriCallbackWhenStateMarked() throws Exception {
|
||||
LoginAttemptService loginAttemptService = mock(LoginAttemptService.class);
|
||||
UserService userService = mock(UserService.class);
|
||||
JwtServiceInterface jwtService = mock(JwtServiceInterface.class);
|
||||
UserLicenseSettingsService licenseSettingsService = mock(UserLicenseSettingsService.class);
|
||||
|
||||
ApplicationProperties.Security.OAUTH2 oauth2Props =
|
||||
new ApplicationProperties.Security.OAUTH2();
|
||||
oauth2Props.setAutoCreateUser(true);
|
||||
oauth2Props.setBlockRegistration(false);
|
||||
|
||||
CustomOAuth2AuthenticationSuccessHandler handler =
|
||||
new CustomOAuth2AuthenticationSuccessHandler(
|
||||
loginAttemptService,
|
||||
oauth2Props,
|
||||
userService,
|
||||
jwtService,
|
||||
licenseSettingsService);
|
||||
|
||||
when(userService.usernameExistsIgnoreCase("user")).thenReturn(false);
|
||||
when(licenseSettingsService.isOAuthEligible(null)).thenReturn(true);
|
||||
when(userService.isUserDisabled("user")).thenReturn(false);
|
||||
when(jwtService.isJwtEnabled()).thenReturn(true);
|
||||
when(jwtService.generateToken(
|
||||
org.mockito.Mockito.any(
|
||||
org.springframework.security.core.Authentication.class),
|
||||
org.mockito.Mockito.anyMap()))
|
||||
.thenReturn("jwt");
|
||||
|
||||
Map<String, Object> attributes = Map.of("sub", "provider-sub", "name", "user");
|
||||
DefaultOAuth2User oauthUser =
|
||||
new DefaultOAuth2User(
|
||||
List.of(new SimpleGrantedAuthority("ROLE_USER")), attributes, "name");
|
||||
OAuth2AuthenticationToken authentication =
|
||||
new OAuth2AuthenticationToken(oauthUser, oauthUser.getAuthorities(), "google");
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setContextPath("");
|
||||
request.setScheme("http");
|
||||
request.setServerName("localhost");
|
||||
request.setServerPort(8080);
|
||||
request.setParameter("state", "tauri:abc");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
handler.onAuthenticationSuccess(request, response, authentication);
|
||||
|
||||
assertEquals(
|
||||
"http://localhost:8080/auth/callback/tauri#access_token=jwt",
|
||||
response.getRedirectedUrl());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user