mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 21:30:14 +03:00
Compare commits
110
Commits
langUpdates
...
utf8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a182676d3a | ||
|
|
73f12f40bb | ||
|
|
95d8917380 | ||
|
|
bdcc186b41 | ||
|
|
77b813723a | ||
|
|
40ac69f101 | ||
|
|
e8c67d9e37 | ||
|
|
0b5c885750 | ||
|
|
72cc4e4963 | ||
|
|
b7041cfc78 | ||
|
|
8d6c8cbc11 | ||
|
|
0e8992e3a5 | ||
|
|
aa39435303 | ||
|
|
3eaccc1e0e | ||
|
|
1a7fd6ac4e | ||
|
|
e49793a6b7 | ||
|
|
a366463496 | ||
|
|
472ee54098 | ||
|
|
db049a3467 | ||
|
|
b00bd760c8 | ||
|
|
e7b030e6b5 | ||
|
|
83e96a9aa3 | ||
|
|
392462a325 | ||
|
|
0a801fe3a6 | ||
|
|
646df1bfb5 | ||
|
|
bfc029f993 | ||
|
|
b266b50bef | ||
|
|
84ed1d7ecb | ||
|
|
daf27b6128 | ||
|
|
818c30cb55 | ||
|
|
7460e58abf | ||
|
|
a9ee7f6a8e | ||
|
|
36e0fd566a | ||
|
|
cfe90cff66 | ||
|
|
65a5d05713 | ||
|
|
8632ed9875 | ||
|
|
3af5970424 | ||
|
|
3c8fb8ac96 | ||
|
|
251ad63ea6 | ||
|
|
c7b713ac80 | ||
|
|
d4d4538630 | ||
|
|
d2677e64dd | ||
|
|
0ae108ca11 | ||
|
|
5236c1ccf6 | ||
|
|
72389f5872 | ||
|
|
a284dbfc15 | ||
|
|
ff078b9d88 | ||
|
|
18be8f4692 | ||
|
|
dd09f7b7cf | ||
|
|
8394b71014 | ||
|
|
b4dd7afe03 | ||
|
|
ef7a3fa933 | ||
|
|
772ed6f52b | ||
|
|
4a1039fa97 | ||
|
|
c158664d2c | ||
|
|
d9caa3482c | ||
|
|
c44b7e229c | ||
|
|
f38e3d2b2c | ||
|
|
b6e675fab3 | ||
|
|
91bf9abbaa | ||
|
|
faf0a3555e | ||
|
|
fbc5d91c2c | ||
|
|
0c96133544 | ||
|
|
991d158cb8 | ||
|
|
02f9785212 | ||
|
|
3fd0398648 | ||
|
|
70fc6348f3 | ||
|
|
8f1af5f967 | ||
|
|
16cd453870 | ||
|
|
915a33bbc2 | ||
|
|
42f9384ece | ||
|
|
804f1d8975 | ||
|
|
182eb504de | ||
|
|
98fa5dfcc1 | ||
|
|
429520d3f9 | ||
|
|
fbfe1da7da | ||
|
|
c047bdda8c | ||
|
|
a33e22559b | ||
|
|
1318604f32 | ||
|
|
43a5f72b01 | ||
|
|
d97820c232 | ||
|
|
03cd7c1456 | ||
|
|
b1b37366e8 | ||
|
|
5cd9377bba | ||
|
|
a3e1cc8393 | ||
|
|
1810a12cf3 | ||
|
|
c3cd8c48b5 | ||
|
|
2502a8def0 | ||
|
|
54cd804319 | ||
|
|
1b0a1e938e | ||
|
|
69b035c6e1 | ||
|
|
8164c56d33 | ||
|
|
098f2badec | ||
|
|
d2a4054120 | ||
|
|
7baf503d60 | ||
|
|
d9622d0fac | ||
|
|
ddba5a3eab | ||
|
|
0bfb90cfc1 | ||
|
|
c454a72f08 | ||
|
|
34fd328444 | ||
|
|
738dd0645d | ||
|
|
e997f0d4ff | ||
|
|
e137436656 | ||
|
|
1649b9bffb | ||
|
|
c990ab3216 | ||
|
|
e6d3f20c36 | ||
|
|
79d5bc942d | ||
|
|
d652fc1d1c | ||
|
|
22a1412242 | ||
|
|
0c3c0765a3 |
+5
-8
@@ -2,20 +2,17 @@
|
||||
* @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
|
||||
|
||||
# Backend
|
||||
/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
|
||||
|
||||
#V1 frontend
|
||||
/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
|
||||
/app/core/src/main/resources/templates/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
|
||||
/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh @balazs-szucs
|
||||
|
||||
#V2 frontend
|
||||
/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle
|
||||
/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @balazs-szucs
|
||||
/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87 @balazs-szucs
|
||||
|
||||
#V2 docker
|
||||
/docker/backend/** @Frooodle @Ludy87 @DarioGii @Ludy87
|
||||
/docker/backend/** @Frooodle @Ludy87 @DarioGii
|
||||
/docker/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
|
||||
/docker/compose/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
|
||||
|
||||
|
||||
#GHA (All users)
|
||||
/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
|
||||
/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87 @balazs-szucs
|
||||
|
||||
@@ -2,12 +2,9 @@ build: &build
|
||||
- build.gradle
|
||||
- app/(common|core|proprietary)/build.gradle
|
||||
|
||||
app: &app
|
||||
- app/(common|core|proprietary)/src/main/java/**
|
||||
|
||||
openapi: &openapi
|
||||
- *build
|
||||
- *app
|
||||
- app/(common|core|proprietary)/src/main/java/**
|
||||
|
||||
docker: &docker
|
||||
- Dockerfile
|
||||
@@ -34,9 +31,30 @@ project: &project
|
||||
- settings.gradle
|
||||
- frontend/**
|
||||
- docker/**
|
||||
- scripts/RestartHelper.java
|
||||
|
||||
frontend: &frontend
|
||||
- frontend/**
|
||||
- .github/workflows/testdriver.yml
|
||||
- testing/**
|
||||
- docker/**
|
||||
- scripts/translations/*.py
|
||||
- scripts/build-tauri-jlink.bat
|
||||
- scripts/build-tauri-jlink.sh
|
||||
- scripts/convert_cff_to_ttf.py
|
||||
- scripts/harvest_type3_fonts.py
|
||||
- scripts/ignore_translation.toml
|
||||
- scripts/index_type3_catalogue.py
|
||||
- scripts/summarize_type3_signatures.py
|
||||
- scripts/type3_to_cff.py
|
||||
- scripts/update_type3_library.py
|
||||
|
||||
licenses-frontend: &licenses-frontend
|
||||
- ".github/workflows/frontend-backend-licenses-update.yml"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/scripts/generate-licenses.js"
|
||||
|
||||
licenses-backend: &licenses-backend
|
||||
- ".github/workflows/frontend-backend-licenses-update.yml"
|
||||
- *build
|
||||
@@ -1 +1 @@
|
||||
allow-ghsas: GHSA-wrw7-89jp-8q8g
|
||||
allow-ghsas: GHSA-wrw7-89jp-8q8g
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"reecebrowne",
|
||||
"DarioGii",
|
||||
"ConnorYoh",
|
||||
"EthanHealy01"
|
||||
"EthanHealy01",
|
||||
"jbrunton96",
|
||||
"balazs-szucs"
|
||||
]
|
||||
}
|
||||
|
||||
+49
-2
@@ -6,18 +6,65 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gradle" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
directories:
|
||||
- "/" # Location of package manifests
|
||||
- "/app/common"
|
||||
- "/app/core"
|
||||
- "/app/proprietary"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/" # Location of Dockerfile
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /devTools
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: /docker/backend
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: /docker/embedded
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: /docker/frontend
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /frontend
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: cargo
|
||||
directory: /frontend/src-tauri
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: pip
|
||||
directory: /testing/cucumber
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
@@ -46,26 +46,34 @@ labels:
|
||||
- label: 'API'
|
||||
title: '.*openapi.*|.*swagger.*|.*api.*'
|
||||
|
||||
- label: 'v2'
|
||||
base-branch: 'V2'
|
||||
- label: 'v3'
|
||||
base-branch: 'V3'
|
||||
|
||||
- label: 'Translation'
|
||||
files:
|
||||
- 'app/core/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}.properties'
|
||||
- 'frontend/public/locales/[a-zA-Z]{2}-[a-zA-Z\-]{2,7}/translation.toml'
|
||||
- 'scripts/ignore_translation.toml'
|
||||
- 'app/core/src/main/resources/templates/fragments/languages.html'
|
||||
- '.github/scripts/check_language_properties.py'
|
||||
- 'scripts/remove_translation_keys.sh'
|
||||
- 'scripts/replace_translation_line.sh'
|
||||
- 'scripts/translations/.*'
|
||||
- '.github/scripts/check_language_toml.py'
|
||||
- 'scripts/counter_translation_v3.py'
|
||||
|
||||
- label: 'Front End'
|
||||
files:
|
||||
- 'app/core/src/main/resources/templates/.*'
|
||||
- 'app/proprietary/src/main/resources/templates/.*'
|
||||
- 'app/core/src/main/resources/static/.*'
|
||||
- 'app/proprietary/src/main/resources/static/.*'
|
||||
- 'app/core/src/main/java/stirling/software/SPDF/controller/web/.*'
|
||||
- 'app/core/src/main/java/stirling/software/SPDF/UI/.*'
|
||||
- 'app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/.*'
|
||||
- 'frontend/**'
|
||||
- 'frontend/.*'
|
||||
- 'frontend/**/.*'
|
||||
|
||||
- label: 'Tauri'
|
||||
files:
|
||||
- 'frontend/src-tauri/**'
|
||||
- 'frontend/src-tauri/.*'
|
||||
|
||||
- label: 'Java'
|
||||
files:
|
||||
|
||||
@@ -84,6 +84,9 @@
|
||||
- name: "v2"
|
||||
color: "FFFF00"
|
||||
description: "Issues or pull requests related to the v2 branch"
|
||||
- name: "v3"
|
||||
color: "FFA500"
|
||||
description: "Issues or pull requests related to the v3 branch"
|
||||
- name: "wontfix"
|
||||
description: "This will not be worked on"
|
||||
color: "FFFFFF"
|
||||
@@ -187,3 +190,14 @@
|
||||
- name: "break-change"
|
||||
color: "FF0000"
|
||||
description: "This PR introduces a breaking API change."
|
||||
- name: "Rust"
|
||||
color: "DEA584"
|
||||
description: "Pull requests that update Rust code"
|
||||
from_name: "rust"
|
||||
- name: "Tauri"
|
||||
color: "24C8FF"
|
||||
description: "Pull requests that update Tauri code"
|
||||
from_name: "tauri"
|
||||
- name: "license-review-required"
|
||||
color: "EDEDED"
|
||||
description: "This PR requires a license review"
|
||||
|
||||
@@ -11,15 +11,16 @@ 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 copy
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import argparse
|
||||
import re
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import tomllib # Python 3.11+ (stdlib)
|
||||
import tomli_w # For writing TOML files
|
||||
|
||||
@@ -38,7 +39,8 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
|
||||
duplicates = []
|
||||
|
||||
# Load TOML file
|
||||
with open(file_path, 'rb') as file:
|
||||
file_path = Path(file_path)
|
||||
with file_path.open("rb") as file:
|
||||
data = tomllib.load(file)
|
||||
|
||||
def process_dict(obj, current_prefix=""):
|
||||
@@ -57,8 +59,8 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
|
||||
return duplicates
|
||||
|
||||
|
||||
# Maximum size for TOML files (e.g., 500 KB)
|
||||
MAX_FILE_SIZE = 500 * 1024
|
||||
# Maximum size for TOML files (e.g., 570 KB)
|
||||
MAX_FILE_SIZE = 570 * 1024
|
||||
|
||||
|
||||
def parse_toml_file(file_path):
|
||||
@@ -67,7 +69,8 @@ def parse_toml_file(file_path):
|
||||
:param file_path: Path to the TOML file.
|
||||
:return: Dictionary with flattened keys.
|
||||
"""
|
||||
with open(file_path, 'rb') as file:
|
||||
file_path = Path(file_path)
|
||||
with file_path.open("rb") as file:
|
||||
data = tomllib.load(file)
|
||||
|
||||
def flatten_dict(d, parent_key="", sep="."):
|
||||
@@ -110,7 +113,8 @@ def write_toml_file(file_path, updated_properties):
|
||||
"""
|
||||
nested_data = unflatten_dict(updated_properties)
|
||||
|
||||
with open(file_path, "wb") as file:
|
||||
file_path = Path(file_path)
|
||||
with file_path.open("wb") as file:
|
||||
tomli_w.dump(nested_data, file)
|
||||
|
||||
|
||||
@@ -121,18 +125,23 @@ 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:
|
||||
basename_current_file = os.path.basename(os.path.join(branch, file_path))
|
||||
file_path = Path(file_path)
|
||||
language_dir = file_path.parent.name
|
||||
reference_lang_dir = reference_file.parent.name
|
||||
if (
|
||||
basename_current_file == os.path.basename(reference_file)
|
||||
or not file_path.endswith(".toml")
|
||||
or not os.path.dirname(file_path).endswith("locales")
|
||||
language_dir == reference_lang_dir
|
||||
or file_path.suffix != ".toml"
|
||||
or file_path.parents[1].name != "locales"
|
||||
):
|
||||
print(f"Skipping file: {file_path}")
|
||||
continue
|
||||
|
||||
current_properties = parse_toml_file(os.path.join(branch, file_path))
|
||||
current_properties = parse_toml_file(branch_path / file_path)
|
||||
updated_properties = {}
|
||||
|
||||
for ref_key, ref_value in reference_properties.items():
|
||||
@@ -143,7 +152,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(os.path.join(branch, file_path), updated_properties)
|
||||
write_toml_file(branch_path / file_path, updated_properties)
|
||||
|
||||
|
||||
def check_for_missing_keys(reference_file, file_list, branch):
|
||||
@@ -151,14 +160,17 @@ def check_for_missing_keys(reference_file, file_list, branch):
|
||||
|
||||
|
||||
def read_toml_keys(file_path):
|
||||
if os.path.isfile(file_path) and os.path.exists(file_path):
|
||||
file_path = Path(file_path)
|
||||
if file_path.is_file():
|
||||
return parse_toml_file(file_path)
|
||||
return {}
|
||||
|
||||
|
||||
def check_for_differences(reference_file, file_list, branch, actor):
|
||||
reference_branch = branch
|
||||
basename_reference_file = os.path.basename(reference_file)
|
||||
reference_file = Path(reference_file)
|
||||
basename_reference_file = reference_file.name
|
||||
branch_path = Path(branch) if branch else Path()
|
||||
|
||||
report = []
|
||||
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
|
||||
@@ -172,39 +184,44 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
if len(file_list) == 1:
|
||||
file_arr = file_list[0].split()
|
||||
|
||||
base_dir = os.path.abspath(
|
||||
os.path.join(os.getcwd(), "frontend", "public", "locales")
|
||||
)
|
||||
base_dir = Path.cwd() / "frontend" / "public" / "locales"
|
||||
|
||||
for file_path in file_arr:
|
||||
file_normpath = os.path.normpath(file_path)
|
||||
absolute_path = os.path.abspath(file_normpath)
|
||||
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}`")
|
||||
|
||||
# Verify that file is within the expected directory
|
||||
if not absolute_path.startswith(base_dir):
|
||||
raise ValueError(f"Unsafe file found: {file_normpath}")
|
||||
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
|
||||
|
||||
# Verify file size before processing
|
||||
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."
|
||||
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"
|
||||
)
|
||||
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 (
|
||||
basename_current_file == basename_reference_file
|
||||
and locale_dir == "en-GB"
|
||||
file_normpath.suffix != ".toml"
|
||||
or basename_current_file != "translation.toml"
|
||||
):
|
||||
continue
|
||||
|
||||
if not file_normpath.endswith(".toml") or basename_current_file != "translation.toml":
|
||||
continue
|
||||
|
||||
only_reference_file = False
|
||||
report.append(f"#### 📃 **File Check:** `{locale_dir}/{basename_current_file}`")
|
||||
current_keys = read_toml_keys(os.path.join(branch, file_path))
|
||||
current_keys = read_toml_keys(branch_path / file_path)
|
||||
reference_key_count = len(reference_keys)
|
||||
current_key_count = len(current_keys)
|
||||
|
||||
@@ -249,13 +266,13 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
else:
|
||||
report.append("2. **Test Status:** ✅ **_Passed_**")
|
||||
|
||||
if find_duplicate_keys(os.path.join(branch, file_normpath)):
|
||||
if find_duplicate_keys(branch_path / file_normpath):
|
||||
has_differences = True
|
||||
output = "\n".join(
|
||||
[
|
||||
f" - `{key}`: first at {first}, duplicate at `{duplicate}`"
|
||||
for key, first, duplicate in find_duplicate_keys(
|
||||
os.path.join(branch, file_normpath)
|
||||
branch_path / file_normpath
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -288,7 +305,9 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Find missing keys in TOML translation files")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Find missing keys in TOML translation files"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--actor",
|
||||
required=False,
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
#
|
||||
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' --strip-extras '.github\scripts\requirements_pre_commit.in'
|
||||
#
|
||||
cfgv==3.4.0 \
|
||||
--hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \
|
||||
--hash=sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560
|
||||
cfgv==3.5.0 \
|
||||
--hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \
|
||||
--hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132
|
||||
# via pre-commit
|
||||
distlib==0.4.0 \
|
||||
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
|
||||
@@ -28,9 +28,9 @@ platformdirs==4.5.0 \
|
||||
--hash=sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312 \
|
||||
--hash=sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3
|
||||
# via virtualenv
|
||||
pre-commit==4.3.0 \
|
||||
--hash=sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8 \
|
||||
--hash=sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16
|
||||
pre-commit==4.5.0 \
|
||||
--hash=sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1 \
|
||||
--hash=sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b
|
||||
# via -r .github/scripts/requirements_pre_commit.in
|
||||
pyyaml==6.0.3 \
|
||||
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
tomlkit
|
||||
tomli-w
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
#
|
||||
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_sync_readme.txt' --strip-extras '.github\scripts\requirements_sync_readme.in'
|
||||
#
|
||||
tomli-w==1.2.0 \
|
||||
--hash=sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90 \
|
||||
--hash=sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021
|
||||
# via -r .github/scripts/requirements_sync_readme.in
|
||||
tomlkit==0.13.3 \
|
||||
--hash=sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1 \
|
||||
--hash=sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0
|
||||
|
||||
@@ -11,6 +11,10 @@ on:
|
||||
allow_fork:
|
||||
description: "Allow deploying fork PR?"
|
||||
required: false
|
||||
type: choice
|
||||
options:
|
||||
- "true"
|
||||
- "false"
|
||||
default: "false"
|
||||
|
||||
permissions:
|
||||
@@ -31,18 +35,18 @@ jobs:
|
||||
pr_ref: ${{ steps.resolve.outputs.ref }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Resolve PR info
|
||||
id: resolve
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
let prNumber = context.eventName === 'workflow_dispatch'
|
||||
? parseInt(process.env.INPUT_PR, 10)
|
||||
? parseInt(context.payload.inputs.pr, 10)
|
||||
: context.payload.number;
|
||||
|
||||
if (!Number.isInteger(prNumber)) { core.setFailed('Invalid PR number'); return; }
|
||||
@@ -107,7 +111,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -128,13 +132,13 @@ jobs:
|
||||
|
||||
- name: Add deployment started comment
|
||||
id: deployment-started
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = ${{ needs.check-pr.outputs.pr_number }};
|
||||
|
||||
|
||||
// Delete previous V2 deployment comments to avoid clutter
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
@@ -157,7 +161,7 @@ jobs:
|
||||
comment_id: comment.id
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Create new deployment started comment
|
||||
const { data: newComment } = await github.rest.issues.createComment({
|
||||
owner,
|
||||
@@ -173,10 +177,10 @@ jobs:
|
||||
repository: ${{ needs.check-pr.outputs.pr_repository }}
|
||||
ref: ${{ needs.check-pr.outputs.pr_ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0 # Fetch full history for commit hash detection
|
||||
fetch-depth: 0 # Fetch full history for commit hash detection
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
@@ -185,7 +189,7 @@ jobs:
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
@@ -204,20 +208,20 @@ jobs:
|
||||
if [ -z "$BACKEND_HASH" ]; then
|
||||
BACKEND_HASH="no-backend-changes"
|
||||
fi
|
||||
|
||||
|
||||
echo "Frontend hash: $FRONTEND_HASH"
|
||||
echo "Backend hash: $BACKEND_HASH"
|
||||
|
||||
|
||||
echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
|
||||
echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
# Short hashes for tags
|
||||
if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
|
||||
echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
|
||||
if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
|
||||
echo "backend_short=no-backend" >> $GITHUB_OUTPUT
|
||||
else
|
||||
@@ -271,7 +275,7 @@ jobs:
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Deploy V2 to VPS
|
||||
@@ -280,7 +284,7 @@ jobs:
|
||||
# Use same port strategy as regular PRs - just the PR number
|
||||
V2_PORT=${{ needs.check-pr.outputs.pr_number }}
|
||||
BACKEND_PORT=$((V2_PORT + 10000)) # Backend on higher port to avoid conflicts
|
||||
|
||||
|
||||
# Create docker-compose for V2 with separate frontend and backend
|
||||
cat > docker-compose.yml << EOF
|
||||
version: '3.3'
|
||||
@@ -316,16 +320,16 @@ jobs:
|
||||
ports:
|
||||
- "${V2_PORT}:80" # Frontend port (same as regular PRs)
|
||||
environment:
|
||||
VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:${BACKEND_PORT}"
|
||||
VITE_API_BASE_URL: "http://${{ secrets.NEW_VPS_HOST }}:${BACKEND_PORT}"
|
||||
depends_on:
|
||||
- stirling-pdf-v2-backend
|
||||
restart: on-failure:5
|
||||
EOF
|
||||
|
||||
# Deploy to VPS
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose-v2.yml
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose-v2.yml
|
||||
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH
|
||||
# Create V2 PR-specific directories
|
||||
mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs}
|
||||
|
||||
@@ -352,14 +356,14 @@ jobs:
|
||||
|
||||
- name: Post V2 deployment URL to PR
|
||||
if: success()
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = ${{ needs.check-pr.outputs.pr_number }};
|
||||
const v2Port = ${{ steps.deploy.outputs.v2_port }};
|
||||
|
||||
|
||||
// Delete the "deploying..." comment since we're posting the final result
|
||||
const deploymentStartedId = ${{ steps.deployment-started.outputs.result }};
|
||||
if (deploymentStartedId) {
|
||||
@@ -374,8 +378,8 @@ jobs:
|
||||
console.log(`Could not delete deployment started comment: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${v2Port}`;
|
||||
|
||||
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`;
|
||||
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
|
||||
|
||||
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
|
||||
@@ -402,7 +406,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -419,25 +423,25 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Clean up V2 deployment comments
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = ${{ github.event.pull_request.number }};
|
||||
|
||||
|
||||
// Find and delete V2 deployment comments
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber
|
||||
});
|
||||
|
||||
|
||||
const v2Comments = comments.filter(c =>
|
||||
c.body?.includes("## 🚀 V2 Auto-Deployment Complete!") &&
|
||||
c.user?.type === "Bot"
|
||||
);
|
||||
|
||||
|
||||
for (const comment of v2Comments) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
@@ -450,12 +454,12 @@ jobs:
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Cleanup V2 deployment
|
||||
run: |
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << 'ENDSSH'
|
||||
if [ -d "/stirling/V2-PR-${{ github.event.pull_request.number }}" ]; then
|
||||
echo "Found V2 PR directory, proceeding with cleanup..."
|
||||
|
||||
|
||||
@@ -25,8 +25,7 @@ jobs:
|
||||
github.event.comment.user.login == 'frooodle' ||
|
||||
github.event.comment.user.login == 'sf298' ||
|
||||
github.event.comment.user.login == 'Ludy87' ||
|
||||
github.event.comment.user.login == 'LaserKaspar' ||
|
||||
github.event.comment.user.login == 'sbplat' ||
|
||||
github.event.comment.user.login == 'balazs-szucs' ||
|
||||
github.event.comment.user.login == 'reecebrowne' ||
|
||||
github.event.comment.user.login == 'DarioGii' ||
|
||||
github.event.comment.user.login == 'EthanHealy01' ||
|
||||
@@ -41,7 +40,7 @@ jobs:
|
||||
enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -129,7 +128,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -151,7 +150,7 @@ jobs:
|
||||
ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
- name: Set up JDK
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
@@ -169,7 +168,7 @@ jobs:
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
@@ -190,7 +189,7 @@ jobs:
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Deploy to VPS
|
||||
@@ -252,9 +251,9 @@ jobs:
|
||||
EOF
|
||||
|
||||
# Then copy the file and execute commands
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose.yml
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose.yml
|
||||
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH
|
||||
# Create PR-specific directories
|
||||
mkdir -p /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/{data,config,logs}
|
||||
|
||||
@@ -336,7 +335,7 @@ jobs:
|
||||
const prNumber = ${{ needs.check-comment.outputs.pr_number }};
|
||||
const securityStatus = process.env.security_status || "Security Disabled";
|
||||
|
||||
const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${prNumber}`;
|
||||
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${prNumber}`;
|
||||
const commentBody = `## 🚀 PR Test Deployment\n\n` +
|
||||
`Your PR has been deployed for testing!\n\n` +
|
||||
`🔗 **Test URL:** [${deploymentUrl}](${deploymentUrl})\n` +
|
||||
@@ -363,7 +362,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
SERVER_IP: ${{ secrets.VPS_IP }} # Add this to your GitHub secrets
|
||||
SERVER_IP: ${{ secrets.NEW_VPS_IP }} # Add this to your GitHub secrets
|
||||
CLEANUP_PERFORMED: "false" # Add flag to track if cleanup occurred
|
||||
|
||||
jobs:
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -96,19 +96,18 @@ jobs:
|
||||
const hasDeploymentComment = deploymentComments.length > 0;
|
||||
core.setOutput('present', (hasLabel || hasDeploymentComment) ? 'true' : 'false');
|
||||
|
||||
|
||||
- name: Set up SSH
|
||||
if: steps.remove-label-comment.outputs.present == 'true'
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Cleanup PR deployment
|
||||
if: steps.remove-label-comment.outputs.present == 'true'
|
||||
id: cleanup
|
||||
run: |
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << 'ENDSSH'
|
||||
if [ -d "/stirling/PR-${{ github.event.pull_request.number }}" ]; then
|
||||
echo "Found PR directory, proceeding with cleanup..."
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
types: [opened, edited]
|
||||
branches: [main]
|
||||
|
||||
permissions: # required for secure-repo hardening
|
||||
permissions: # required for secure-repo hardening
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
- name: AI PR Title Analysis
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
id: ai-title-analysis
|
||||
uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1.2.8
|
||||
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt-file: ".github/config/system-prompt.txt"
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
types: [opened, synchronize]
|
||||
branches:
|
||||
- main
|
||||
- V2
|
||||
- V3
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
+49
-39
@@ -2,7 +2,7 @@ name: Build and Test Workflow
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["main", "V2", "V2-gha"]
|
||||
branches: ["main"]
|
||||
workflow_dispatch:
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
@@ -27,15 +27,16 @@ jobs:
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
build: ${{ steps.changes.outputs.build }}
|
||||
app: ${{ steps.changes.outputs.app }}
|
||||
project: ${{ steps.changes.outputs.project }}
|
||||
openapi: ${{ steps.changes.outputs.openapi }}
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
@@ -55,7 +56,7 @@ jobs:
|
||||
spring-security: [true, false]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -66,14 +67,12 @@ jobs:
|
||||
with:
|
||||
java-version: ${{ matrix.jdk-version }}
|
||||
distribution: "temurin"
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
|
||||
run: ./gradlew clean build -PnoSpotless
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.spring-security }}
|
||||
|
||||
- name: Check Test Reports Exist
|
||||
if: always()
|
||||
run: |
|
||||
@@ -91,12 +90,14 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Upload Test Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
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/
|
||||
@@ -104,31 +105,41 @@ 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 17
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Generate OpenAPI documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
|
||||
- name: Upload OpenAPI Documentation
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
@@ -136,10 +147,12 @@ jobs:
|
||||
path: ./SwaggerDoc.json
|
||||
|
||||
frontend-validation:
|
||||
if: needs.files-changed.outputs.frontend == 'true'
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -147,8 +160,8 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install frontend dependencies
|
||||
run: cd frontend && npm ci
|
||||
@@ -173,15 +186,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up JDK 17
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: check the licenses for compatibility
|
||||
@@ -216,21 +231,21 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up Java 17
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Install Docker Compose
|
||||
run: |
|
||||
@@ -238,10 +253,10 @@ jobs:
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: 'pip' # caching pip dependencies
|
||||
cache: "pip" # caching pip dependencies
|
||||
cache-dependency-path: ./testing/cucumber/requirements.txt
|
||||
|
||||
- name: Pip requirements
|
||||
@@ -271,7 +286,7 @@ jobs:
|
||||
artifact-suffix: Dockerfile.fat
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -285,17 +300,12 @@ jobs:
|
||||
docker system prune -af || true
|
||||
echo "Disk space after cleanup:" && df -h
|
||||
|
||||
- name: Set up JDK 17
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Build application
|
||||
run: ./gradlew clean build
|
||||
env:
|
||||
@@ -303,11 +313,11 @@ jobs:
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Build ${{ matrix.docker-rev }}
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
@@ -316,8 +326,8 @@ jobs:
|
||||
context: .
|
||||
file: ./${{ matrix.docker-rev }}
|
||||
push: false
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-from: type=gha,scope=${{ matrix.artifact-suffix }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.artifact-suffix }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
@@ -7,6 +7,8 @@ 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:
|
||||
@@ -25,7 +27,7 @@ jobs:
|
||||
pull-requests: write # Allow writing to pull requests
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -194,13 +196,12 @@ jobs:
|
||||
core.exportVariable("REFERENCE_FILE", referenceFilePath);
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
pip install tomli-w
|
||||
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
|
||||
|
||||
- name: Run Python script to check files
|
||||
id: run-check
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
#disable for now
|
||||
#on:
|
||||
# push:
|
||||
# branches: ["main"]
|
||||
# pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
# branches: ["main"]
|
||||
# schedule:
|
||||
# - cron: "0 0 * * 1"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ["java"]
|
||||
# CodeQL supports [ $supported-codeql-languages ]
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -26,4 +26,4 @@ jobs:
|
||||
- name: "Dependency Review"
|
||||
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||
with:
|
||||
config-file: './.github/config/dependency-review-config.yml'
|
||||
config-file: "./.github/config/dependency-review-config.yml"
|
||||
|
||||
@@ -18,15 +18,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Get commit hashes for frontend and backend
|
||||
id: commit-hashes
|
||||
@@ -36,26 +36,26 @@ jobs:
|
||||
if [ -z "$FRONTEND_HASH" ]; then
|
||||
FRONTEND_HASH="no-frontend-changes"
|
||||
fi
|
||||
|
||||
|
||||
# Get last commit that touched backend code, docker/backend, or docker/compose
|
||||
BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
|
||||
if [ -z "$BACKEND_HASH" ]; then
|
||||
BACKEND_HASH="no-backend-changes"
|
||||
fi
|
||||
|
||||
|
||||
echo "Frontend hash: $FRONTEND_HASH"
|
||||
echo "Backend hash: $BACKEND_HASH"
|
||||
|
||||
|
||||
echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
|
||||
echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
# Short hashes for tags
|
||||
if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
|
||||
echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
|
||||
if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
|
||||
echo "backend_short=no-backend" >> $GITHUB_OUTPUT
|
||||
else
|
||||
@@ -84,16 +84,15 @@ jobs:
|
||||
echo "Backend image needs to be built"
|
||||
fi
|
||||
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Build and push frontend image
|
||||
if: steps.check-frontend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/frontend/Dockerfile
|
||||
@@ -103,10 +102,10 @@ jobs:
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
|
||||
- name: Build and push backend image
|
||||
if: steps.check-backend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/backend/Dockerfile
|
||||
@@ -117,18 +116,16 @@ jobs:
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
chmod 600 ../private.key
|
||||
|
||||
|
||||
- name: Deploy to VPS on port 3000
|
||||
run: |
|
||||
export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml
|
||||
|
||||
|
||||
cat > $UNIQUE_NAME << EOF
|
||||
version: '3.3'
|
||||
services:
|
||||
@@ -154,24 +151,24 @@ jobs:
|
||||
SWAGGER_SERVER_URL: "https://demo.stirlingpdf.cloud"
|
||||
baseUrl: "https://demo.stirlingpdf.cloud"
|
||||
restart: on-failure:5
|
||||
|
||||
|
||||
frontend:
|
||||
container_name: stirling-v2-frontend
|
||||
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
|
||||
ports:
|
||||
- "3000:80"
|
||||
environment:
|
||||
VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:13000"
|
||||
VITE_API_BASE_URL: "http://${{ secrets.NEW_VPS_HOST }}:13000"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: on-failure:5
|
||||
EOF
|
||||
|
||||
|
||||
# Copy to remote with unique name
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/$UNIQUE_NAME
|
||||
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/$UNIQUE_NAME
|
||||
|
||||
# SSH and rename/move atomically to avoid interference
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH
|
||||
mkdir -p /stirling/V2/{data,config,logs}
|
||||
mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml
|
||||
cd /stirling/V2
|
||||
@@ -186,4 +183,3 @@ jobs:
|
||||
if: always()
|
||||
run: |
|
||||
rm -f ../private.key
|
||||
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
name: License Report Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
files-changed:
|
||||
name: detect what files changed
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
licenses-frontend: ${{ steps.changes.outputs.licenses-frontend }}
|
||||
licenses-backend: ${{ steps.changes.outputs.licenses-backend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: changes
|
||||
with:
|
||||
filters: .github/config/.files.yaml
|
||||
|
||||
generate-frontend-license-report:
|
||||
if: needs.files-changed.outputs.licenses-frontend == 'true'
|
||||
name: Generate Frontend License Report
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR head (default)
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)) && github.actor != 'dependabot[bot]'
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout BASE branch (safe script)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: base
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
env:
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true"
|
||||
run: npm ci --ignore-scripts --audit=false --fund=false
|
||||
|
||||
- name: Generate frontend license report (internal PR)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
working-directory: frontend
|
||||
env:
|
||||
PR_IS_FORK: "false"
|
||||
run: npm run generate-licenses
|
||||
|
||||
- name: Generate frontend license report (fork PRs, pinned)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
env:
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true"
|
||||
working-directory: frontend
|
||||
run: |
|
||||
mkdir -p src/assets
|
||||
npx --yes license-report --only=prod --output=json > src/assets/3rdPartyLicenses.json
|
||||
|
||||
- name: Postprocess with project script (BASE version)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
env:
|
||||
PR_IS_FORK: "true"
|
||||
run: |
|
||||
node base/frontend/scripts/generate-licenses.js \
|
||||
--input frontend/src/assets/3rdPartyLicenses.json
|
||||
|
||||
- name: Copy postprocessed artifacts back (fork PRs)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
run: |
|
||||
mkdir -p frontend/src/assets
|
||||
if [ -f "base/frontend/src/assets/3rdPartyLicenses.json" ]; then
|
||||
cp base/frontend/src/assets/3rdPartyLicenses.json frontend/src/assets/3rdPartyLicenses.json
|
||||
fi
|
||||
if [ -f "base/frontend/src/assets/license-warnings.json" ]; then
|
||||
cp base/frontend/src/assets/license-warnings.json frontend/src/assets/license-warnings.json
|
||||
fi
|
||||
|
||||
- name: Check for license warnings
|
||||
run: |
|
||||
if [ -f "frontend/src/assets/license-warnings.json" ]; then
|
||||
echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# PR Event: Check licenses and comment on PR
|
||||
- name: Delete previous license check comments
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.issue.number;
|
||||
|
||||
// Get all comments on the PR
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
// Filter for license check comments
|
||||
const licenseComments = comments.filter(comment =>
|
||||
comment.body.includes('## ✅ Frontend License Check Passed') ||
|
||||
comment.body.includes('## ❌ Frontend License Check Failed')
|
||||
);
|
||||
|
||||
// Delete old license check comments
|
||||
for (const comment of licenseComments) {
|
||||
console.log(`Deleting old license check comment: ${comment.id}`);
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: comment.id
|
||||
});
|
||||
}
|
||||
|
||||
- name: Summarize results (fork PRs)
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) || github.actor == 'dependabot[bot]'
|
||||
run: |
|
||||
{
|
||||
echo "## Frontend License Check"
|
||||
echo ""
|
||||
if [ "${LICENSE_WARNINGS_EXIST}" = "true" ]; then
|
||||
echo "❌ **Failed** – incompatible or unknown licenses found."
|
||||
if [ -f "frontend/src/assets/license-warnings.json" ]; then
|
||||
echo ""
|
||||
echo "### Warnings"
|
||||
jq -r '.warnings[] | "- \(.message)"' frontend/src/assets/license-warnings.json || true
|
||||
fi
|
||||
else
|
||||
echo "✅ **Passed** – no license warnings detected."
|
||||
fi
|
||||
echo ""
|
||||
echo "_Note: This is a fork PR. PR comments are disabled; use this summary._"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Comment on PR - License Check Results
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.issue.number;
|
||||
const hasWarnings = process.env.LICENSE_WARNINGS_EXIST === 'true';
|
||||
|
||||
let commentBody;
|
||||
|
||||
if (hasWarnings) {
|
||||
// Read warnings file to get specific issues
|
||||
const fs = require('fs');
|
||||
let warningDetails = '';
|
||||
try {
|
||||
const warnings = JSON.parse(fs.readFileSync('frontend/src/assets/license-warnings.json', 'utf8'));
|
||||
warningDetails = warnings.warnings.map(w => `- ${w.message}`).join('\n');
|
||||
} catch (e) {
|
||||
warningDetails = 'Unable to read warning details';
|
||||
}
|
||||
|
||||
commentBody = `## ❌ Frontend License Check Failed
|
||||
|
||||
The frontend license check has detected compatibility warnings that require review:
|
||||
|
||||
${warningDetails}
|
||||
|
||||
**Action Required:** Please review these licenses to ensure they are acceptable for your use case before merging.
|
||||
|
||||
_This check will fail the PR until license issues are resolved._`;
|
||||
} else {
|
||||
commentBody = `## ✅ Frontend License Check Passed
|
||||
|
||||
All frontend licenses have been validated and no compatibility warnings were detected.
|
||||
|
||||
The frontend license report has been updated successfully.`;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: commentBody
|
||||
});
|
||||
|
||||
- name: Fail workflow if license warnings exist (PR only)
|
||||
if: github.event_name == 'pull_request' && env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
run: |
|
||||
echo "❌ License warnings detected. Failing the workflow."
|
||||
exit 1
|
||||
|
||||
# Push Event: Commit license files and create PR
|
||||
- name: Commit changes (Push only)
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
git add frontend/src/assets/3rdPartyLicenses.json
|
||||
# Note: Do NOT commit license-warnings.json - it's only for PR review
|
||||
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
|
||||
|
||||
- name: Prepare PR body (Push only)
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
PR_BODY="Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
|
||||
|
||||
This PR updates the frontend license report based on changes to package.json dependencies."
|
||||
|
||||
if [ "${{ env.LICENSE_WARNINGS_EXIST }}" = "true" ]; then
|
||||
PR_BODY="$PR_BODY
|
||||
|
||||
## ⚠️ License Compatibility Warnings
|
||||
|
||||
The following licenses may require review for corporate compatibility:
|
||||
|
||||
$(cat frontend/src/assets/license-warnings.json | jq -r '.warnings[].message')
|
||||
|
||||
Please review these licenses to ensure they are acceptable for your use case."
|
||||
fi
|
||||
|
||||
echo "PR_BODY<<EOF" >> $GITHUB_ENV
|
||||
echo "$PR_BODY" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Create Pull Request (Push only)
|
||||
id: cpr
|
||||
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Update Frontend 3rd Party Licenses"
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: update-frontend-3rd-party-licenses
|
||||
base: main
|
||||
title: "Update Frontend 3rd Party Licenses"
|
||||
body: ${{ env.PR_BODY }}
|
||||
labels: Licenses,github-actions,frontend
|
||||
draft: false
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
|
||||
- name: Enable Pull Request Automerge (Push only)
|
||||
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'false'
|
||||
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
- name: Add review required label (Push only)
|
||||
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
run: gh pr edit "${{ steps.cpr.outputs.pull-request-number }}" --add-label "license-review-required"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
generate-backend-license-report:
|
||||
if: needs.files-changed.outputs.licenses-backend == 'true'
|
||||
needs: files-changed
|
||||
name: Generate Backend License Report
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)) && github.actor != 'dependabot[bot]'
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Check licenses and generate report
|
||||
id: license-check
|
||||
run: |
|
||||
./gradlew clean checkLicense generateLicenseReport || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: false
|
||||
STIRLING_PDF_DESKTOP_UI: true
|
||||
|
||||
- name: Check for license compatibility issues
|
||||
run: |
|
||||
if [ -f build/reports/dependency-license/dependencies-without-allowed-license.json ] && \
|
||||
jq '.dependenciesWithoutAllowedLicenses | length > 0' build/reports/dependency-license/dependencies-without-allowed-license.json | grep -q true; then
|
||||
echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
|
||||
fi
|
||||
if: always()
|
||||
|
||||
- name: Upload artifact on license issues
|
||||
if: env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: backend-dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
|
||||
- name: Move license file
|
||||
if: env.LICENSE_CHECK_FAILED != 'true' && env.LICENSE_WARNINGS_EXIST == 'false'
|
||||
run: |
|
||||
mkdir -p app/core/src/main/resources/static
|
||||
cp build/reports/dependency-license/index.json app/core/src/main/resources/static/3rdPartyLicenses.json
|
||||
|
||||
- name: Delete previous backend license check comments
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.issue.number;
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
const backendLicenseComments = comments.filter(comment =>
|
||||
comment.body.includes('## ✅ Backend License Check Passed') ||
|
||||
comment.body.includes('## ❌ Backend License Check Failed')
|
||||
);
|
||||
|
||||
for (const comment of backendLicenseComments) {
|
||||
console.log(`Deleting old backend license comment: ${comment.id}`);
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: comment.id
|
||||
});
|
||||
}
|
||||
|
||||
- name: Comment on PR - Backend License Check Results
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const hasWarnings = process.env.LICENSE_WARNINGS_EXIST === 'true';
|
||||
const fs = require('fs');
|
||||
let warningDetails = '';
|
||||
|
||||
if (hasWarnings) {
|
||||
try {
|
||||
const warningsFile = 'build/reports/dependency-license/dependencies-without-allowed-license.json';
|
||||
if (fs.existsSync(warningsFile)) {
|
||||
const data = JSON.parse(fs.readFileSync(warningsFile, 'utf8'));
|
||||
if (data.length > 0) {
|
||||
warningDetails = data.map(dep => `- **${dep.moduleName}@${dep.moduleVersion}** – ${dep.moduleLicenses.map(l => l.licenseName).join(', ')}`).join('\n');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
warningDetails = 'Unable to parse warning details.';
|
||||
}
|
||||
}
|
||||
|
||||
let commentBody;
|
||||
if (hasWarnings) {
|
||||
commentBody = `## ❌ Backend License Check Failed
|
||||
|
||||
The backend license check has detected dependencies with incompatible or unallowed licenses:
|
||||
|
||||
${warningDetails || 'See uploaded artifact for details.'}
|
||||
|
||||
**Action Required:** Please review these licenses and resolve before merging.
|
||||
|
||||
_This check will fail the PR until license issues are resolved._`;
|
||||
} else {
|
||||
commentBody = `## ✅ Backend License Check Passed
|
||||
|
||||
All backend dependencies have valid and allowed licenses.
|
||||
|
||||
The backend license report has been updated successfully.`;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: commentBody
|
||||
});
|
||||
|
||||
- name: Fail workflow if license warnings exist (PR only)
|
||||
if: github.event_name == 'pull_request' && env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
run: |
|
||||
echo "❌ Backend license warnings detected. Failing the workflow."
|
||||
exit 1
|
||||
|
||||
- name: Commit changes (push only)
|
||||
if: github.event_name == 'push' && env.LICENSE_WARNINGS_EXIST == 'false'
|
||||
run: |
|
||||
git config user.name "${{ steps.setup-bot.outputs.committer }}"
|
||||
git config user.email "${{ steps.setup-bot.outputs.committer-email || 'bot@github.com' }}"
|
||||
git add app/core/src/main/resources/static/3rdPartyLicenses.json
|
||||
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
|
||||
|
||||
- name: Prepare PR body (push only)
|
||||
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
|
||||
run: |
|
||||
PR_BODY="Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
|
||||
|
||||
This PR updates the backend license report based on dependency changes."
|
||||
|
||||
if [ "${{ env.LICENSE_WARNINGS_EXIST }}" = "true" ]; then
|
||||
PR_BODY="$PR_BODY
|
||||
|
||||
## ⚠️ License Compatibility Warnings
|
||||
|
||||
Incompatible licenses detected – manual review required before merge."
|
||||
fi
|
||||
echo "PR_BODY<<EOF" >> $GITHUB_ENV
|
||||
echo "$PR_BODY" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Create Pull Request (push only)
|
||||
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Update Backend 3rd Party Licenses"
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: update-backend-3rd-party-licenses
|
||||
base: main
|
||||
title: "Update Backend 3rd Party Licenses"
|
||||
body: ${{ env.PR_BODY }}
|
||||
labels: Licenses,github-actions,backend
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
|
||||
- name: Enable Pull Request Automerge (push only, no warnings)
|
||||
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'false'
|
||||
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
- name: Add review required label (push only, with warnings)
|
||||
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
run: gh pr edit "${{ steps.cpr.outputs.pull-request-number }}" --add-label "license-review-required"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
@@ -1,282 +0,0 @@
|
||||
name: Frontend License Report Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- V2
|
||||
paths:
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/scripts/generate-licenses.js"
|
||||
pull_request:
|
||||
branches:
|
||||
- V2
|
||||
paths:
|
||||
- ".github/workflows/frontend-licenses-update.yml"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/scripts/generate-licenses.js"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
generate-frontend-license-report:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR head (default)
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout BASE branch (safe script)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: base
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
env:
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true"
|
||||
run: npm ci --ignore-scripts --audit=false --fund=false
|
||||
|
||||
- name: Generate frontend license report (internal PR)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
working-directory: frontend
|
||||
env:
|
||||
PR_IS_FORK: "false"
|
||||
run: npm run generate-licenses
|
||||
|
||||
- name: Generate frontend license report (fork PRs, pinned)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
env:
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true"
|
||||
working-directory: frontend
|
||||
run: |
|
||||
mkdir -p src/assets
|
||||
npx --yes license-report --only=prod --output=json > src/assets/3rdPartyLicenses.json
|
||||
|
||||
- name: Postprocess with project script (BASE version)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
env:
|
||||
PR_IS_FORK: "true"
|
||||
run: |
|
||||
node base/frontend/scripts/generate-licenses.js \
|
||||
--input frontend/src/assets/3rdPartyLicenses.json
|
||||
|
||||
- name: Copy postprocessed artifacts back (fork PRs)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
run: |
|
||||
mkdir -p frontend/src/assets
|
||||
if [ -f "base/frontend/src/assets/3rdPartyLicenses.json" ]; then
|
||||
cp base/frontend/src/assets/3rdPartyLicenses.json frontend/src/assets/3rdPartyLicenses.json
|
||||
fi
|
||||
if [ -f "base/frontend/src/assets/license-warnings.json" ]; then
|
||||
cp base/frontend/src/assets/license-warnings.json frontend/src/assets/license-warnings.json
|
||||
fi
|
||||
|
||||
- name: Check for license warnings
|
||||
run: |
|
||||
if [ -f "frontend/src/assets/license-warnings.json" ]; then
|
||||
echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# PR Event: Check licenses and comment on PR
|
||||
- name: Delete previous license check comments
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.issue.number;
|
||||
|
||||
// Get all comments on the PR
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
// Filter for license check comments
|
||||
const licenseComments = comments.filter(comment =>
|
||||
comment.body.includes('## ✅ Frontend License Check Passed') ||
|
||||
comment.body.includes('## ❌ Frontend License Check Failed')
|
||||
);
|
||||
|
||||
// Delete old license check comments
|
||||
for (const comment of licenseComments) {
|
||||
console.log(`Deleting old license check comment: ${comment.id}`);
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: comment.id
|
||||
});
|
||||
}
|
||||
|
||||
- name: Summarize results (fork PRs)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
run: |
|
||||
{
|
||||
echo "## Frontend License Check"
|
||||
echo ""
|
||||
if [ "${LICENSE_WARNINGS_EXIST}" = "true" ]; then
|
||||
echo "❌ **Failed** – incompatible or unknown licenses found."
|
||||
if [ -f "frontend/src/assets/license-warnings.json" ]; then
|
||||
echo ""
|
||||
echo "### Warnings"
|
||||
jq -r '.warnings[] | "- \(.message)"' frontend/src/assets/license-warnings.json || true
|
||||
fi
|
||||
else
|
||||
echo "✅ **Passed** – no license warnings detected."
|
||||
fi
|
||||
echo ""
|
||||
echo "_Note: This is a fork PR. PR comments are disabled; use this summary._"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Comment on PR - License Check Results
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.issue.number;
|
||||
const hasWarnings = process.env.LICENSE_WARNINGS_EXIST === 'true';
|
||||
|
||||
let commentBody;
|
||||
|
||||
if (hasWarnings) {
|
||||
// Read warnings file to get specific issues
|
||||
const fs = require('fs');
|
||||
let warningDetails = '';
|
||||
try {
|
||||
const warnings = JSON.parse(fs.readFileSync('frontend/src/assets/license-warnings.json', 'utf8'));
|
||||
warningDetails = warnings.warnings.map(w => `- ${w.message}`).join('\n');
|
||||
} catch (e) {
|
||||
warningDetails = 'Unable to read warning details';
|
||||
}
|
||||
|
||||
commentBody = `## ❌ Frontend License Check Failed
|
||||
|
||||
The frontend license check has detected compatibility warnings that require review:
|
||||
|
||||
${warningDetails}
|
||||
|
||||
**Action Required:** Please review these licenses to ensure they are acceptable for your use case before merging.
|
||||
|
||||
_This check will fail the PR until license issues are resolved._`;
|
||||
} else {
|
||||
commentBody = `## ✅ Frontend License Check Passed
|
||||
|
||||
All frontend licenses have been validated and no compatibility warnings were detected.
|
||||
|
||||
The frontend license report has been updated successfully.`;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: commentBody
|
||||
});
|
||||
|
||||
- name: Fail workflow if license warnings exist (PR only)
|
||||
if: github.event_name == 'pull_request' && env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
run: |
|
||||
echo "❌ License warnings detected. Failing the workflow."
|
||||
exit 1
|
||||
|
||||
# Push Event: Commit license files and create PR
|
||||
- name: Commit changes (Push only)
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
git add frontend/src/assets/3rdPartyLicenses.json
|
||||
# Note: Do NOT commit license-warnings.json - it's only for PR review
|
||||
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
|
||||
|
||||
- name: Prepare PR body (Push only)
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
PR_BODY="Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
|
||||
|
||||
This PR updates the frontend license report based on changes to package.json dependencies."
|
||||
|
||||
if [ "${{ env.LICENSE_WARNINGS_EXIST }}" = "true" ]; then
|
||||
PR_BODY="$PR_BODY
|
||||
|
||||
## ⚠️ License Compatibility Warnings
|
||||
|
||||
The following licenses may require review for corporate compatibility:
|
||||
|
||||
$(cat frontend/src/assets/license-warnings.json | jq -r '.warnings[].message')
|
||||
|
||||
Please review these licenses to ensure they are acceptable for your use case."
|
||||
fi
|
||||
|
||||
echo "PR_BODY<<EOF" >> $GITHUB_ENV
|
||||
echo "$PR_BODY" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Create Pull Request (Push only)
|
||||
id: cpr
|
||||
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Update Frontend 3rd Party Licenses"
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: update-frontend-3rd-party-licenses
|
||||
base: V2
|
||||
title: "Update Frontend 3rd Party Licenses"
|
||||
body: ${{ env.PR_BODY }}
|
||||
labels: Licenses,github-actions,frontend
|
||||
draft: false
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
|
||||
- name: Enable Pull Request Automerge (Push only)
|
||||
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'false'
|
||||
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
- name: Add review required label (Push only)
|
||||
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
run: gh pr edit "${{ steps.cpr.outputs.pull-request-number }}" --add-label "license-review-required"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
@@ -1,105 +0,0 @@
|
||||
name: License Report Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "build.gradle"
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
# or a pull request is updated.
|
||||
# It helps to save resources and time by ensuring that only the latest commit is built and tested
|
||||
# This is particularly useful for long-running jobs that may take a while to complete.
|
||||
# The `group` is set to a combination of the workflow name, event name, and branch name.
|
||||
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
|
||||
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
generate-license-report:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Check licenses for compatibility
|
||||
run: ./gradlew clean checkLicense
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: false
|
||||
STIRLING_PDF_DESKTOP_UI: true
|
||||
|
||||
- name: Upload artifact on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
retention-days: 3
|
||||
|
||||
- name: Move and rename license file
|
||||
run: |
|
||||
mv build/reports/dependency-license/index.json app/core/src/main/resources/static/3rdPartyLicenses.json
|
||||
|
||||
- name: Commit changes
|
||||
run: |
|
||||
git add app/core/src/main/resources/static/3rdPartyLicenses.json
|
||||
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
if: env.CHANGES_DETECTED == 'true'
|
||||
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Update 3rd Party Licenses"
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: update-3rd-party-licenses
|
||||
title: "Update 3rd Party Licenses"
|
||||
body: |
|
||||
Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
|
||||
labels: Licenses,github-actions
|
||||
draft: false
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
|
||||
- name: Enable Pull Request Automerge
|
||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
||||
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ on:
|
||||
- macos
|
||||
- linux
|
||||
push:
|
||||
branches: [main, V2, V2-demo, V2-master]
|
||||
branches: [main, V2-master]
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
file_suffix: "-server"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -126,8 +126,8 @@ jobs:
|
||||
if: matrix.variant.build_frontend == true
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Build JAR
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -178,8 +178,8 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
@@ -268,7 +268,7 @@ jobs:
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
# 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@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -562,7 +562,7 @@ jobs:
|
||||
run: ls -R ./artifacts
|
||||
|
||||
- name: Upload binaries to Release
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
with:
|
||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -38,10 +38,10 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: 'pip' # caching pip dependencies
|
||||
cache: "pip" # caching pip dependencies
|
||||
cache-dependency-path: ./.github/scripts/requirements_pre_commit.txt
|
||||
|
||||
- name: Run Pre-Commit Hooks
|
||||
@@ -51,10 +51,10 @@ jobs:
|
||||
- run: pre-commit run --all-files -c .pre-commit-config.yaml
|
||||
continue-on-error: true
|
||||
|
||||
- name: Set up JDK
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Build with Gradle
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
if: env.CHANGES_DETECTED == 'true'
|
||||
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: ":file_folder: pre-commit"
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
name: Push Docker Image - V2 Branch
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- V2-master
|
||||
- V1_V2_merge
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
# or a pull request is updated.
|
||||
# It helps to save resources and time by ensuring that only the latest commit is built and tested
|
||||
# This is particularly useful for long-running jobs that may take a while to complete.
|
||||
# The `group` is set to a combination of the workflow name, event name, and branch name.
|
||||
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
|
||||
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
push:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-24.04-8core
|
||||
permissions:
|
||||
packages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate tags for latest (V2-master branch - production)
|
||||
id: meta
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Generate tags for latest (V1_V2_merge branch - test)
|
||||
id: meta-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V1_V2_merge'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Unified Dockerfile (latest variant)
|
||||
id: build-push-latest
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta.outputs.tags || steps.meta-test.outputs.tags }}
|
||||
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta.outputs.labels || steps.meta-test.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign regular images
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-latest.outputs.digest }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --yes \
|
||||
--key env://COSIGN_PRIVATE_KEY \
|
||||
"${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags for latest-fat (V2-master branch - production)
|
||||
id: meta-fat
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
|
||||
type=raw,value=latest-fat
|
||||
|
||||
- name: Generate tags for latest-fat (V1_V2_merge branch - test)
|
||||
id: meta-fat-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V1_V2_merge'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
|
||||
type=raw,value=latest-fat
|
||||
|
||||
- name: Build and push Unified Dockerfile (fat variant)
|
||||
id: build-push-fat
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile.fat
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-fat.outputs.tags || steps.meta-fat-test.outputs.tags }}
|
||||
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-fat.outputs.labels || steps.meta-fat-test.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign fat images
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-fat.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags for ultra-lite (V2-master branch - production)
|
||||
id: meta-lite
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
|
||||
type=raw,value=latest-ultra-lite
|
||||
|
||||
- name: Generate tags for ultra-lite (V1_V2_merge branch - test)
|
||||
id: meta-lite-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V1_V2_merge'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
|
||||
type=raw,value=latest-ultra-lite
|
||||
|
||||
- name: Build and push Unified Dockerfile (ultra-lite variant)
|
||||
id: build-push-lite
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile.ultra-lite
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-lite.outputs.tags || steps.meta-lite-test.outputs.tags }}
|
||||
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-lite.outputs.labels || steps.meta-lite-test.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign ultra-lite images
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-lite.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
@@ -6,6 +6,8 @@ on:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
- V2-master
|
||||
- testMain
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
@@ -25,48 +27,42 @@ permissions:
|
||||
jobs:
|
||||
push:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04-8core
|
||||
permissions:
|
||||
packages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up JDK 17
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Run Gradle Command
|
||||
run: ./gradlew clean build
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/master'
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- 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:
|
||||
@@ -87,10 +83,9 @@ jobs:
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate tags
|
||||
- name: Generate tags for latest
|
||||
id: meta
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
@@ -98,13 +93,13 @@ jobs:
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/testMain' }}
|
||||
|
||||
- name: Build and push main Dockerfile
|
||||
id: build-push-regular
|
||||
- name: Build and push Unified Dockerfile (latest variant)
|
||||
id: build-push-latest
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
@@ -120,9 +115,9 @@ jobs:
|
||||
sbom: true
|
||||
|
||||
- name: Sign regular images
|
||||
if: github.ref == 'refs/heads/master'
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-regular.outputs.digest }}
|
||||
DIGEST: ${{ steps.build-push-latest.outputs.digest }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
@@ -133,10 +128,10 @@ jobs:
|
||||
"${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags ultra-lite
|
||||
id: meta2
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
- name: Generate tags for latest-fat
|
||||
id: meta-fat
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
@@ -144,43 +139,13 @@ jobs:
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
|
||||
- name: Build and push Dockerfile-ultra-lite
|
||||
id: build-push-lite
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile.ultra-lite
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ steps.meta2.outputs.tags }}
|
||||
labels: ${{ steps.meta2.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Generate tags fat
|
||||
id: meta3
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push main Dockerfile fat
|
||||
- name: Build and push Unified Dockerfile (fat variant)
|
||||
id: build-push-fat
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
@@ -188,18 +153,62 @@ jobs:
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ steps.meta3.outputs.tags }}
|
||||
labels: ${{ steps.meta3.outputs.labels }}
|
||||
tags: ${{ steps.meta-fat.outputs.tags }}
|
||||
labels: ${{ steps.meta-fat.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign fat images
|
||||
if: github.ref == 'refs/heads/master'
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
|
||||
TAGS: ${{ steps.meta3.outputs.tags }}
|
||||
TAGS: ${{ steps.meta-fat.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags for ultra-lite
|
||||
id: meta-lite
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
|
||||
- name: Build and push Unified Dockerfile (ultra-lite variant)
|
||||
id: build-push-lite
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile.ultra-lite
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ steps.meta-lite.outputs.tags }}
|
||||
labels: ${{ steps.meta-lite.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign ultra-lite images
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-lite.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
|
||||
@@ -1,535 +0,0 @@
|
||||
name: Release Artifacts
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platform:
|
||||
description: "Platform to build (windows, macos, linux, or all)"
|
||||
required: true
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- windows
|
||||
- macos
|
||||
- linux
|
||||
push:
|
||||
branches: [main, V2, V2-demo]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
determine-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: |
|
||||
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Determine build matrix
|
||||
id: set-matrix
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
case "${{ github.event.inputs.platform }}" in
|
||||
"windows")
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"macos")
|
||||
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"linux")
|
||||
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
*)
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# For push events, build all platforms
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: determine-matrix
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Build Java backend with JLink
|
||||
working-directory: ./
|
||||
shell: bash
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
echo "🔧 Building Stirling-PDF JAR..."
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Find the built JAR
|
||||
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
|
||||
echo "✅ Built JAR: $STIRLING_JAR"
|
||||
|
||||
# Create Tauri directories
|
||||
mkdir -p ./frontend/src-tauri/libs
|
||||
mkdir -p ./frontend/src-tauri/runtime
|
||||
|
||||
# Copy JAR to Tauri libs
|
||||
cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
|
||||
echo "✅ JAR copied to Tauri libs"
|
||||
|
||||
# Analyze JAR dependencies for jlink modules
|
||||
echo "🔍 Analyzing JAR dependencies..."
|
||||
if command -v jdeps &> /dev/null; then
|
||||
DETECTED_MODULES=$(jdeps --print-module-deps --ignore-missing-deps "$STIRLING_JAR" 2>/dev/null || echo "")
|
||||
if [ -n "$DETECTED_MODULES" ]; then
|
||||
echo "📋 jdeps detected modules: $DETECTED_MODULES"
|
||||
MODULES="$DETECTED_MODULES,java.compiler,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
else
|
||||
echo "⚠️ jdeps analysis failed, using predefined modules"
|
||||
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ jdeps not available, using predefined modules"
|
||||
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
fi
|
||||
|
||||
# Create custom JRE with jlink
|
||||
echo "🔧 Creating custom JRE with jlink..."
|
||||
echo "📋 Using modules: $MODULES"
|
||||
|
||||
# Remove any existing JRE
|
||||
rm -rf ./frontend/src-tauri/runtime/jre
|
||||
|
||||
# Create the custom JRE
|
||||
jlink \
|
||||
--add-modules "$MODULES" \
|
||||
--strip-debug \
|
||||
--compress=2 \
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output ./frontend/src-tauri/runtime/jre
|
||||
|
||||
if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
|
||||
echo "❌ Failed to create JLink runtime"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test the bundled runtime
|
||||
if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then
|
||||
RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
|
||||
echo "✅ Custom JRE created successfully: $RUNTIME_VERSION"
|
||||
else
|
||||
echo "❌ Custom JRE executable not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Calculate runtime size
|
||||
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
|
||||
echo "📊 Custom JRE size: $RUNTIME_SIZE"
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm install
|
||||
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
uses: digicert/ssm-code-signing@v1.1.0
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
|
||||
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
|
||||
- name: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
|
||||
# Decode client certificate
|
||||
$certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}")
|
||||
$certPath = "D:\Certificate_pkcs12.p12"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Set environment variables
|
||||
echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
|
||||
echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV
|
||||
echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV
|
||||
echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV
|
||||
echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV
|
||||
|
||||
# Get PKCS11 config path from DigiCert action
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if ($pkcs11Config) {
|
||||
Write-Host "Found PKCS11_CONFIG: $pkcs11Config"
|
||||
echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV
|
||||
} else {
|
||||
Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path"
|
||||
$defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg"
|
||||
if (Test-Path $defaultPath) {
|
||||
Write-Host "Found config at default path: $defaultPath"
|
||||
echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV
|
||||
} else {
|
||||
Write-Host "Warning: Could not find PKCS11 config file"
|
||||
}
|
||||
}
|
||||
|
||||
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
|
||||
- name: Import Windows Code Signing Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
shell: powershell
|
||||
run: |
|
||||
if ($env:WINDOWS_CERTIFICATE) {
|
||||
Write-Host "Importing Windows Code Signing Certificate..."
|
||||
|
||||
# Decode base64 certificate and save to file
|
||||
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
|
||||
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Import certificate to CurrentUser\My store
|
||||
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
|
||||
|
||||
# Extract and set thumbprint as environment variable
|
||||
$thumbprint = $cert.Thumbprint
|
||||
Write-Host "Certificate imported with thumbprint: $thumbprint"
|
||||
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
|
||||
|
||||
# Clean up certificate file
|
||||
Remove-Item $certPath
|
||||
|
||||
Write-Host "Windows certificate import completed."
|
||||
} else {
|
||||
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
echo "Importing Apple Developer Certificate..."
|
||||
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
|
||||
# Create temporary keychain
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
# Import certificate
|
||||
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
# Clean up
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application")
|
||||
echo "Certificate Info: $CERT_INFO"
|
||||
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
|
||||
echo "Certificate ID: $CERT_ID"
|
||||
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
|
||||
echo "Certificate imported successfully."
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
|
||||
# Only enable Windows signing in Tauri when on main
|
||||
SIGN: ${{ github.ref == 'refs/heads/main' && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
CI: true
|
||||
with:
|
||||
projectPath: ./frontend
|
||||
tauriScript: npx tauri
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
# Sign with DigiCert KeyLocker (post-build)
|
||||
- name: Sign Windows binaries with DigiCert KeyLocker
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "=== DigiCert KeyLocker Signing ==="
|
||||
|
||||
# Test smctl connectivity first
|
||||
Write-Host "Testing smctl connection..."
|
||||
$healthCheck = & smctl healthcheck 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "[SUCCESS] Connected to DigiCert KeyLocker"
|
||||
} else {
|
||||
Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker"
|
||||
Write-Host $healthCheck
|
||||
exit 1
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# Sync certificates to Windows certificate store
|
||||
Write-Host "Syncing certificates to Windows certificate store..."
|
||||
$syncOutput = & smctl windows certsync 2>&1
|
||||
Write-Host "Cert sync result: $syncOutput"
|
||||
Write-Host ""
|
||||
|
||||
# Find only the files we need to sign (not build scripts)
|
||||
$filesToSign = @()
|
||||
|
||||
# Main application executable
|
||||
$mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue
|
||||
if ($mainExe) { $filesToSign += $mainExe }
|
||||
|
||||
# MSI installer
|
||||
$msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
|
||||
$filesToSign += $msiFiles
|
||||
|
||||
if ($filesToSign.Count -eq 0) {
|
||||
Write-Host "[ERROR] No files found to sign"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($filesToSign.Count) files to sign:"
|
||||
foreach ($f in $filesToSign) { Write-Host " - $($f.Name)" }
|
||||
Write-Host ""
|
||||
|
||||
$signedCount = 0
|
||||
foreach ($file in $filesToSign) {
|
||||
Write-Host "Signing: $($file.Name)"
|
||||
|
||||
# Get PKCS11 config file path (set by DigiCert action)
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if (-not $pkcs11Config) {
|
||||
Write-Host "[ERROR] PKCS11_CONFIG environment variable not set"
|
||||
Write-Host "DigiCert KeyLocker action may not have run correctly"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Using PKCS11 config: $pkcs11Config"
|
||||
|
||||
# Try signing with certificate fingerprint first (if available)
|
||||
$fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}"
|
||||
if ($fingerprint -and $fingerprint -ne "") {
|
||||
Write-Host "Attempting to sign with certificate fingerprint..."
|
||||
$output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
} else {
|
||||
Write-Host "No fingerprint provided, using keypair alias..."
|
||||
# Use smctl to sign with keypair alias
|
||||
$output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
}
|
||||
|
||||
Write-Host "Exit code: $exitCode"
|
||||
Write-Host "Output: $output"
|
||||
|
||||
# Check if output contains "FAILED" even with exit code 0
|
||||
if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") {
|
||||
Write-Host ""
|
||||
Write-Host "[ERROR] Signing failed for $($file.Name)"
|
||||
Write-Host "[ERROR] smctl returned success but output indicates failure"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Host "[ERROR] Failed to sign $($file.Name)"
|
||||
Write-Host "Full error output:"
|
||||
Write-Host $output
|
||||
exit 1
|
||||
}
|
||||
|
||||
$signedCount++
|
||||
Write-Host "[SUCCESS] Signed: $($file.Name)"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
Write-Host "=== Summary ==="
|
||||
Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully"
|
||||
|
||||
- name: Rename artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ./dist
|
||||
cd ./frontend/src-tauri/target
|
||||
|
||||
# Find and rename artifacts based on platform
|
||||
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
|
||||
find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \;
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
|
||||
else
|
||||
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
|
||||
fi
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
retention-days: 30
|
||||
|
||||
sign_verify:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: success()
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
name: [windows-x86_64, macos-aarch64, macos-x86_64, linux-x86_64]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
|
||||
- name: Generate key pair
|
||||
run: cosign generate-key-pair
|
||||
|
||||
- name: Sign and generate attestations
|
||||
shell: bash
|
||||
run: |
|
||||
# Sign all artifacts for this platform
|
||||
for file in *; do
|
||||
if [ -f "$file" ] && [[ ! "$file" =~ \.(sig|intoto\.jsonl)$ ]]; then
|
||||
echo "Signing: $file"
|
||||
|
||||
# Sign the artifact
|
||||
cosign sign-blob \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-signature "${file}.sig" \
|
||||
"$file"
|
||||
|
||||
# Generate attestation
|
||||
cosign attest-blob \
|
||||
--predicate - \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-attestation "${file}.intoto.jsonl" \
|
||||
"$file"
|
||||
|
||||
# Verify the signature
|
||||
cosign verify-blob \
|
||||
--key ./cosign.pub \
|
||||
--signature "${file}.sig" \
|
||||
"$file"
|
||||
|
||||
echo "✅ Signed and verified: $file"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Upload signed artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}-signed
|
||||
path: |
|
||||
*
|
||||
!cosign.key
|
||||
!cosign.pub
|
||||
retention-days: 30
|
||||
|
||||
release:
|
||||
needs: [determine-matrix, build, sign_verify]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download all signed artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
pattern: Stirling-PDF-*-signed
|
||||
path: ./artifacts
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R ./artifacts
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
with:
|
||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||
generate_release_notes: true
|
||||
files: ./artifacts/**/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
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@fdbfb4d2750291e159f0156def62b853c2798ca2 # v3.29.5
|
||||
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v3.29.5
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -17,12 +17,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: 30 days stale issues
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 30
|
||||
|
||||
@@ -27,16 +27,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up JDK 17
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
@@ -5,7 +5,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- syncLangTest
|
||||
paths:
|
||||
- "build.gradle"
|
||||
- "README.md"
|
||||
@@ -33,7 +32,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -47,14 +46,13 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
pip install tomli-w
|
||||
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
|
||||
|
||||
- name: Sync translation TOML files
|
||||
run: |
|
||||
@@ -65,9 +63,6 @@ jobs:
|
||||
git add frontend/public/locales/*/translation.toml
|
||||
git diff --staged --quiet || git commit -m ":memo: Sync translation files (TOML)" || echo "No changes detected"
|
||||
|
||||
- name: Install README dependencies
|
||||
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
|
||||
|
||||
- name: Sync README.md
|
||||
run: |
|
||||
python scripts/counter_translation_v3.py
|
||||
@@ -79,7 +74,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
if: always()
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: Update files
|
||||
@@ -121,4 +116,4 @@ jobs:
|
||||
add-paths: |
|
||||
README.md
|
||||
frontend/public/locales/*/translation.toml
|
||||
scripts/ignore_translation.toml
|
||||
scripts/ignore_translation.toml
|
||||
|
||||
@@ -14,14 +14,14 @@ on:
|
||||
- macos
|
||||
- linux
|
||||
pull_request:
|
||||
branches: [main, V2, V2-tauri-windows]
|
||||
branches: [main, V2-tauri-windows]
|
||||
paths:
|
||||
- 'frontend/src-tauri/**'
|
||||
- 'frontend/src/desktop/**'
|
||||
- 'frontend/tsconfig.desktop.json'
|
||||
- '.github/workflows/tauri-build.yml'
|
||||
- "frontend/src-tauri/**"
|
||||
- "frontend/src/desktop/**"
|
||||
- "frontend/tsconfig.desktop.json"
|
||||
- ".github/workflows/tauri-build.yml"
|
||||
push:
|
||||
branches: [main, V2]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -83,8 +83,8 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
@@ -111,15 +111,15 @@ jobs:
|
||||
# Find the built JAR
|
||||
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
|
||||
echo "✅ Built JAR: $STIRLING_JAR"
|
||||
|
||||
|
||||
# Create Tauri directories
|
||||
mkdir -p ./frontend/src-tauri/libs
|
||||
mkdir -p ./frontend/src-tauri/runtime
|
||||
|
||||
|
||||
# Copy JAR to Tauri libs
|
||||
cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
|
||||
echo "✅ JAR copied to Tauri libs"
|
||||
|
||||
|
||||
# Analyze JAR dependencies for jlink modules
|
||||
echo "🔍 Analyzing JAR dependencies..."
|
||||
if command -v jdeps &> /dev/null; then
|
||||
@@ -135,14 +135,14 @@ jobs:
|
||||
echo "⚠️ jdeps not available, using predefined modules"
|
||||
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
fi
|
||||
|
||||
|
||||
# Create custom JRE with jlink (always rebuild)
|
||||
echo "🔧 Creating custom JRE with jlink..."
|
||||
echo "📋 Using modules: $MODULES"
|
||||
|
||||
|
||||
# Remove any existing JRE
|
||||
rm -rf ./frontend/src-tauri/runtime/jre
|
||||
|
||||
|
||||
# Create the custom JRE
|
||||
jlink \
|
||||
--add-modules "$MODULES" \
|
||||
@@ -151,12 +151,12 @@ jobs:
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output ./frontend/src-tauri/runtime/jre
|
||||
|
||||
|
||||
if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
|
||||
echo "❌ Failed to create JLink runtime"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Test the bundled runtime
|
||||
if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then
|
||||
RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
echo "❌ Custom JRE executable not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Calculate runtime size
|
||||
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
|
||||
echo "📊 Custom JRE size: $RUNTIME_SIZE"
|
||||
@@ -174,7 +174,7 @@ jobs:
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
@@ -507,7 +507,6 @@ jobs:
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
|
||||
else
|
||||
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
|
||||
@@ -579,7 +578,7 @@ jobs:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
retention-days: 7
|
||||
|
||||
|
||||
- name: Verify build artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -595,8 +594,8 @@ jobs:
|
||||
fi
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
echo "Checking for macOS artifacts..."
|
||||
find . -name "*.dmg" -o -name "*.app" | head -5
|
||||
if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then
|
||||
find . -name "*.dmg" | head -5
|
||||
if [ $(find . -name "*.dmg" | wc -l) -eq 0 ]; then
|
||||
echo "❌ No macOS artifacts found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -25,18 +25,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up JDK
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Deploy to VPS
|
||||
@@ -107,9 +107,9 @@ jobs:
|
||||
restart: on-failure:5
|
||||
EOF
|
||||
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose.yml
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose.yml
|
||||
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << EOF
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << EOF
|
||||
mkdir -p /stirling/test-${{ github.sha }}/{data,config,logs}
|
||||
mv /tmp/docker-compose.yml /stirling/test-${{ github.sha }}/docker-compose.yml
|
||||
cd /stirling/test-${{ github.sha }}
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Run TestDriver.ai
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
npm install
|
||||
npm run build
|
||||
npm install dashcam-chrome --save
|
||||
Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.VPS_HOST }}:1337"
|
||||
Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.NEW_VPS_HOST }}:1337"
|
||||
Start-Sleep -Seconds 20
|
||||
prompt: |
|
||||
1. /run testing/testdriver/test.yml
|
||||
@@ -176,20 +176,20 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Cleanup deployment
|
||||
if: always()
|
||||
run: |
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << EOF
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << EOF
|
||||
cd /stirling/test-${{ github.sha }}
|
||||
docker-compose down
|
||||
cd /stirling
|
||||
|
||||
@@ -43,6 +43,8 @@ app/core/src/main/resources/static/og_images/
|
||||
app/core/src/main/resources/static/samples/
|
||||
app/core/src/main/resources/static/manifest-classic.json
|
||||
app/core/src/main/resources/static/robots.txt
|
||||
app/core/src/main/resources/static/pdfium/
|
||||
app/core/src/main/resources/static/vendor/
|
||||
# Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc.
|
||||
|
||||
# Gradle
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.2
|
||||
rev: v0.14.8
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
@@ -22,7 +22,7 @@ repos:
|
||||
files: \.(html|css|js|py|md)$
|
||||
exclude: (.vscode|.devcontainer|app/core/src/main/resources|app/proprietary/src/main/resources|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.28.0
|
||||
rev: v8.30.0
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
|
||||
@@ -96,6 +96,22 @@
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "MPL 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Mozilla Public License, Version 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "CDDL+GPL License"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "BSD"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "UnboundID SCIM2 SDK Free Use License"
|
||||
|
||||
@@ -37,12 +37,16 @@ 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'
|
||||
}
|
||||
|
||||
@@ -156,19 +156,19 @@ public class EndpointConfiguration {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rule 2: For tool groups, they're enabled unless explicitly disabled (handled above)
|
||||
if (isToolGroup(group)) {
|
||||
log.debug("isGroupEnabled('{}') -> true (tool group not disabled)", group);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Rule 3: For functional groups, check if all endpoints are enabled
|
||||
Set<String> endpoints = endpointGroups.get(group);
|
||||
if (endpoints == null || endpoints.isEmpty()) {
|
||||
log.debug("isGroupEnabled('{}') -> false (no endpoints)", group);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rule 2: For functional groups, check if all endpoints are enabled
|
||||
// Rule 3: For tool groups, they're enabled unless explicitly disabled (handled above)
|
||||
if (isToolGroup(group)) {
|
||||
log.debug("isGroupEnabled('{}') -> true (tool group not disabled)", group);
|
||||
return true;
|
||||
}
|
||||
|
||||
// For functional groups, check each endpoint individually
|
||||
for (String endpoint : endpoints) {
|
||||
if (!isEndpointEnabledDirectly(endpoint)) {
|
||||
@@ -353,6 +353,7 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Security", "auto-redact");
|
||||
addEndpointToGroup("Security", "redact");
|
||||
addEndpointToGroup("Security", "validate-signature");
|
||||
addEndpointToGroup("Security", "verify-pdf");
|
||||
addEndpointToGroup("Security", "stamp");
|
||||
addEndpointToGroup("Security", "sign");
|
||||
|
||||
@@ -479,6 +480,8 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "pdf-to-json");
|
||||
addEndpointToGroup("Java", "json-to-pdf");
|
||||
addEndpointToGroup("rar", "pdf-to-cbr");
|
||||
addEndpointToGroup("Java", "pdf-to-video");
|
||||
addEndpointToGroup("Java", "verify-pdf");
|
||||
|
||||
// Javascript
|
||||
addEndpointToGroup("Javascript", "pdf-organizer");
|
||||
@@ -535,6 +538,9 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Weasyprint", "markdown-to-pdf");
|
||||
addEndpointToGroup("Weasyprint", "eml-to-pdf");
|
||||
|
||||
// veraPDF dependent endpoints
|
||||
addEndpointToGroup("veraPDF", "verify-pdf");
|
||||
|
||||
// Pdftohtml dependent endpoints
|
||||
addEndpointToGroup("Pdftohtml", "pdf-to-html");
|
||||
addEndpointToGroup("Pdftohtml", "pdf-to-markdown");
|
||||
@@ -585,7 +591,10 @@ public class EndpointConfiguration {
|
||||
|| "Weasyprint".equals(group)
|
||||
|| "Pdftohtml".equals(group)
|
||||
|| "ImageMagick".equals(group)
|
||||
|| "rar".equals(group);
|
||||
|| "rar".equals(group)
|
||||
|| "Calibre".equals(group)
|
||||
|| "FFmpeg".equals(group)
|
||||
|| "veraPDF".equals(group);
|
||||
}
|
||||
|
||||
private boolean isEndpointEnabledDirectly(String endpoint) {
|
||||
|
||||
+11
-8
@@ -94,16 +94,19 @@ public class RuntimePathConfig {
|
||||
defaultSOfficePath, operations != null ? operations.getSoffice() : null);
|
||||
|
||||
// Initialize Tesseract data path
|
||||
String defaultTessDataPath =
|
||||
isDocker ? "/usr/share/tesseract-ocr/5/tessdata" : "/usr/share/tessdata";
|
||||
|
||||
// Priority: config setting > TESSDATA_PREFIX env var > default path
|
||||
String tessPath = system.getTessdataDir();
|
||||
String tessdataDir = java.lang.System.getenv("TESSDATA_PREFIX");
|
||||
String tessdataPrefix = java.lang.System.getenv("TESSDATA_PREFIX");
|
||||
String defaultPath = "/usr/share/tesseract-ocr/5/tessdata";
|
||||
|
||||
if (tessPath != null && !tessPath.isEmpty()) {
|
||||
this.tessDataPath = tessPath;
|
||||
} else if (tessdataPrefix != null && !tessdataPrefix.isEmpty()) {
|
||||
this.tessDataPath = tessdataPrefix;
|
||||
} else {
|
||||
this.tessDataPath = defaultPath;
|
||||
}
|
||||
|
||||
this.tessDataPath =
|
||||
resolvePath(
|
||||
defaultTessDataPath,
|
||||
(tessPath != null && !tessPath.isEmpty()) ? tessPath : tessdataDir);
|
||||
log.info("Using Tesseract data path: {}", this.tessDataPath);
|
||||
}
|
||||
|
||||
|
||||
+120
-4
@@ -61,6 +61,7 @@ public class ApplicationProperties {
|
||||
private AutomaticallyGenerated automaticallyGenerated = new AutomaticallyGenerated();
|
||||
|
||||
private Mail mail = new Mail();
|
||||
private Telegram telegram = new Telegram();
|
||||
|
||||
private Premium premium = new Premium();
|
||||
|
||||
@@ -393,6 +394,7 @@ public class ApplicationProperties {
|
||||
private boolean googlevisibility;
|
||||
private boolean showUpdate;
|
||||
private boolean showUpdateOnlyAdmin;
|
||||
private boolean showSettingsWhenNoLogin = true;
|
||||
private boolean customHTMLFiles;
|
||||
private String tessdataDir;
|
||||
private boolean enableAlphaFunctionality;
|
||||
@@ -416,6 +418,16 @@ public class ApplicationProperties {
|
||||
private String frontendUrl; // Frontend URL for invite email links (e.g.
|
||||
|
||||
// 'https://app.example.com'). If not set, falls back to backendUrl.
|
||||
private boolean enableMobileScanner = false; // Enable mobile phone QR code upload feature
|
||||
private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings();
|
||||
|
||||
@Data
|
||||
public static class MobileScannerSettings {
|
||||
private boolean convertToPdf = true; // Whether to automatically convert images to PDF
|
||||
private String imageResolution = "full"; // Options: "full", "reduced"
|
||||
private String pageFormat = "A4"; // Options: "keep", "A4", "letter"
|
||||
private boolean stretchToFit = false; // Whether to stretch image to fill page
|
||||
}
|
||||
|
||||
public boolean isAnalyticsEnabled() {
|
||||
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
|
||||
@@ -540,10 +552,10 @@ public class ApplicationProperties {
|
||||
@Override
|
||||
public String toString() {
|
||||
return """
|
||||
Driver {
|
||||
driverName='%s'
|
||||
}
|
||||
"""
|
||||
Driver {
|
||||
driverName='%s'
|
||||
}
|
||||
"""
|
||||
.formatted(driverName);
|
||||
}
|
||||
}
|
||||
@@ -596,6 +608,7 @@ public class ApplicationProperties {
|
||||
private boolean ssoAutoLogin;
|
||||
private CustomMetadata customMetadata = new CustomMetadata();
|
||||
|
||||
@Deprecated
|
||||
@Data
|
||||
public static class CustomMetadata {
|
||||
private boolean autoUpdateMetadata;
|
||||
@@ -603,16 +616,23 @@ 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;
|
||||
@@ -635,6 +655,102 @@ 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;
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Service for handling mobile scanner file uploads and temporary storage. Files are stored
|
||||
* temporarily and automatically cleaned up after 10 minutes or upon retrieval.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class MobileScannerService {
|
||||
|
||||
private static final long SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
private final Map<String, SessionData> activeSessions = new ConcurrentHashMap<>();
|
||||
private final Path tempDirectory;
|
||||
|
||||
public MobileScannerService() throws IOException {
|
||||
// Create temp directory for mobile scanner uploads
|
||||
this.tempDirectory =
|
||||
Paths.get(System.getProperty("java.io.tmpdir"), "stirling-mobile-scanner");
|
||||
Files.createDirectories(tempDirectory);
|
||||
log.info("Mobile scanner temp directory: {}", tempDirectory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new session (called by desktop when QR code is generated)
|
||||
*
|
||||
* @param sessionId Unique session identifier
|
||||
* @return SessionInfo with creation time and expiry
|
||||
*/
|
||||
public SessionInfo createSession(String sessionId) {
|
||||
validateSessionId(sessionId);
|
||||
|
||||
SessionData session = new SessionData(sessionId);
|
||||
activeSessions.put(sessionId, session);
|
||||
|
||||
log.info("Created mobile scanner session: {}", sessionId);
|
||||
return new SessionInfo(
|
||||
sessionId,
|
||||
session.createdAt,
|
||||
session.createdAt + SESSION_TIMEOUT_MS,
|
||||
SESSION_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a session exists and is not expired
|
||||
*
|
||||
* @param sessionId Session identifier to validate
|
||||
* @return SessionInfo if valid, null if invalid/expired
|
||||
*/
|
||||
public SessionInfo validateSession(String sessionId) {
|
||||
SessionData session = activeSessions.get(sessionId);
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
long expiryTime = session.getLastAccessTime() + SESSION_TIMEOUT_MS;
|
||||
|
||||
// Check if expired
|
||||
if (now > expiryTime) {
|
||||
deleteSession(sessionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
session.updateLastAccess();
|
||||
return new SessionInfo(sessionId, session.createdAt, expiryTime, SESSION_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores uploaded files for a session
|
||||
*
|
||||
* @param sessionId Unique session identifier
|
||||
* @param files Files to upload
|
||||
* @throws IOException If file storage fails
|
||||
*/
|
||||
public void uploadFiles(String sessionId, List<MultipartFile> files) throws IOException {
|
||||
validateSessionId(sessionId);
|
||||
|
||||
SessionData session =
|
||||
activeSessions.computeIfAbsent(sessionId, id -> new SessionData(sessionId));
|
||||
|
||||
// Create session directory
|
||||
Path sessionDir = getSafeSessionDirectory(sessionId);
|
||||
Files.createDirectories(sessionDir);
|
||||
|
||||
// Save each file
|
||||
for (MultipartFile file : files) {
|
||||
if (file.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
if (originalFilename == null || originalFilename.isBlank()) {
|
||||
originalFilename = "upload-" + System.currentTimeMillis();
|
||||
}
|
||||
|
||||
// Sanitize filename
|
||||
String safeFilename = sanitizeFilename(originalFilename);
|
||||
Path filePath = sessionDir.resolve(safeFilename).normalize().toAbsolutePath();
|
||||
|
||||
// Ensure resulting path stays within the session directory
|
||||
if (!filePath.startsWith(sessionDir)) {
|
||||
throw new IOException("Invalid filename");
|
||||
}
|
||||
|
||||
// Handle duplicate filenames
|
||||
int counter = 1;
|
||||
while (Files.exists(filePath)) {
|
||||
String nameWithoutExt = safeFilename.replaceFirst("[.][^.]+$", "");
|
||||
String ext =
|
||||
safeFilename.contains(".")
|
||||
? safeFilename.substring(safeFilename.lastIndexOf("."))
|
||||
: "";
|
||||
safeFilename = nameWithoutExt + "-" + counter + ext;
|
||||
filePath = sessionDir.resolve(safeFilename).normalize().toAbsolutePath();
|
||||
if (!filePath.startsWith(sessionDir)) {
|
||||
throw new IOException("Invalid filename");
|
||||
}
|
||||
counter++;
|
||||
}
|
||||
|
||||
file.transferTo(filePath);
|
||||
session.addFile(new FileMetadata(safeFilename, file.getSize(), file.getContentType()));
|
||||
log.info(
|
||||
"Uploaded file for session {}: {} ({} bytes)",
|
||||
sessionId,
|
||||
safeFilename,
|
||||
file.getSize());
|
||||
}
|
||||
|
||||
session.updateLastAccess();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves file metadata for a session
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @return List of file metadata, or empty list if session doesn't exist
|
||||
*/
|
||||
public List<FileMetadata> getSessionFiles(String sessionId) {
|
||||
SessionData session = activeSessions.get(sessionId);
|
||||
if (session == null) {
|
||||
return List.of();
|
||||
}
|
||||
session.updateLastAccess();
|
||||
return new ArrayList<>(session.getFiles());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves actual file data for download
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @param filename Filename to retrieve
|
||||
* @return File path
|
||||
* @throws IOException If file not found or session doesn't exist
|
||||
*/
|
||||
public Path getFile(String sessionId, String filename) throws IOException {
|
||||
SessionData session = activeSessions.get(sessionId);
|
||||
if (session == null) {
|
||||
throw new IOException("Session not found: " + sessionId);
|
||||
}
|
||||
|
||||
Path filePath = getSafeFilePath(sessionId, filename);
|
||||
if (!Files.exists(filePath)) {
|
||||
throw new IOException("File not found: " + filename);
|
||||
}
|
||||
|
||||
session.updateLastAccess();
|
||||
session.markFileAsDownloaded(filename);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a file after it has been served to the client. Should be called after successful
|
||||
* download.
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @param filename Filename to delete
|
||||
*/
|
||||
public void deleteFileAfterDownload(String sessionId, String filename) {
|
||||
try {
|
||||
Path filePath = getSafeFilePath(sessionId, filename);
|
||||
Files.deleteIfExists(filePath);
|
||||
log.info("Deleted file after download: {}/{}", sessionId, filename);
|
||||
|
||||
// Check if all files have been downloaded - if so, delete the entire session
|
||||
SessionData session = activeSessions.get(sessionId);
|
||||
if (session != null && session.allFilesDownloaded()) {
|
||||
deleteSession(sessionId);
|
||||
log.info("All files downloaded - deleted session: {}", sessionId);
|
||||
}
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
log.warn("Failed to delete file after download: {}/{}", sessionId, filename, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a session and all its files
|
||||
*
|
||||
* @param sessionId Session to delete
|
||||
*/
|
||||
public void deleteSession(String sessionId) {
|
||||
SessionData session = activeSessions.remove(sessionId);
|
||||
if (session != null) {
|
||||
try {
|
||||
Path sessionDir = getSafeSessionDirectory(sessionId);
|
||||
if (Files.exists(sessionDir)) {
|
||||
// Delete all files in session directory
|
||||
Files.walk(sessionDir)
|
||||
.sorted(
|
||||
(a, b) ->
|
||||
-a.compareTo(b)) // Reverse order to delete files before
|
||||
// directory
|
||||
.forEach(
|
||||
path -> {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete file: {}", path, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
log.info("Deleted session: {}", sessionId);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn(
|
||||
"Refused to delete session with invalid sessionId '{}': {}",
|
||||
sessionId,
|
||||
e.getMessage());
|
||||
} catch (IOException e) {
|
||||
log.error("Error deleting session directory: {}", sessionId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Scheduled cleanup of expired sessions (runs every 5 minutes) */
|
||||
@Scheduled(fixedRate = 5 * 60 * 1000)
|
||||
public void cleanupExpiredSessions() {
|
||||
long now = System.currentTimeMillis();
|
||||
List<String> expiredSessions = new ArrayList<>();
|
||||
|
||||
activeSessions.forEach(
|
||||
(sessionId, session) -> {
|
||||
if (now - session.getLastAccessTime() > SESSION_TIMEOUT_MS) {
|
||||
expiredSessions.add(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
if (!expiredSessions.isEmpty()) {
|
||||
log.info("Cleaning up {} expired mobile scanner sessions", expiredSessions.size());
|
||||
expiredSessions.forEach(this::deleteSession);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateSessionId(String sessionId) {
|
||||
if (sessionId == null || sessionId.isBlank()) {
|
||||
throw new IllegalArgumentException("Session ID cannot be empty");
|
||||
}
|
||||
// Basic validation: alphanumeric and hyphens only
|
||||
if (!sessionId.matches("[a-zA-Z0-9-]+")) {
|
||||
throw new IllegalArgumentException("Invalid session ID format");
|
||||
}
|
||||
}
|
||||
|
||||
private String sanitizeFilename(String filename) {
|
||||
// Remove path traversal attempts and dangerous characters
|
||||
String sanitized = filename.replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||
// Ensure we have a non-empty, safe filename
|
||||
if (sanitized.isBlank()) {
|
||||
sanitized = "upload-" + System.currentTimeMillis();
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely resolves and validates a session directory path to prevent directory traversal
|
||||
* attacks.
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @return Normalized absolute path to the session directory
|
||||
* @throws IllegalArgumentException if the resolved path escapes the temp directory
|
||||
*/
|
||||
private Path getSafeSessionDirectory(String sessionId) {
|
||||
validateSessionId(sessionId);
|
||||
|
||||
Path baseDir = tempDirectory.normalize().toAbsolutePath();
|
||||
Path sessionDir = baseDir.resolve(sessionId).normalize().toAbsolutePath();
|
||||
|
||||
// Verify the resolved path is still within the temp directory
|
||||
if (!sessionDir.startsWith(baseDir)) {
|
||||
throw new IllegalArgumentException("Invalid session ID: path traversal detected");
|
||||
}
|
||||
|
||||
return sessionDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely resolves and validates a file path within a session directory to prevent directory
|
||||
* traversal attacks.
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @param filename Filename within the session
|
||||
* @return Normalized absolute path to the file
|
||||
* @throws IOException if the resolved path escapes the session directory
|
||||
*/
|
||||
private Path getSafeFilePath(String sessionId, String filename) throws IOException {
|
||||
if (filename == null || filename.isBlank()) {
|
||||
throw new IOException("Filename cannot be empty");
|
||||
}
|
||||
|
||||
// Additional validation: reject filenames with path separators or parent references
|
||||
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
|
||||
throw new IOException(
|
||||
"Invalid filename: contains path separators or parent references");
|
||||
}
|
||||
|
||||
Path sessionDir = getSafeSessionDirectory(sessionId);
|
||||
Path filePath = sessionDir.resolve(filename).normalize().toAbsolutePath();
|
||||
|
||||
// Verify the resolved path is still within the session directory
|
||||
if (!filePath.startsWith(sessionDir)) {
|
||||
throw new IOException("Invalid filename: path traversal detected");
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/** Session information for client */
|
||||
public static class SessionInfo {
|
||||
private final String sessionId;
|
||||
private final long createdAt;
|
||||
private final long expiresAt;
|
||||
private final long timeoutMs;
|
||||
|
||||
public SessionInfo(String sessionId, long createdAt, long expiresAt, long timeoutMs) {
|
||||
this.sessionId = sessionId;
|
||||
this.createdAt = createdAt;
|
||||
this.expiresAt = expiresAt;
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
public String getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
public long getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public long getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public long getTimeoutMs() {
|
||||
return timeoutMs;
|
||||
}
|
||||
}
|
||||
|
||||
/** File metadata for client */
|
||||
public static class FileMetadata {
|
||||
private final String filename;
|
||||
private final long size;
|
||||
private final String contentType;
|
||||
|
||||
public FileMetadata(String filename, long size, String contentType) {
|
||||
this.filename = filename;
|
||||
this.size = size;
|
||||
this.contentType = contentType;
|
||||
}
|
||||
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
|
||||
public long getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
}
|
||||
|
||||
/** Session data tracking */
|
||||
private static class SessionData {
|
||||
private final String sessionId;
|
||||
private final List<FileMetadata> files = new ArrayList<>();
|
||||
private final Map<String, Boolean> downloadedFiles = new HashMap<>();
|
||||
private final long createdAt;
|
||||
private long lastAccessTime;
|
||||
|
||||
public SessionData(String sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
this.createdAt = System.currentTimeMillis();
|
||||
this.lastAccessTime = createdAt;
|
||||
}
|
||||
|
||||
public void addFile(FileMetadata file) {
|
||||
files.add(file);
|
||||
downloadedFiles.put(file.getFilename(), false);
|
||||
}
|
||||
|
||||
public List<FileMetadata> getFiles() {
|
||||
return files;
|
||||
}
|
||||
|
||||
public void markFileAsDownloaded(String filename) {
|
||||
downloadedFiles.put(filename, true);
|
||||
}
|
||||
|
||||
public boolean allFilesDownloaded() {
|
||||
return !downloadedFiles.isEmpty()
|
||||
&& downloadedFiles.values().stream().allMatch(downloaded -> downloaded);
|
||||
}
|
||||
|
||||
public void updateLastAccess() {
|
||||
this.lastAccessTime = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
public long getLastAccessTime() {
|
||||
return lastAccessTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,12 @@ public class CbrUtils {
|
||||
new PDRectangle(pdImage.getWidth(), pdImage.getHeight()));
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(document, page)) {
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true,
|
||||
true)) {
|
||||
contentStream.drawImage(pdImage, 0, 0);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
|
||||
@@ -97,7 +97,12 @@ public class CbzUtils {
|
||||
new PDRectangle(pdImage.getWidth(), pdImage.getHeight()));
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(document, page)) {
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true,
|
||||
true)) {
|
||||
contentStream.drawImage(pdImage, 0, 0);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
|
||||
@@ -1,651 +1,417 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Properties;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
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 lombok.Data;
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.api.converters.EmlToPdfRequest;
|
||||
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
public class EmlParser {
|
||||
|
||||
private static volatile Boolean jakartaMailAvailable = null;
|
||||
private static volatile Method mimeUtilityDecodeTextMethod = null;
|
||||
private static volatile boolean mimeUtilityChecked = false;
|
||||
// Configuration constants
|
||||
private final int DEFAULT_MAX_ATTACHMENT_MB = 10;
|
||||
private final long MAX_SIZE_ESTIMATION_BYTES = 500L * 1024 * 1024; // 500MB
|
||||
|
||||
private static final Pattern MIME_ENCODED_PATTERN =
|
||||
RegexPatternUtils.getInstance().getMimeEncodedWordPattern();
|
||||
// Message constants
|
||||
private final String NO_CONTENT_MESSAGE = "Email content could not be parsed";
|
||||
private final String ATTACHMENT_PREFIX = "attachment-";
|
||||
|
||||
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(
|
||||
public EmailContent extractEmailContent(
|
||||
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer)
|
||||
throws IOException {
|
||||
|
||||
EmlProcessingUtils.validateEmlInput(emlBytes);
|
||||
|
||||
if (isJakartaMailAvailable()) {
|
||||
return extractEmailContentAdvanced(emlBytes, request, customHtmlSanitizer);
|
||||
} else {
|
||||
return extractEmailContentBasic(emlBytes, customHtmlSanitizer);
|
||||
}
|
||||
Email email = parseEmail(emlBytes);
|
||||
return buildEmailContent(email, request, customHtmlSanitizer);
|
||||
}
|
||||
|
||||
private static EmailContent extractEmailContentBasic(
|
||||
byte[] emlBytes, CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
String emlContent = new String(emlBytes, StandardCharsets.UTF_8);
|
||||
EmailContent content = new EmailContent();
|
||||
|
||||
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 = extractHtmlBody(emlContent);
|
||||
if (htmlBody != null) {
|
||||
content.setHtmlBody(htmlBody);
|
||||
} else {
|
||||
String textBody = extractTextBody(emlContent);
|
||||
content.setTextBody(textBody != null ? textBody : "Email content could not be parsed");
|
||||
}
|
||||
|
||||
content.getAttachments().addAll(extractAttachmentsBasic(emlContent));
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
Object toType = recipientTypeClass.getField("TO").get(null);
|
||||
Object[] toRecipients = (Object[]) getRecipients.invoke(message, toType);
|
||||
content.setTo(buildAddressString(toRecipients));
|
||||
|
||||
Object ccType = recipientTypeClass.getField("CC").get(null);
|
||||
Object[] ccRecipients = (Object[]) getRecipients.invoke(message, ccType);
|
||||
content.setCc(buildAddressString(ccRecipients));
|
||||
|
||||
Object bccType = recipientTypeClass.getField("BCC").get(null);
|
||||
Object[] bccRecipients = (Object[]) getRecipients.invoke(message, bccType);
|
||||
content.setBcc(buildAddressString(bccRecipients));
|
||||
|
||||
} 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("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String buildAddressString(Object[] addresses) {
|
||||
if (addresses == null || addresses.length == 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < addresses.length; i++) {
|
||||
if (i > 0) builder.append(", ");
|
||||
builder.append(safeMimeDecode(addresses[i].toString()));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (contentType != null
|
||||
&& contentType.toLowerCase(Locale.ROOT).contains(TEXT_HTML)) {
|
||||
content.setHtmlBody(stringContent);
|
||||
} else {
|
||||
content.setTextBody(stringContent);
|
||||
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 {
|
||||
Class<?> multipartClass = Class.forName("jakarta.mail.Multipart");
|
||||
if (multipartClass.isInstance(messageContent)) {
|
||||
processMultipart(messageContent, content, request, customHtmlSanitizer, 0);
|
||||
}
|
||||
}
|
||||
} catch (ReflectiveOperationException | ClassCastException e) {
|
||||
content.setTextBody("Email content could not be parsed with advanced processing");
|
||||
}
|
||||
}
|
||||
|
||||
private static void processMultipart(
|
||||
Object multipart,
|
||||
EmailContent content,
|
||||
EmlToPdfRequest request,
|
||||
CustomHtmlSanitizer customHtmlSanitizer,
|
||||
int depth) {
|
||||
|
||||
final int MAX_MULTIPART_DEPTH = 10;
|
||||
if (depth > MAX_MULTIPART_DEPTH) {
|
||||
content.setHtmlBody("<div class=\"error\">Maximum multipart depth exceeded</div>");
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
email = EmailConverter.emlToEmail(input);
|
||||
}
|
||||
|
||||
} catch (ReflectiveOperationException | ClassCastException e) {
|
||||
content.setHtmlBody("<div class=\"error\">Error processing multipart content</div>");
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
return email;
|
||||
} catch (IOException e) {
|
||||
throw e; // Re-throw IOException as-is
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
throw new IOException(
|
||||
String.format(
|
||||
"Failed to parse EML file with Simple Java Mail: %s", e.getMessage()),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
private EmailContent buildEmailContent(
|
||||
Email email, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer)
|
||||
throws IOException {
|
||||
|
||||
int bodyStart = emlContent.indexOf("\r\n\r\n", textStart);
|
||||
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n", textStart);
|
||||
if (bodyStart == -1) return null;
|
||||
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));
|
||||
|
||||
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");
|
||||
Date sentDate = email.getSentDate();
|
||||
if (sentDate != null) {
|
||||
// Use UTC for consistent timezone handling across deployments
|
||||
content.setDate(ZonedDateTime.ofInstant(sentDate.toInstant(), ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
return start + result.length();
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
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 = "";
|
||||
attachments.addAll(mapResources(email.getEmbeddedImages(), request, true));
|
||||
attachments.addAll(mapResources(email.getAttachments(), request, false));
|
||||
content.setAttachments(attachments);
|
||||
content.setAttachmentCount(attachments.size());
|
||||
|
||||
for (String line : lines) {
|
||||
String lowerLine = line.toLowerCase(Locale.ROOT).trim();
|
||||
return content;
|
||||
}
|
||||
|
||||
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);
|
||||
private List<EmailAttachment> mapResources(
|
||||
List<AttachmentResource> resources, EmlToPdfRequest request, boolean embedded)
|
||||
throws IOException {
|
||||
|
||||
if (resources == null || resources.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
String resourceName = resource.getName();
|
||||
if (!isBlank(resourceName)) {
|
||||
return false;
|
||||
}
|
||||
DataSource dataSource = resource.getDataSource();
|
||||
return isBlank(dataSource.getName());
|
||||
}
|
||||
|
||||
private EmailAttachment toEmailAttachment(
|
||||
AttachmentResource resource, EmlToPdfRequest request, boolean embedded, int counter)
|
||||
throws IOException {
|
||||
|
||||
if (resource == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
EmailAttachment attachment = new EmailAttachment();
|
||||
attachment.setEmbedded(embedded);
|
||||
|
||||
String resourceName = defaultString(resource.getName());
|
||||
String filename = resourceName;
|
||||
DataSource dataSource = resource.getDataSource();
|
||||
String contentType = dataSource.getContentType();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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();
|
||||
}
|
||||
|
||||
// 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 "";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private ReadResult readData(DataSource dataSource, boolean embedded, EmlToPdfRequest request)
|
||||
throws IOException {
|
||||
if (dataSource == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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();
|
||||
output.write(buffer, 0, bytesRead);
|
||||
}
|
||||
byte[] data = output.toByteArray();
|
||||
return new ReadResult(data, data.length);
|
||||
} else {
|
||||
byte[] data = input.readAllBytes();
|
||||
return new ReadResult(data, data.length);
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
// Continue with empty list
|
||||
} 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;
|
||||
}
|
||||
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 long countRemainingBytes(InputStream input, long alreadyRead) throws IOException {
|
||||
long count = alreadyRead;
|
||||
|
||||
long skipped;
|
||||
while (count < MAX_SIZE_ESTIMATION_BYTES
|
||||
&& (skipped = input.skip(MAX_SIZE_ESTIMATION_BYTES - count)) > 0) {
|
||||
count += skipped;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static String extractFilenameFromDisposition(String disposition) {
|
||||
if (disposition == null || !disposition.contains("filename=")) {
|
||||
private String formatRecipients(List<Recipient> recipients, RecipientType type) {
|
||||
if (recipients == null || type == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// 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);
|
||||
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(", "));
|
||||
}
|
||||
|
||||
public static String safeMimeDecode(String headerValue) {
|
||||
if (headerValue == null || headerValue.trim().isEmpty()) {
|
||||
private String formatRecipient(Recipient recipient) {
|
||||
if (recipient == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (!mimeUtilityChecked) {
|
||||
synchronized (EmlParser.class) {
|
||||
if (!mimeUtilityChecked) {
|
||||
initializeMimeUtilityDecoding();
|
||||
}
|
||||
}
|
||||
}
|
||||
String name = safeMimeDecode(recipient.getName());
|
||||
String address = safeMimeDecode(recipient.getAddress());
|
||||
|
||||
if (mimeUtilityDecodeTextMethod != null) {
|
||||
try {
|
||||
return (String) mimeUtilityDecodeTextMethod.invoke(null, headerValue.trim());
|
||||
} catch (ReflectiveOperationException | RuntimeException e) {
|
||||
// Fall through to custom implementation
|
||||
}
|
||||
if (!isBlank(name) && !isBlank(address)) {
|
||||
return name + " <" + address + ">";
|
||||
}
|
||||
return !isBlank(name) ? name : address;
|
||||
}
|
||||
|
||||
public String safeMimeDecode(String headerValue) {
|
||||
if (isBlank(headerValue)) {
|
||||
return "";
|
||||
}
|
||||
return EmlProcessingUtils.decodeMimeHeader(headerValue.trim());
|
||||
}
|
||||
|
||||
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;
|
||||
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 + ")");
|
||||
}
|
||||
}
|
||||
mimeUtilityChecked = true;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class EmailContent {
|
||||
public class EmailContent {
|
||||
private String subject;
|
||||
private String from;
|
||||
private String to;
|
||||
private String cc;
|
||||
private String bcc;
|
||||
private ZonedDateTime date;
|
||||
private String dateString; // For basic parsing fallback
|
||||
private String dateString; // Maintained for compatibility
|
||||
private String htmlBody;
|
||||
private String textBody;
|
||||
private int attachmentCount;
|
||||
@@ -673,7 +439,7 @@ public class EmlParser {
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class EmailAttachment {
|
||||
public class EmailAttachment {
|
||||
private String filename;
|
||||
private String contentType;
|
||||
private byte[] data;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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;
|
||||
@@ -8,32 +10,41 @@ 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 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 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 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 =
|
||||
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 =
|
||||
Map.of(
|
||||
".png", MediaType.IMAGE_PNG_VALUE,
|
||||
".jpg", MediaType.IMAGE_JPEG_VALUE,
|
||||
@@ -45,18 +56,36 @@ public class EmlProcessingUtils {
|
||||
".ico", "image/x-icon",
|
||||
".tiff", "image/tiff",
|
||||
".tif", "image/tiff");
|
||||
private volatile String cachedCssContent = null;
|
||||
|
||||
public static void validateEmlInput(byte[] emlBytes) {
|
||||
public 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();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isInvalidEmlFormat(byte[] emlBytes) {
|
||||
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) {
|
||||
try {
|
||||
int checkLength = Math.min(emlBytes.length, EML_CHECK_LENGTH);
|
||||
String content;
|
||||
@@ -101,7 +130,7 @@ public class EmlProcessingUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public static String generateEnhancedEmailHtml(
|
||||
public String generateEnhancedEmailHtml(
|
||||
EmlParser.EmailContent content,
|
||||
EmlToPdfRequest request,
|
||||
CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
@@ -145,7 +174,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)));
|
||||
}
|
||||
|
||||
@@ -153,7 +182,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)));
|
||||
}
|
||||
|
||||
@@ -161,19 +190,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("</div></div>\n");
|
||||
html.append(String.format(Locale.ROOT, "</div></div>%n"));
|
||||
|
||||
html.append("<div class=\"email-body\">\n");
|
||||
html.append(String.format(Locale.ROOT, "<div class=\"email-body\">%n"));
|
||||
if (content.getHtmlBody() != null && !content.getHtmlBody().trim().isEmpty()) {
|
||||
String processedHtml =
|
||||
processEmailHtmlBody(content.getHtmlBody(), content, customHtmlSanitizer);
|
||||
@@ -187,17 +216,17 @@ public class EmlProcessingUtils {
|
||||
} else {
|
||||
html.append("<div class=\"no-content\"><p><em>No content available</em></p></div>");
|
||||
}
|
||||
html.append("</div>\n");
|
||||
html.append(String.format(Locale.ROOT, "</div>%n"));
|
||||
|
||||
if (content.getAttachmentCount() > 0 || !content.getAttachments().isEmpty()) {
|
||||
appendAttachmentsSection(html, content, request, customHtmlSanitizer);
|
||||
appendAttachmentsSection(html, content, request);
|
||||
}
|
||||
|
||||
html.append("</div>\n</body></html>");
|
||||
html.append(String.format(Locale.ROOT, "</div>%n</body></html>"));
|
||||
return html.toString();
|
||||
}
|
||||
|
||||
public static String processEmailHtmlBody(
|
||||
public String processEmailHtmlBody(
|
||||
String htmlBody,
|
||||
EmlParser.EmailContent emailContent,
|
||||
CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
@@ -224,8 +253,7 @@ public class EmlProcessingUtils {
|
||||
return processed;
|
||||
}
|
||||
|
||||
public static String convertTextToHtml(
|
||||
String textBody, CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
public String convertTextToHtml(String textBody, CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
if (textBody == null) return "";
|
||||
|
||||
String html =
|
||||
@@ -255,129 +283,25 @@ public class EmlProcessingUtils {
|
||||
return html;
|
||||
}
|
||||
|
||||
private static void appendEnhancedStyles(StringBuilder html) {
|
||||
String css =
|
||||
private void appendEnhancedStyles(StringBuilder html) {
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
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;
|
||||
: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;
|
||||
}
|
||||
""",
|
||||
DEFAULT_FONT_FAMILY,
|
||||
@@ -386,29 +310,70 @@ public class EmlProcessingUtils {
|
||||
DEFAULT_TEXT_COLOR,
|
||||
DEFAULT_BACKGROUND_COLOR,
|
||||
DEFAULT_BORDER_COLOR,
|
||||
DEFAULT_FONT_SIZE + 4,
|
||||
DEFAULT_FONT_SIZE - 1,
|
||||
DEFAULT_FONT_SIZE + 6,
|
||||
DEFAULT_FONT_SIZE,
|
||||
ATTACHMENT_BACKGROUND_COLOR,
|
||||
ATTACHMENT_BORDER_COLOR,
|
||||
DEFAULT_FONT_SIZE + 1,
|
||||
DEFAULT_FONT_SIZE - 2,
|
||||
DEFAULT_FONT_SIZE - 2,
|
||||
DEFAULT_FONT_SIZE - 3);
|
||||
DEFAULT_FONT_SIZE + 2,
|
||||
DEFAULT_FONT_SIZE - 1,
|
||||
DEFAULT_FONT_SIZE - 1));
|
||||
|
||||
html.append(css);
|
||||
html.append(loadEmailStyles());
|
||||
}
|
||||
|
||||
private static void appendAttachmentsSection(
|
||||
StringBuilder html,
|
||||
EmlParser.EmailContent content,
|
||||
EmlToPdfRequest request,
|
||||
CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
html.append("<div class=\"attachment-section\">\n");
|
||||
@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"));
|
||||
int displayedAttachmentCount =
|
||||
content.getAttachmentCount() > 0
|
||||
? content.getAttachmentCount()
|
||||
: content.getAttachments().size();
|
||||
html.append("<h3>Attachments (").append(displayedAttachmentCount).append(")</h3>\n");
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT, "<h3>Attachments (%d)</h3>%n", displayedAttachmentCount));
|
||||
|
||||
if (!content.getAttachments().isEmpty()) {
|
||||
for (int i = 0; i < content.getAttachments().size(); i++) {
|
||||
@@ -461,10 +426,10 @@ public class EmlProcessingUtils {
|
||||
</div>
|
||||
""");
|
||||
}
|
||||
html.append("</div>\n");
|
||||
html.append(String.format(Locale.ROOT, "</div>%n"));
|
||||
}
|
||||
|
||||
public static HTMLToPdfRequest createHtmlRequest(EmlToPdfRequest request) {
|
||||
public HTMLToPdfRequest createHtmlRequest(EmlToPdfRequest request) {
|
||||
HTMLToPdfRequest htmlRequest = new HTMLToPdfRequest();
|
||||
|
||||
if (request != null) {
|
||||
@@ -475,7 +440,7 @@ public class EmlProcessingUtils {
|
||||
return htmlRequest;
|
||||
}
|
||||
|
||||
public static String detectMimeType(String filename, String existingMimeType) {
|
||||
public String detectMimeType(String filename, String existingMimeType) {
|
||||
if (existingMimeType != null && !existingMimeType.isEmpty()) {
|
||||
return existingMimeType;
|
||||
}
|
||||
@@ -492,7 +457,7 @@ public class EmlProcessingUtils {
|
||||
return MediaType.IMAGE_PNG_VALUE; // Default MIME type
|
||||
}
|
||||
|
||||
public static String decodeUrlEncoded(String encoded) {
|
||||
public String decodeUrlEncoded(String encoded) {
|
||||
try {
|
||||
return java.net.URLDecoder.decode(encoded, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
@@ -500,7 +465,7 @@ public class EmlProcessingUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public static String decodeMimeHeader(String encodedText) {
|
||||
public String decodeMimeHeader(String encodedText) {
|
||||
if (encodedText == null || encodedText.trim().isEmpty()) {
|
||||
return encodedText;
|
||||
}
|
||||
@@ -566,7 +531,7 @@ public class EmlProcessingUtils {
|
||||
}
|
||||
}
|
||||
|
||||
private static String decodeQuotedPrintable(String encodedText, String charset) {
|
||||
private String decodeQuotedPrintable(String encodedText, String charset) {
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (int i = 0; i < encodedText.length(); i++) {
|
||||
char c = encodedText.charAt(i);
|
||||
@@ -609,7 +574,7 @@ public class EmlProcessingUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public static String escapeHtml(String text) {
|
||||
public String escapeHtml(String text) {
|
||||
if (text == null) return "";
|
||||
return text.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
@@ -618,7 +583,7 @@ public class EmlProcessingUtils {
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
public static String sanitizeText(String text, CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
public String sanitizeText(String text, CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
if (customHtmlSanitizer != null) {
|
||||
return customHtmlSanitizer.sanitize(text);
|
||||
} else {
|
||||
@@ -626,7 +591,7 @@ public class EmlProcessingUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public static String simplifyHtmlContent(String htmlContent) {
|
||||
public 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 = PDDocument.load(file);
|
||||
* PDDocument doc = Loader.loadPDF(file);
|
||||
* } catch (IOException e) {
|
||||
* throw ExceptionUtils.createPdfCorruptedException("during load", e);
|
||||
* }
|
||||
|
||||
@@ -91,6 +91,14 @@ public class GeneralUtils {
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
if (tempFile.exists()) {
|
||||
try {
|
||||
Files.delete(tempFile.toPath());
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return tempFile;
|
||||
}
|
||||
@@ -499,6 +507,12 @@ public class GeneralUtils {
|
||||
while ((bytesRead = in.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, bytesRead);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
try {
|
||||
Files.deleteIfExists(tempFile);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return tempFile.toFile();
|
||||
}
|
||||
|
||||
@@ -63,15 +63,16 @@ public class ImageProcessingUtils {
|
||||
} else {
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
int[] pixels = new int[width * height];
|
||||
|
||||
image.getRGB(0, 0, width, height, pixels, 0, width);
|
||||
|
||||
byte[] data = new byte[width * height * 3];
|
||||
int index = 0;
|
||||
for (int y = 0; y < height; y++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
int rgb = image.getRGB(x, y);
|
||||
data[index++] = (byte) ((rgb >> 16) & 0xFF); // Red
|
||||
data[index++] = (byte) ((rgb >> 8) & 0xFF); // Green
|
||||
data[index++] = (byte) (rgb & 0xFF); // Blue
|
||||
}
|
||||
for (int rgb : pixels) {
|
||||
data[index++] = (byte) ((rgb >> 16) & 0xFF); // Red
|
||||
data[index++] = (byte) ((rgb >> 8) & 0xFF); // Green
|
||||
data[index++] = (byte) (rgb & 0xFF); // Blue
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,11 @@ 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(
|
||||
@@ -97,15 +102,13 @@ public class PDFToFile {
|
||||
"-noframes",
|
||||
"-c",
|
||||
tempInputFile.getAbsolutePath(),
|
||||
pdfBaseName));
|
||||
outputBasename));
|
||||
|
||||
ProcessExecutorResult returnCode =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.PDFTOHTML)
|
||||
.runCommandWithOutputHandling(
|
||||
command, tempOutputDir.getPath().toFile());
|
||||
.runCommandWithOutputHandling(command, pdftohtmlDir.toFile());
|
||||
// Process HTML files to Markdown
|
||||
File[] outputFiles =
|
||||
Objects.requireNonNull(tempOutputDir.getPath().toFile().listFiles());
|
||||
File[] outputFiles = Objects.requireNonNull(pdftohtmlDir.toFile().listFiles());
|
||||
List<File> markdownFiles = new ArrayList<>();
|
||||
|
||||
// Convert HTML files to Markdown
|
||||
@@ -115,7 +118,7 @@ public class PDFToFile {
|
||||
String markdown = htmlToMarkdownConverter.convert(html);
|
||||
|
||||
String mdFileName = outputFile.getName().replace(".html", ".md");
|
||||
File mdFile = new File(tempOutputDir.getPath().toFile(), mdFileName);
|
||||
File mdFile = new File(pdftohtmlDir.toFile(), mdFileName);
|
||||
Files.writeString(mdFile.toPath(), markdown);
|
||||
markdownFiles.add(mdFile);
|
||||
}
|
||||
@@ -182,18 +185,22 @@ public class PDFToFile {
|
||||
// Save the uploaded file to a temporary location
|
||||
inputFile.transferTo(tempInputFile);
|
||||
|
||||
// Run the pdftohtml command with complex output
|
||||
// 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();
|
||||
|
||||
List<String> command =
|
||||
new ArrayList<>(
|
||||
Arrays.asList(
|
||||
"pdftohtml", "-c", tempInputFile.toString(), pdfBaseName));
|
||||
"pdftohtml", "-c", tempInputFile.toString(), outputBasename));
|
||||
|
||||
ProcessExecutorResult returnCode =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.PDFTOHTML)
|
||||
.runCommandWithOutputHandling(command, tempOutputDir.toFile());
|
||||
.runCommandWithOutputHandling(command, pdftohtmlDir.toFile());
|
||||
|
||||
// Get output files
|
||||
File[] outputFiles = Objects.requireNonNull(tempOutputDir.toFile().listFiles());
|
||||
File[] outputFiles = Objects.requireNonNull(pdftohtmlDir.toFile().listFiles());
|
||||
|
||||
// Return output files in a ZIP archive
|
||||
fileName = pdfBaseName + "ToHtml.zip";
|
||||
@@ -254,9 +261,6 @@ 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);
|
||||
@@ -265,8 +269,15 @@ 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);
|
||||
@@ -275,6 +286,14 @@ 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,6 +60,7 @@ public class PdfToCbrUtils {
|
||||
|
||||
private static byte[] createCbrFromPdf(PDDocument document, int dpi) throws IOException {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
|
||||
|
||||
Path tempDir = Files.createTempDirectory("stirling-pdf-cbr-");
|
||||
List<Path> generatedImages = new ArrayList<>();
|
||||
|
||||
@@ -55,6 +55,7 @@ public class PdfToCbzUtils {
|
||||
|
||||
private static byte[] createCbzFromPdf(PDDocument document, int dpi) throws IOException {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
|
||||
|
||||
try (ByteArrayOutputStream cbzOutputStream = new ByteArrayOutputStream();
|
||||
ZipOutputStream zipOut = new ZipOutputStream(cbzOutputStream)) {
|
||||
|
||||
@@ -119,11 +119,11 @@ public class PdfUtils {
|
||||
|
||||
public boolean hasTextOnPage(PDPage page, String phrase) throws IOException {
|
||||
PDFTextStripper textStripper = new PDFTextStripper();
|
||||
PDDocument tempDoc = new PDDocument();
|
||||
tempDoc.addPage(page);
|
||||
String pageText = textStripper.getText(tempDoc);
|
||||
tempDoc.close();
|
||||
return pageText.contains(phrase);
|
||||
try (PDDocument tempDoc = new PDDocument()) {
|
||||
tempDoc.addPage(page);
|
||||
String pageText = textStripper.getText(tempDoc);
|
||||
return pageText.contains(phrase);
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] convertFromPdf(
|
||||
@@ -153,7 +153,8 @@ public class PdfUtils {
|
||||
maxSafeDpi);
|
||||
}
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(inputStream)) {
|
||||
try (PDDocument document = pdfDocumentFactory.load(inputStream);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true);
|
||||
if (!includeAnnotations) {
|
||||
@@ -161,9 +162,6 @@ public class PdfUtils {
|
||||
}
|
||||
int pageCount = document.getNumberOfPages();
|
||||
|
||||
// Create a ByteArrayOutputStream to save the image(s) to
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
|
||||
if (singleImage) {
|
||||
if ("tiff".equals(imageType.toLowerCase(Locale.ROOT))
|
||||
|| "tif".equals(imageType.toLowerCase(Locale.ROOT))) {
|
||||
@@ -400,55 +398,61 @@ public class PdfUtils {
|
||||
*/
|
||||
public PDDocument convertPdfToPdfImage(PDDocument document) throws IOException {
|
||||
PDDocument imageDocument = new PDDocument();
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true);
|
||||
for (int page = 0; page < document.getNumberOfPages(); ++page) {
|
||||
final int pageIndex = page;
|
||||
BufferedImage bim;
|
||||
try {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true);
|
||||
for (int page = 0; page < document.getNumberOfPages(); ++page) {
|
||||
final int pageIndex = page;
|
||||
BufferedImage bim;
|
||||
|
||||
// Use global maximum DPI setting, fallback to 300 if not set
|
||||
int renderDpi = 300; // Default fallback
|
||||
ApplicationProperties properties =
|
||||
ApplicationContextProvider.getBean(ApplicationProperties.class);
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
renderDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
final int dpi = renderDpi;
|
||||
|
||||
try {
|
||||
bim =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
dpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, dpi, ImageType.RGB));
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage().contains("Maximum size of image exceeded")) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pageTooBigFor300Dpi",
|
||||
"PDF page {0} is too large to render at 300 DPI. The resulting image"
|
||||
+ " would exceed Java's maximum array size. Please use a lower DPI"
|
||||
+ " value for PDF-to-image conversion.",
|
||||
pageIndex + 1);
|
||||
// Use global maximum DPI setting, fallback to 300 if not set
|
||||
int renderDpi = 300; // Default fallback
|
||||
ApplicationProperties properties =
|
||||
ApplicationContextProvider.getBean(ApplicationProperties.class);
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
renderDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
throw e;
|
||||
final int dpi = renderDpi;
|
||||
|
||||
try {
|
||||
bim =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
dpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, dpi, ImageType.RGB));
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage().contains("Maximum size of image exceeded")) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pageTooBigFor300Dpi",
|
||||
"PDF page {0} is too large to render at 300 DPI. The resulting image"
|
||||
+ " would exceed Java's maximum array size. Please use a lower DPI"
|
||||
+ " value for PDF-to-image conversion.",
|
||||
pageIndex + 1);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
PDPage originalPage = document.getPage(page);
|
||||
|
||||
float width = originalPage.getMediaBox().getWidth();
|
||||
float height = originalPage.getMediaBox().getHeight();
|
||||
|
||||
PDPage newPage = new PDPage(new PDRectangle(width, height));
|
||||
imageDocument.addPage(newPage);
|
||||
PDImageXObject pdImage = LosslessFactory.createFromImage(imageDocument, bim);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
imageDocument, newPage, AppendMode.APPEND, true, true)) {
|
||||
contentStream.drawImage(pdImage, 0, 0, width, height);
|
||||
}
|
||||
bim.flush();
|
||||
}
|
||||
PDPage originalPage = document.getPage(page);
|
||||
|
||||
float width = originalPage.getMediaBox().getWidth();
|
||||
float height = originalPage.getMediaBox().getHeight();
|
||||
|
||||
PDPage newPage = new PDPage(new PDRectangle(width, height));
|
||||
imageDocument.addPage(newPage);
|
||||
PDImageXObject pdImage = LosslessFactory.createFromImage(imageDocument, bim);
|
||||
PDPageContentStream contentStream =
|
||||
new PDPageContentStream(imageDocument, newPage, AppendMode.APPEND, true, true);
|
||||
contentStream.drawImage(pdImage, 0, 0, width, height);
|
||||
contentStream.close();
|
||||
return imageDocument;
|
||||
} catch (Exception e) {
|
||||
throw e;
|
||||
}
|
||||
return imageDocument;
|
||||
}
|
||||
|
||||
private BufferedImage prepareImageForPdfToImage(int maxWidth, int height, String imageType) {
|
||||
|
||||
@@ -52,6 +52,11 @@ public class RequestUriUtils {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mobile scanner page for QR code-based file uploads (peer-to-peer, no backend auth needed)
|
||||
if (normalizedUri.startsWith("/mobile-scanner")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Treat common static file extensions as static resources
|
||||
return normalizedUri.endsWith(".svg")
|
||||
|| normalizedUri.endsWith(".png")
|
||||
@@ -168,6 +173,8 @@ public class RequestUriUtils {
|
||||
"/api/v1/ui-data/footer-info") // Public footer configuration
|
||||
|| trimmedUri.startsWith("/api/v1/invite/validate")
|
||||
|| trimmedUri.startsWith("/api/v1/invite/accept")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|
||||
|| trimmedUri.startsWith("/v1/api-docs");
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ public class WebResponseUtils {
|
||||
// Open Byte Array and save document to it
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
document.close();
|
||||
|
||||
return baosToWebResponse(baos, docName);
|
||||
}
|
||||
|
||||
+6
-1
@@ -146,13 +146,18 @@ public class CustomColorReplaceStrategy extends ReplaceAndInvertColorStrategy {
|
||||
// Save the modified PDF to a ByteArrayOutputStream
|
||||
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
|
||||
document.save(byteArrayOutputStream);
|
||||
document.close();
|
||||
|
||||
// Prepare the modified PDF for download
|
||||
ByteArrayInputStream inputStream =
|
||||
new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
|
||||
InputStreamResource resource = new InputStreamResource(inputStream);
|
||||
return resource;
|
||||
} finally {
|
||||
try {
|
||||
Files.deleteIfExists(file.toPath());
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temporary file: {}", file.getAbsolutePath(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+122
-76
@@ -7,6 +7,7 @@ import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
@@ -19,11 +20,14 @@ import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.api.misc.ReplaceAndInvert;
|
||||
import stirling.software.common.util.ApplicationContextProvider;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
@Slf4j
|
||||
public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
|
||||
|
||||
public InvertFullColorStrategy(MultipartFile file, ReplaceAndInvert replaceAndInvert) {
|
||||
@@ -32,83 +36,101 @@ public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
|
||||
|
||||
@Override
|
||||
public InputStreamResource replace() throws IOException {
|
||||
|
||||
File file = null;
|
||||
try {
|
||||
// Create a temporary file, with the original filename from the multipart file
|
||||
file = Files.createTempFile("temp", getFileInput().getOriginalFilename()).toFile();
|
||||
|
||||
try (TempFile tempFile =
|
||||
new TempFile(
|
||||
Files.createTempFile("temp", getFileInput().getOriginalFilename())
|
||||
.toFile())) {
|
||||
// Transfer the content of the multipart file to the file
|
||||
getFileInput().transferTo(file);
|
||||
getFileInput().transferTo(tempFile.getFile());
|
||||
|
||||
// Load the uploaded PDF
|
||||
PDDocument document = Loader.loadPDF(file);
|
||||
try (PDDocument document = Loader.loadPDF(tempFile.getFile())) {
|
||||
// Render each page and invert colors
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(
|
||||
true); // Enable subsampling to reduce memory usage
|
||||
for (int page = 0; page < document.getNumberOfPages(); page++) {
|
||||
BufferedImage image;
|
||||
|
||||
// Render each page and invert colors
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
for (int page = 0; page < document.getNumberOfPages(); page++) {
|
||||
BufferedImage image;
|
||||
// Use global maximum DPI setting, fallback to 300 if not set
|
||||
int renderDpi = 300; // Default fallback
|
||||
ApplicationProperties properties =
|
||||
ApplicationContextProvider.getBean(ApplicationProperties.class);
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
renderDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
final int dpi = renderDpi;
|
||||
final int pageNum = page;
|
||||
|
||||
// Use global maximum DPI setting, fallback to 300 if not set
|
||||
int renderDpi = 300; // Default fallback
|
||||
ApplicationProperties properties =
|
||||
ApplicationContextProvider.getBean(ApplicationProperties.class);
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
renderDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
final int dpi = renderDpi;
|
||||
final int pageNum = page;
|
||||
image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageNum + 1,
|
||||
dpi,
|
||||
() -> pdfRenderer.renderImageWithDPI(pageNum, dpi));
|
||||
|
||||
image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageNum + 1,
|
||||
dpi,
|
||||
() -> pdfRenderer.renderImageWithDPI(pageNum, dpi));
|
||||
// Invert the colors
|
||||
invertImageColors(image);
|
||||
|
||||
// Invert the colors
|
||||
invertImageColors(image);
|
||||
// Create a new PDPage from the inverted image
|
||||
PDPage pdPage = document.getPage(page);
|
||||
File tempImageFile = null;
|
||||
try {
|
||||
tempImageFile = convertToBufferedImageTpFile(image);
|
||||
PDImageXObject pdImage =
|
||||
PDImageXObject.createFromFileByContent(tempImageFile, document);
|
||||
|
||||
// Create a new PDPage from the inverted image
|
||||
PDPage pdPage = document.getPage(page);
|
||||
File tempImageFile = null;
|
||||
try {
|
||||
tempImageFile = convertToBufferedImageTpFile(image);
|
||||
PDImageXObject pdImage =
|
||||
PDImageXObject.createFromFileByContent(tempImageFile, document);
|
||||
// Delete temp file immediately after loading into memory to prevent disk
|
||||
// exhaustion
|
||||
// The file content is now in the PDImageXObject, so the file is no longer
|
||||
// needed
|
||||
try {
|
||||
Files.deleteIfExists(tempImageFile.toPath());
|
||||
tempImageFile = null; // Mark as deleted to avoid double deletion
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Failed to delete temporary image file: {}",
|
||||
tempImageFile.getAbsolutePath(),
|
||||
e);
|
||||
}
|
||||
|
||||
PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
pdPage,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true);
|
||||
contentStream.drawImage(
|
||||
pdImage,
|
||||
0,
|
||||
0,
|
||||
pdPage.getMediaBox().getWidth(),
|
||||
pdPage.getMediaBox().getHeight());
|
||||
contentStream.close();
|
||||
} finally {
|
||||
if (tempImageFile != null && tempImageFile.exists()) {
|
||||
Files.delete(tempImageFile.toPath());
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
pdPage,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true,
|
||||
true)) { // resetContext=true ensures clean graphics state
|
||||
contentStream.drawImage(
|
||||
pdImage,
|
||||
0,
|
||||
0,
|
||||
pdPage.getMediaBox().getWidth(),
|
||||
pdPage.getMediaBox().getHeight());
|
||||
}
|
||||
} finally {
|
||||
// Safety net: ensure temp file is deleted even if an exception occurred
|
||||
if (tempImageFile != null && tempImageFile.exists()) {
|
||||
try {
|
||||
Files.deleteIfExists(tempImageFile.toPath());
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Failed to delete temporary image file: {}",
|
||||
tempImageFile.getAbsolutePath(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save the modified PDF to a ByteArrayOutputStream
|
||||
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
|
||||
document.save(byteArrayOutputStream);
|
||||
document.close();
|
||||
// Save the modified PDF to a ByteArrayOutputStream
|
||||
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
|
||||
document.save(byteArrayOutputStream);
|
||||
|
||||
// Prepare the modified PDF for download
|
||||
ByteArrayInputStream inputStream =
|
||||
new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
|
||||
InputStreamResource resource = new InputStreamResource(inputStream);
|
||||
return resource;
|
||||
} finally {
|
||||
if (file != null && file.exists()) {
|
||||
Files.delete(file.toPath());
|
||||
// Prepare the modified PDF for download
|
||||
ByteArrayInputStream inputStream =
|
||||
new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
|
||||
InputStreamResource resource = new InputStreamResource(inputStream);
|
||||
return resource;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,24 +139,48 @@ public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
|
||||
private void invertImageColors(BufferedImage image) {
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
for (int x = 0; x < width; x++) {
|
||||
for (int y = 0; y < height; y++) {
|
||||
int rgba = image.getRGB(x, y);
|
||||
Color color = new Color(rgba, true);
|
||||
Color invertedColor =
|
||||
new Color(
|
||||
255 - color.getRed(),
|
||||
255 - color.getGreen(),
|
||||
255 - color.getBlue());
|
||||
image.setRGB(x, y, invertedColor.getRGB());
|
||||
}
|
||||
int[] pixels = new int[width * height];
|
||||
image.getRGB(0, 0, width, height, pixels, 0, width);
|
||||
for (int i = 0; i < pixels.length; i++) {
|
||||
int pixel = pixels[i];
|
||||
|
||||
int a = 0xff;
|
||||
|
||||
int r = (pixel >> 16) & 0xff;
|
||||
int g = (pixel >> 8) & 0xff;
|
||||
int b = pixel & 0xff;
|
||||
|
||||
pixels[i] = (a << 24) | ((255 - r) << 16) | ((255 - g) << 8) | (255 - b);
|
||||
}
|
||||
image.setRGB(0, 0, width, height, pixels, 0, width);
|
||||
}
|
||||
|
||||
// Helper method to convert BufferedImage to InputStream
|
||||
private File convertToBufferedImageTpFile(BufferedImage image) throws IOException {
|
||||
File file = File.createTempFile("image", ".png");
|
||||
// Use Files.createTempFile instead of File.createTempFile for better security and modern
|
||||
// Java practices
|
||||
Path tempPath = Files.createTempFile("image", ".png");
|
||||
File file = tempPath.toFile();
|
||||
ImageIO.write(image, "png", file);
|
||||
return file;
|
||||
}
|
||||
|
||||
private static class TempFile implements AutoCloseable {
|
||||
private final File file;
|
||||
|
||||
public TempFile(File file) {
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
public File getFile() {
|
||||
return file;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (file != null && file.exists()) {
|
||||
Files.delete(file.toPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
*, *::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,9 +439,7 @@ class EmlToPdfTest {
|
||||
"binary data");
|
||||
|
||||
testEmailConversion(
|
||||
emlContent,
|
||||
new String[] {"Attachment Only Test", "data.bin", "No content available"},
|
||||
true);
|
||||
emlContent, new String[] {"Attachment Only Test", "data.bin"}, true);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -469,10 +467,13 @@ class EmlToPdfTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle non-standard but valid character sets like ISO-8859-1")
|
||||
@DisplayName("Should accept ISO-8859-1 charset declaration without errors")
|
||||
void handleIso88591Charset() throws IOException {
|
||||
String subject = "Subject with special characters: ñ é ü";
|
||||
String body = "Body with special characters: ñ é ü";
|
||||
// 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 emlContent =
|
||||
createSimpleTextEmailWithCharset(
|
||||
@@ -488,8 +489,13 @@ class EmlToPdfTest {
|
||||
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request);
|
||||
|
||||
assertNotNull(htmlResult);
|
||||
assertTrue(htmlResult.contains(subject));
|
||||
assertTrue(htmlResult.contains(body));
|
||||
// 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");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -92,8 +92,7 @@ public class WebResponseUtilsTest {
|
||||
|
||||
@Test
|
||||
public void testPdfDocToWebResponse() {
|
||||
try {
|
||||
PDDocument document = new PDDocument();
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
document.addPage(new org.apache.pdfbox.pdmodel.PDPage());
|
||||
String docName = "sample.pdf";
|
||||
|
||||
|
||||
+32
-4
@@ -59,10 +59,11 @@ 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.0'
|
||||
implementation 'io.micrometer:micrometer-core:1.16.1'
|
||||
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"
|
||||
@@ -71,6 +72,13 @@ dependencies {
|
||||
implementation "org.apache.pdfbox:preflight:$pdfboxVersion"
|
||||
implementation "org.apache.pdfbox:xmpbox:$pdfboxVersion"
|
||||
|
||||
implementation 'org.verapdf:validation-model:1.28.2'
|
||||
|
||||
// veraPDF still uses javax.xml.bind, not the new jakarta namespace
|
||||
implementation 'javax.xml.bind:jaxb-api:2.3.1'
|
||||
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
|
||||
implementation 'com.sun.xml.bind:jaxb-core:2.3.0.1'
|
||||
|
||||
// https://mvnrepository.com/artifact/technology.tabula/tabula
|
||||
implementation ('technology.tabula:tabula:1.0.5') {
|
||||
exclude group: 'org.slf4j', module: 'slf4j-simple'
|
||||
@@ -200,6 +208,14 @@ 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() ||
|
||||
@@ -268,6 +284,18 @@ tasks.register('cleanFrontendAssets', Delete) {
|
||||
delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) }
|
||||
}
|
||||
|
||||
tasks.register('copyApiLandingPage', Copy) {
|
||||
group = 'frontend'
|
||||
description = 'Copy API landing page to index.html for backend-only mode'
|
||||
from(new File(resourcesStaticDir, 'api-landing.html'))
|
||||
into(resourcesStaticDir)
|
||||
rename('api-landing.html', 'index.html')
|
||||
dependsOn cleanFrontendAssets
|
||||
doFirst {
|
||||
println "Copying API landing page to index.html for backend-only mode..."
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure copyFrontendAssets runs after spotless tasks
|
||||
tasks.named('copyFrontendAssets').configure {
|
||||
mustRunAfter tasks.matching { it.name.startsWith('spotless') }
|
||||
@@ -277,9 +305,9 @@ if (buildWithFrontend) {
|
||||
println "Frontend build enabled - JAR will include React frontend"
|
||||
processResources.dependsOn copyFrontendAssets
|
||||
} else {
|
||||
println "Frontend build disabled - JAR will be backend-only"
|
||||
// When not building the UI, ensure any stale frontend assets are removed
|
||||
processResources.dependsOn cleanFrontendAssets
|
||||
println "Frontend build disabled - JAR will be backend-only with API landing page"
|
||||
// When not building the UI, ensure any stale frontend assets are removed and use API landing page
|
||||
processResources.dependsOn copyApiLandingPage
|
||||
}
|
||||
|
||||
bootJar.dependsOn ':common:jar'
|
||||
|
||||
@@ -141,10 +141,10 @@ public class SPDFApplication {
|
||||
String backendUrl = appConfig.getBackendUrl();
|
||||
String contextPath = appConfig.getContextPath();
|
||||
String serverPort = appConfig.getServerPort();
|
||||
baseUrlStatic = backendUrl;
|
||||
baseUrlStatic = normalizeBackendUrl(backendUrl, serverPort);
|
||||
contextPathStatic = contextPath;
|
||||
serverPortStatic = serverPort;
|
||||
String url = backendUrl + ":" + getStaticPort() + contextPath;
|
||||
String url = buildFullUrl(baseUrlStatic, getStaticPort(), contextPathStatic);
|
||||
|
||||
// Log Tauri mode information
|
||||
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
|
||||
@@ -210,7 +210,7 @@ public class SPDFApplication {
|
||||
|
||||
private static void printStartupLogs() {
|
||||
log.info("Stirling-PDF Started.");
|
||||
String url = baseUrlStatic + ":" + getStaticPort() + contextPathStatic;
|
||||
String url = buildFullUrl(baseUrlStatic, getStaticPort(), contextPathStatic);
|
||||
log.info("Navigate to {}", url);
|
||||
}
|
||||
|
||||
@@ -258,4 +258,79 @@ public class SPDFApplication {
|
||||
public static String getStaticContextPath() {
|
||||
return contextPathStatic;
|
||||
}
|
||||
|
||||
private static String buildFullUrl(String backendUrl, String port, String contextPath) {
|
||||
String normalizedBase = normalizeBackendUrl(backendUrl, port);
|
||||
|
||||
String normalizedContextPath =
|
||||
(contextPath == null || contextPath.isBlank() || "/".equals(contextPath))
|
||||
? "/"
|
||||
: (contextPath.startsWith("/") ? contextPath : "/" + contextPath);
|
||||
|
||||
return normalizedBase + normalizedContextPath;
|
||||
}
|
||||
|
||||
private static String normalizeBackendUrl(String backendUrl, String port) {
|
||||
String trimmedBase =
|
||||
(backendUrl == null || backendUrl.isBlank())
|
||||
? "http://localhost"
|
||||
: backendUrl.trim().replaceAll("/+$", "");
|
||||
boolean hasScheme = trimmedBase.matches("^[a-zA-Z][a-zA-Z0-9+.-]*://.*");
|
||||
String baseForParsing = hasScheme ? trimmedBase : "http://" + trimmedBase;
|
||||
Integer parsedPort = parsePort(port);
|
||||
|
||||
try {
|
||||
java.net.URI uri = new java.net.URI(baseForParsing);
|
||||
String scheme = uri.getScheme() == null ? "http" : uri.getScheme();
|
||||
String host = uri.getHost();
|
||||
if (host == null) {
|
||||
return appendPortFallback(trimmedBase, parsedPort);
|
||||
}
|
||||
|
||||
boolean defaultHttp =
|
||||
"http".equalsIgnoreCase(scheme) && Integer.valueOf(80).equals(parsedPort);
|
||||
boolean defaultHttps =
|
||||
"https".equalsIgnoreCase(scheme) && Integer.valueOf(443).equals(parsedPort);
|
||||
|
||||
int effectivePort = uri.getPort();
|
||||
if (effectivePort == -1 && parsedPort != null && !defaultHttp && !defaultHttps) {
|
||||
effectivePort = parsedPort;
|
||||
}
|
||||
|
||||
java.net.URI rebuilt =
|
||||
new java.net.URI(
|
||||
scheme,
|
||||
uri.getUserInfo(),
|
||||
host,
|
||||
effectivePort,
|
||||
uri.getPath(),
|
||||
uri.getQuery(),
|
||||
uri.getFragment());
|
||||
return rebuilt.toString();
|
||||
} catch (java.net.URISyntaxException e) {
|
||||
return appendPortFallback(trimmedBase, parsedPort);
|
||||
}
|
||||
}
|
||||
|
||||
private static Integer parsePort(String port) {
|
||||
if (port == null || port.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
int parsed = Integer.parseInt(port);
|
||||
return parsed > 0 ? parsed : null;
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String appendPortFallback(String trimmedBase, Integer port) {
|
||||
if (port == null) {
|
||||
return trimmedBase;
|
||||
}
|
||||
if (trimmedBase.matches(".+:\\d+$")) {
|
||||
return trimmedBase;
|
||||
}
|
||||
return trimmedBase + ":" + port;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
|
||||
"principal",
|
||||
"startDate",
|
||||
"endDate",
|
||||
"async");
|
||||
"async",
|
||||
"session");
|
||||
|
||||
@Override
|
||||
public boolean preHandle(
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+25
-24
@@ -29,6 +29,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import stirling.software.SPDF.model.api.general.BookletImpositionRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@@ -68,33 +69,33 @@ public class BookletImpositionController {
|
||||
"Booklet printing uses 2 pages per side (landscape). For 4-up, use the N-up feature.");
|
||||
}
|
||||
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
|
||||
// Create proper booklet with signature-based page ordering
|
||||
PDDocument newDocument =
|
||||
createSaddleBooklet(
|
||||
sourceDocument,
|
||||
totalPages,
|
||||
addBorder,
|
||||
spineLocation,
|
||||
addGutter,
|
||||
gutterSize,
|
||||
doubleSided,
|
||||
duplexPass,
|
||||
flipOnShortEdge);
|
||||
// Create proper booklet with signature-based page ordering
|
||||
try (PDDocument newDocument =
|
||||
createSaddleBooklet(
|
||||
sourceDocument,
|
||||
totalPages,
|
||||
addBorder,
|
||||
spineLocation,
|
||||
addGutter,
|
||||
gutterSize,
|
||||
doubleSided,
|
||||
duplexPass,
|
||||
flipOnShortEdge)) {
|
||||
|
||||
sourceDocument.close();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
newDocument.close();
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
Filenames.toSimpleFileName(file.getOriginalFilename()).replaceFirst("[.][^.]+$", "")
|
||||
+ "_booklet.pdf");
|
||||
byte[] result = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
GeneralUtils.generateFilename(
|
||||
Filenames.toSimpleFileName(file.getOriginalFilename()),
|
||||
"_booklet.pdf"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int padToMultipleOf4(int n) {
|
||||
|
||||
@@ -155,6 +155,7 @@ public class CropController {
|
||||
try (PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
|
||||
PDFRenderer renderer = new PDFRenderer(sourceDocument);
|
||||
renderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
|
||||
for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package stirling.software.SPDF.controller.api;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
@@ -27,7 +28,6 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
@@ -57,12 +57,20 @@ public class MergeController {
|
||||
// Merges a list of PDDocument objects into a single PDDocument
|
||||
public PDDocument mergeDocuments(List<PDDocument> documents) throws IOException {
|
||||
PDDocument mergedDoc = pdfDocumentFactory.createNewDocument();
|
||||
for (PDDocument doc : documents) {
|
||||
for (PDPage page : doc.getPages()) {
|
||||
mergedDoc.addPage(page);
|
||||
boolean success = false;
|
||||
try {
|
||||
for (PDDocument doc : documents) {
|
||||
for (PDPage page : doc.getPages()) {
|
||||
mergedDoc.addPage(page);
|
||||
}
|
||||
}
|
||||
success = true;
|
||||
return mergedDoc;
|
||||
} finally {
|
||||
if (!success) {
|
||||
mergedDoc.close();
|
||||
}
|
||||
}
|
||||
return mergedDoc;
|
||||
}
|
||||
|
||||
// Re-order files to match the explicit order provided by the front-end.
|
||||
@@ -269,7 +277,7 @@ public class MergeController {
|
||||
"This endpoint merges multiple PDF files into a single PDF file. The merged"
|
||||
+ " file will contain all pages from the input files in the order they were"
|
||||
+ " provided. Input:PDF Output:PDF Type:MISO")
|
||||
public ResponseEntity<StreamingResponseBody> mergePdfs(
|
||||
public ResponseEntity<byte[]> mergePdfs(
|
||||
@ModelAttribute MergePdfsRequest request,
|
||||
@RequestParam(value = "fileOrder", required = false) String fileOrder)
|
||||
throws IOException {
|
||||
@@ -296,8 +304,6 @@ public class MergeController {
|
||||
request.getSortType())); // Sort files based on requested sort type
|
||||
}
|
||||
|
||||
ResponseEntity<StreamingResponseBody> response;
|
||||
|
||||
try (TempFile mt = new TempFile(tempFileManager, ".pdf")) {
|
||||
|
||||
PDFMergerUtility mergerUtility = new PDFMergerUtility();
|
||||
@@ -363,9 +369,18 @@ public class MergeController {
|
||||
|
||||
// Save the modified document to a temporary file
|
||||
outputTempFile = new TempFile(tempFileManager, ".pdf");
|
||||
mergedDocument.save(outputTempFile.getFile());
|
||||
try {
|
||||
mergedDocument.save(outputTempFile.getFile());
|
||||
} catch (Exception e) {
|
||||
outputTempFile.close();
|
||||
outputTempFile = null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
if (outputTempFile != null) {
|
||||
outputTempFile.close();
|
||||
}
|
||||
if (ex instanceof IOException && PdfErrorUtils.isCorruptedPdfError((IOException) ex)) {
|
||||
log.warn("Corrupted PDF detected in merge pdf process: {}", ex.getMessage());
|
||||
} else {
|
||||
@@ -382,7 +397,7 @@ public class MergeController {
|
||||
String mergedFileName =
|
||||
GeneralUtils.generateFilename(firstFilename, "_merged_unsigned.pdf");
|
||||
|
||||
response = WebResponseUtils.pdfFileToWebResponse(outputTempFile, mergedFileName);
|
||||
return response;
|
||||
byte[] pdfBytes = Files.readAllBytes(outputTempFile.getPath());
|
||||
return WebResponseUtils.bytesToWebResponse(pdfBytes, mergedFileName);
|
||||
}
|
||||
}
|
||||
|
||||
+98
-97
@@ -70,108 +70,109 @@ public class MultiPageLayoutController {
|
||||
: (int) Math.sqrt(pagesPerSheet);
|
||||
int rows = pagesPerSheet == 2 || pagesPerSheet == 3 ? 1 : (int) Math.sqrt(pagesPerSheet);
|
||||
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
PDPage newPage = new PDPage(PDRectangle.A4);
|
||||
newDocument.addPage(newPage);
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
|
||||
try (PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
float cellWidth = newPage.getMediaBox().getWidth() / cols;
|
||||
float cellHeight = newPage.getMediaBox().getHeight() / rows;
|
||||
// Calculate cell dimensions once (all output pages are A4) - declare outside try
|
||||
// blocks
|
||||
float cellWidth = PDRectangle.A4.getWidth() / cols;
|
||||
float cellHeight = PDRectangle.A4.getHeight() / rows;
|
||||
|
||||
PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
newDocument, newPage, PDPageContentStream.AppendMode.APPEND, true, true);
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
// Process pages in groups of pagesPerSheet, creating a new page and content stream
|
||||
// for each group
|
||||
for (int i = 0; i < totalPages; i += pagesPerSheet) {
|
||||
// Create a new output page for each group of pagesPerSheet
|
||||
PDPage newPage = new PDPage(PDRectangle.A4);
|
||||
newDocument.addPage(newPage);
|
||||
|
||||
float borderThickness = 1.5f; // Specify border thickness as required
|
||||
contentStream.setLineWidth(borderThickness);
|
||||
contentStream.setStrokingColor(Color.BLACK);
|
||||
// Use try-with-resources for each content stream to ensure proper cleanup
|
||||
// resetContext=true: Start with a clean graphics state for new content
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
newDocument,
|
||||
newPage,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true)) {
|
||||
float borderThickness = 1.5f; // Specify border thickness as required
|
||||
contentStream.setLineWidth(borderThickness);
|
||||
contentStream.setStrokingColor(Color.BLACK);
|
||||
|
||||
for (int i = 0; i < totalPages; i++) {
|
||||
if (i != 0 && i % pagesPerSheet == 0) {
|
||||
// Close the current content stream and create a new page and content stream
|
||||
contentStream.close();
|
||||
newPage = new PDPage(PDRectangle.A4);
|
||||
newDocument.addPage(newPage);
|
||||
contentStream =
|
||||
new PDPageContentStream(
|
||||
// Process all pages in this group
|
||||
for (int j = 0; j < pagesPerSheet && (i + j) < totalPages; j++) {
|
||||
int pageIndex = i + j;
|
||||
PDPage sourcePage = sourceDocument.getPage(pageIndex);
|
||||
PDRectangle rect = sourcePage.getMediaBox();
|
||||
float scaleWidth = cellWidth / rect.getWidth();
|
||||
float scaleHeight = cellHeight / rect.getHeight();
|
||||
float scale = Math.min(scaleWidth, scaleHeight);
|
||||
|
||||
int adjustedPageIndex = j % pagesPerSheet;
|
||||
int rowIndex = adjustedPageIndex / cols;
|
||||
int colIndex = adjustedPageIndex % cols;
|
||||
|
||||
float x =
|
||||
colIndex * cellWidth
|
||||
+ (cellWidth - rect.getWidth() * scale) / 2;
|
||||
float y =
|
||||
newPage.getMediaBox().getHeight()
|
||||
- ((rowIndex + 1) * cellHeight
|
||||
- (cellHeight - rect.getHeight() * scale) / 2);
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.transform(Matrix.getTranslateInstance(x, y));
|
||||
contentStream.transform(Matrix.getScaleInstance(scale, scale));
|
||||
|
||||
PDFormXObject formXObject =
|
||||
layerUtility.importPageAsForm(sourceDocument, pageIndex);
|
||||
contentStream.drawForm(formXObject);
|
||||
|
||||
contentStream.restoreGraphicsState();
|
||||
|
||||
if (addBorder) {
|
||||
// Draw border around each page
|
||||
float borderX = colIndex * cellWidth;
|
||||
float borderY =
|
||||
newPage.getMediaBox().getHeight()
|
||||
- (rowIndex + 1) * cellHeight;
|
||||
contentStream.addRect(borderX, borderY, cellWidth, cellHeight);
|
||||
contentStream.stroke();
|
||||
}
|
||||
}
|
||||
} // contentStream is automatically closed here
|
||||
}
|
||||
|
||||
// If any source page is rotated, skip form copying/transformation entirely
|
||||
boolean hasRotation = GeneralFormCopyUtils.hasAnyRotatedPage(sourceDocument);
|
||||
if (hasRotation) {
|
||||
log.info("Source document has rotated pages; skipping form field copying.");
|
||||
} else {
|
||||
try {
|
||||
GeneralFormCopyUtils.copyAndTransformFormFields(
|
||||
sourceDocument,
|
||||
newDocument,
|
||||
newPage,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true);
|
||||
}
|
||||
totalPages,
|
||||
pagesPerSheet,
|
||||
cols,
|
||||
rows,
|
||||
cellWidth,
|
||||
cellHeight);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to copy and transform form fields: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
PDPage sourcePage = sourceDocument.getPage(i);
|
||||
PDRectangle rect = sourcePage.getMediaBox();
|
||||
float scaleWidth = cellWidth / rect.getWidth();
|
||||
float scaleHeight = cellHeight / rect.getHeight();
|
||||
float scale = Math.min(scaleWidth, scaleHeight);
|
||||
|
||||
int adjustedPageIndex =
|
||||
i % pagesPerSheet; // Close the current content stream and create a new
|
||||
// page and content stream
|
||||
int rowIndex = adjustedPageIndex / cols;
|
||||
int colIndex = adjustedPageIndex % cols;
|
||||
|
||||
float x = colIndex * cellWidth + (cellWidth - rect.getWidth() * scale) / 2;
|
||||
float y =
|
||||
newPage.getMediaBox().getHeight()
|
||||
- ((rowIndex + 1) * cellHeight
|
||||
- (cellHeight - rect.getHeight() * scale) / 2);
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.transform(Matrix.getTranslateInstance(x, y));
|
||||
contentStream.transform(Matrix.getScaleInstance(scale, scale));
|
||||
|
||||
PDFormXObject formXObject = layerUtility.importPageAsForm(sourceDocument, i);
|
||||
contentStream.drawForm(formXObject);
|
||||
|
||||
contentStream.restoreGraphicsState();
|
||||
|
||||
if (addBorder) {
|
||||
// Draw border around each page
|
||||
float borderX = colIndex * cellWidth;
|
||||
float borderY = newPage.getMediaBox().getHeight() - (rowIndex + 1) * cellHeight;
|
||||
contentStream.addRect(borderX, borderY, cellWidth, cellHeight);
|
||||
contentStream.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
contentStream.close();
|
||||
|
||||
// If any source page is rotated, skip form copying/transformation entirely
|
||||
boolean hasRotation = GeneralFormCopyUtils.hasAnyRotatedPage(sourceDocument);
|
||||
if (hasRotation) {
|
||||
log.info("Source document has rotated pages; skipping form field copying.");
|
||||
} else {
|
||||
try {
|
||||
GeneralFormCopyUtils.copyAndTransformFormFields(
|
||||
sourceDocument,
|
||||
newDocument,
|
||||
totalPages,
|
||||
pagesPerSheet,
|
||||
cols,
|
||||
rows,
|
||||
cellWidth,
|
||||
cellHeight);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to copy and transform form fields: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
sourceDocument.close();
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
newDocument.close();
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
GeneralUtils.generateFilename(
|
||||
file.getOriginalFilename(), "_multi_page_layout.pdf"));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
byte[] result = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
GeneralUtils.generateFilename(
|
||||
file.getOriginalFilename(), "_multi_page_layout.pdf"));
|
||||
} // newDocument is closed here
|
||||
} // sourceDocument is closed here
|
||||
}
|
||||
}
|
||||
|
||||
+18
-15
@@ -53,25 +53,28 @@ public class PdfImageRemovalController {
|
||||
"This endpoint remove images from file to reduce the file size.Input:PDF"
|
||||
+ " Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> removeImages(@ModelAttribute PDFFile file) throws IOException {
|
||||
// Load the PDF document
|
||||
PDDocument document = pdfDocumentFactory.load(file);
|
||||
// Load the PDF document with proper resource management
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
|
||||
// Remove images from the PDF document using the service
|
||||
PDDocument modifiedDocument = pdfImageRemovalService.removeImagesFromPdf(document);
|
||||
// Remove images from the PDF document using the service
|
||||
try (PDDocument modifiedDocument =
|
||||
pdfImageRemovalService.removeImagesFromPdf(document)) {
|
||||
|
||||
// Create a ByteArrayOutputStream to hold the modified PDF data
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
// Create a ByteArrayOutputStream to hold the modified PDF data
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
|
||||
// Save the modified PDF document to the output stream
|
||||
modifiedDocument.save(outputStream);
|
||||
modifiedDocument.close();
|
||||
// Save the modified PDF document to the output stream
|
||||
modifiedDocument.save(outputStream);
|
||||
|
||||
// Generate a new filename for the modified PDF
|
||||
String mergedFileName =
|
||||
GeneralUtils.generateFilename(
|
||||
file.getFileInput().getOriginalFilename(), "_images_removed.pdf");
|
||||
// Generate a new filename for the modified PDF
|
||||
String mergedFileName =
|
||||
GeneralUtils.generateFilename(
|
||||
file.getFileInput().getOriginalFilename(), "_images_removed.pdf");
|
||||
|
||||
// Convert the byte array to a web response and return it
|
||||
return WebResponseUtils.bytesToWebResponse(outputStream.toByteArray(), mergedFileName);
|
||||
// Convert the byte array to a web response and return it
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
outputStream.toByteArray(), mergedFileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+49
-45
@@ -50,23 +50,25 @@ public class RearrangePagesPDFController {
|
||||
MultipartFile pdfFile = request.getFileInput();
|
||||
String pagesToDelete = request.getPageNumbers();
|
||||
|
||||
PDDocument document = pdfDocumentFactory.load(pdfFile);
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
|
||||
|
||||
// Split the page order string into an array of page numbers or range of numbers
|
||||
String[] pageOrderArr = pagesToDelete.split(",");
|
||||
// Split the page order string into an array of page numbers or range of numbers
|
||||
String[] pageOrderArr = pagesToDelete.split(",");
|
||||
|
||||
List<Integer> pagesToRemove =
|
||||
GeneralUtils.parsePageList(pageOrderArr, document.getNumberOfPages(), false);
|
||||
List<Integer> pagesToRemove =
|
||||
GeneralUtils.parsePageList(pageOrderArr, document.getNumberOfPages(), false);
|
||||
|
||||
Collections.sort(pagesToRemove);
|
||||
Collections.sort(pagesToRemove);
|
||||
|
||||
for (int i = pagesToRemove.size() - 1; i >= 0; i--) {
|
||||
int pageIndex = pagesToRemove.get(i);
|
||||
document.removePage(pageIndex);
|
||||
for (int i = pagesToRemove.size() - 1; i >= 0; i--) {
|
||||
int pageIndex = pagesToRemove.get(i);
|
||||
document.removePage(pageIndex);
|
||||
}
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
pdfFile.getOriginalFilename(), "_removed_pages.pdf"));
|
||||
}
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_removed_pages.pdf"));
|
||||
}
|
||||
|
||||
private List<Integer> removeFirst(int totalPages) {
|
||||
@@ -243,41 +245,43 @@ public class RearrangePagesPDFController {
|
||||
String pageOrder = request.getPageNumbers();
|
||||
String sortType = request.getCustomMode();
|
||||
try {
|
||||
// Load the input PDF
|
||||
PDDocument document = pdfDocumentFactory.load(pdfFile);
|
||||
// Load the input PDF with proper resource management
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
|
||||
|
||||
// Split the page order string into an array of page numbers or range of numbers
|
||||
String[] pageOrderArr = pageOrder != null ? pageOrder.split(",") : new String[0];
|
||||
int totalPages = document.getNumberOfPages();
|
||||
List<Integer> newPageOrder;
|
||||
if (sortType != null
|
||||
&& !sortType.isEmpty()
|
||||
&& !"custom".equals(sortType.toLowerCase(Locale.ROOT))) {
|
||||
newPageOrder = processSortTypes(sortType, totalPages, pageOrder);
|
||||
} else {
|
||||
newPageOrder = GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
|
||||
// Split the page order string into an array of page numbers or range of numbers
|
||||
String[] pageOrderArr = pageOrder != null ? pageOrder.split(",") : new String[0];
|
||||
int totalPages = document.getNumberOfPages();
|
||||
List<Integer> newPageOrder;
|
||||
if (sortType != null
|
||||
&& !sortType.isEmpty()
|
||||
&& !"custom".equals(sortType.toLowerCase(Locale.ROOT))) {
|
||||
newPageOrder = processSortTypes(sortType, totalPages, pageOrder);
|
||||
} else {
|
||||
newPageOrder = GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
|
||||
}
|
||||
log.info("newPageOrder = {}", newPageOrder);
|
||||
log.info("totalPages = {}", totalPages);
|
||||
// Create a new list to hold the pages in the new order
|
||||
List<PDPage> newPages = new ArrayList<>();
|
||||
for (int i = 0; i < newPageOrder.size(); i++) {
|
||||
newPages.add(document.getPage(newPageOrder.get(i)));
|
||||
}
|
||||
|
||||
// Create a new document based on the original one
|
||||
try (PDDocument rearrangedDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document)) {
|
||||
|
||||
// Add the pages in the new order
|
||||
for (PDPage page : newPages) {
|
||||
rearrangedDocument.addPage(page);
|
||||
}
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
rearrangedDocument,
|
||||
GeneralUtils.generateFilename(
|
||||
pdfFile.getOriginalFilename(), "_rearranged.pdf"));
|
||||
}
|
||||
}
|
||||
log.info("newPageOrder = {}", newPageOrder);
|
||||
log.info("totalPages = {}", totalPages);
|
||||
// Create a new list to hold the pages in the new order
|
||||
List<PDPage> newPages = new ArrayList<>();
|
||||
for (int i = 0; i < newPageOrder.size(); i++) {
|
||||
newPages.add(document.getPage(newPageOrder.get(i)));
|
||||
}
|
||||
|
||||
// Create a new document based on the original one
|
||||
PDDocument rearrangedDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document);
|
||||
|
||||
// Add the pages in the new order
|
||||
for (PDPage page : newPages) {
|
||||
rearrangedDocument.addPage(page);
|
||||
}
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
rearrangedDocument,
|
||||
GeneralUtils.generateFilename(
|
||||
pdfFile.getOriginalFilename(), "_rearranged.pdf"));
|
||||
} catch (IOException e) {
|
||||
ExceptionUtils.logException("document rearrangement", e);
|
||||
throw e;
|
||||
|
||||
+12
-11
@@ -47,19 +47,20 @@ public class RotationController {
|
||||
"error.angleNotMultipleOf90", "Angle must be a multiple of 90");
|
||||
}
|
||||
|
||||
// Load the PDF document
|
||||
PDDocument document = pdfDocumentFactory.load(request);
|
||||
// Load the PDF document with proper resource management
|
||||
try (PDDocument document = pdfDocumentFactory.load(request)) {
|
||||
|
||||
// Get the list of pages in the document
|
||||
PDPageTree pages = document.getPages();
|
||||
// Get the list of pages in the document
|
||||
PDPageTree pages = document.getPages();
|
||||
|
||||
for (PDPage page : pages) {
|
||||
page.setRotation(page.getRotation() + angle);
|
||||
for (PDPage page : pages) {
|
||||
page.setRotation(page.getRotation() + angle);
|
||||
}
|
||||
|
||||
// Return the rotated PDF as a response
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_rotated.pdf"));
|
||||
}
|
||||
|
||||
// Return the rotated PDF as a response
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_rotated.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
+7
-4
@@ -32,16 +32,19 @@ public class SettingsController {
|
||||
|
||||
@AutoJobPostMapping("/update-enable-analytics")
|
||||
@Hidden
|
||||
public ResponseEntity<String> updateApiKey(@RequestParam Boolean enabled) throws IOException {
|
||||
public ResponseEntity<Map<String, Object>> updateApiKey(@RequestParam Boolean enabled)
|
||||
throws IOException {
|
||||
if (applicationProperties.getSystem().getEnableAnalytics() != null) {
|
||||
return ResponseEntity.status(HttpStatus.ALREADY_REPORTED)
|
||||
.body(
|
||||
"Setting has already been set, To adjust please edit "
|
||||
+ InstallationPathConfig.getSettingsPath());
|
||||
Map.of(
|
||||
"message",
|
||||
"Setting has already been set, To adjust please edit "
|
||||
+ InstallationPathConfig.getSettingsPath()));
|
||||
}
|
||||
GeneralUtils.saveKeyToSettings("system.enableAnalytics", enabled);
|
||||
applicationProperties.getSystem().setEnableAnalytics(enabled);
|
||||
return ResponseEntity.ok("Updated");
|
||||
return ResponseEntity.ok(Map.of("message", "Updated"));
|
||||
}
|
||||
|
||||
@GetMapping("/get-endpoints-status")
|
||||
|
||||
+217
-131
@@ -2,9 +2,9 @@ package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.*;
|
||||
import java.util.stream.IntStream;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@@ -19,38 +19,37 @@ import org.apache.pdfbox.util.Matrix;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.MultiFileResponse;
|
||||
import stirling.software.SPDF.model.SplitTypes;
|
||||
import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.PDFService;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@GeneralApi
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class SplitPdfBySectionsController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final PDFService pdfService;
|
||||
|
||||
@PostMapping(value = "/split-pdf-by-sections", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/split-pdf-by-sections")
|
||||
@MultiFileResponse
|
||||
@Operation(
|
||||
summary = "Split PDF pages into smaller sections",
|
||||
description =
|
||||
@@ -58,10 +57,8 @@ public class SplitPdfBySectionsController {
|
||||
+ " which page to split, and how to split"
|
||||
+ " ( halves, thirds, quarters, etc.), both vertically and horizontally."
|
||||
+ " Input:PDF Output:ZIP-PDF Type:SISO")
|
||||
public ResponseEntity<StreamingResponseBody> splitPdf(
|
||||
@ModelAttribute SplitPdfBySectionsRequest request) throws Exception {
|
||||
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
|
||||
|
||||
public ResponseEntity<byte[]> splitPdf(@ModelAttribute SplitPdfBySectionsRequest request)
|
||||
throws Exception {
|
||||
MultipartFile file = request.getFileInput();
|
||||
String pageNumbers = request.getPageNumbers();
|
||||
SplitTypes splitMode =
|
||||
@@ -69,55 +66,216 @@ public class SplitPdfBySectionsController {
|
||||
.map(SplitTypes::valueOf)
|
||||
.orElse(SplitTypes.SPLIT_ALL);
|
||||
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
|
||||
Set<Integer> pagesToSplit =
|
||||
getPagesToSplit(pageNumbers, splitMode, sourceDocument.getNumberOfPages());
|
||||
|
||||
Set<Integer> pagesToSplit =
|
||||
getPagesToSplit(pageNumbers, splitMode, sourceDocument.getNumberOfPages());
|
||||
// Process the PDF based on split parameters
|
||||
int horiz = request.getHorizontalDivisions() + 1;
|
||||
int verti = request.getVerticalDivisions() + 1;
|
||||
boolean merge = Boolean.TRUE.equals(request.getMerge());
|
||||
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "_split");
|
||||
|
||||
// Process the PDF based on split parameters
|
||||
int horiz = request.getHorizontalDivisions() + 1;
|
||||
int verti = request.getVerticalDivisions() + 1;
|
||||
boolean merge = Boolean.TRUE.equals(request.getMerge());
|
||||
List<PDDocument> splitDocuments = splitPdfPages(sourceDocument, verti, horiz, pagesToSplit);
|
||||
|
||||
String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), "_split.pdf");
|
||||
if (merge) {
|
||||
TempFile tempFile = new TempFile(tempFileManager, ".pdf");
|
||||
try (PDDocument merged = pdfService.mergeDocuments(splitDocuments);
|
||||
OutputStream out = Files.newOutputStream(tempFile.getPath())) {
|
||||
merged.save(out);
|
||||
for (PDDocument d : splitDocuments) d.close();
|
||||
sourceDocument.close();
|
||||
if (merge) {
|
||||
try (PDDocument mergedDoc =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(
|
||||
sourceDocument);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
LayerUtility layerUtility = new LayerUtility(mergedDoc);
|
||||
for (int pageIndex = 0;
|
||||
pageIndex < sourceDocument.getNumberOfPages();
|
||||
pageIndex++) {
|
||||
if (pagesToSplit.contains(pageIndex)) {
|
||||
addSplitPageToTarget(
|
||||
sourceDocument,
|
||||
pageIndex,
|
||||
mergedDoc,
|
||||
layerUtility,
|
||||
horiz,
|
||||
verti);
|
||||
} else {
|
||||
addPageToTarget(sourceDocument, pageIndex, mergedDoc, layerUtility);
|
||||
}
|
||||
}
|
||||
mergedDoc.save(baos);
|
||||
return WebResponseUtils.baosToWebResponse(baos, filename + ".pdf");
|
||||
}
|
||||
} else {
|
||||
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
|
||||
try (ZipOutputStream zipOut =
|
||||
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
|
||||
for (int pageIndex = 0;
|
||||
pageIndex < sourceDocument.getNumberOfPages();
|
||||
pageIndex++) {
|
||||
int pageNum = pageIndex + 1;
|
||||
if (pagesToSplit.contains(pageIndex)) {
|
||||
for (int i = 0; i < horiz; i++) {
|
||||
for (int j = 0; j < verti; j++) {
|
||||
try (PDDocument subDoc =
|
||||
pdfDocumentFactory.createNewDocument()) {
|
||||
LayerUtility subLayerUtility = new LayerUtility(subDoc);
|
||||
addSingleSectionToTarget(
|
||||
sourceDocument,
|
||||
pageIndex,
|
||||
subDoc,
|
||||
subLayerUtility,
|
||||
i,
|
||||
j,
|
||||
horiz,
|
||||
verti);
|
||||
int sectionNum = i * verti + j + 1;
|
||||
String entryName =
|
||||
filename
|
||||
+ "_"
|
||||
+ pageNum
|
||||
+ "_"
|
||||
+ sectionNum
|
||||
+ ".pdf";
|
||||
saveDocToZip(subDoc, zipOut, entryName);
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
"Error creating section {} for page {}",
|
||||
(i * verti + j + 1),
|
||||
pageNum,
|
||||
e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try (PDDocument subDoc = pdfDocumentFactory.createNewDocument()) {
|
||||
LayerUtility subLayerUtility = new LayerUtility(subDoc);
|
||||
addPageToTarget(sourceDocument, pageIndex, subDoc, subLayerUtility);
|
||||
String entryName = filename + "_" + pageNum + "_1.pdf";
|
||||
saveDocToZip(subDoc, zipOut, entryName);
|
||||
} catch (IOException e) {
|
||||
log.error("Error processing unsplit page {}", pageNum, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Error creating ZIP file with split PDF sections", e);
|
||||
throw e;
|
||||
}
|
||||
byte[] zipBytes = Files.readAllBytes(zipTempFile.getPath());
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
zipBytes, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
return WebResponseUtils.pdfFileToWebResponse(tempFile, filename + "_split.pdf");
|
||||
} catch (Exception e) {
|
||||
log.error("Error splitting PDF file: {}", file.getOriginalFilename(), e);
|
||||
throw e;
|
||||
}
|
||||
for (PDDocument doc : splitDocuments) {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
doc.save(baos);
|
||||
doc.close();
|
||||
splitDocumentsBoas.add(baos);
|
||||
}
|
||||
|
||||
private void addPageToTarget(
|
||||
PDDocument sourceDoc, int pageIndex, PDDocument targetDoc, LayerUtility layerUtility)
|
||||
throws IOException {
|
||||
PDPage sourcePage = sourceDoc.getPage(pageIndex);
|
||||
PDPage newPage = new PDPage(sourcePage.getMediaBox());
|
||||
targetDoc.addPage(newPage);
|
||||
|
||||
PDFormXObject form = layerUtility.importPageAsForm(sourceDoc, pageIndex);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(targetDoc, newPage, AppendMode.APPEND, true, true)) {
|
||||
contentStream.drawForm(form);
|
||||
} catch (IOException e) {
|
||||
log.error("Error adding page {} to target document", pageIndex, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
sourceDocument.close();
|
||||
private void addSplitPageToTarget(
|
||||
PDDocument sourceDoc,
|
||||
int pageIndex,
|
||||
PDDocument targetDoc,
|
||||
LayerUtility layerUtility,
|
||||
int totalHoriz,
|
||||
int totalVert)
|
||||
throws IOException {
|
||||
PDPage sourcePage = sourceDoc.getPage(pageIndex);
|
||||
PDRectangle mediaBox = sourcePage.getMediaBox();
|
||||
float width = mediaBox.getWidth();
|
||||
float height = mediaBox.getHeight();
|
||||
float subPageWidth = width / totalHoriz;
|
||||
float subPageHeight = height / totalVert;
|
||||
|
||||
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
|
||||
try (ZipOutputStream zipOut =
|
||||
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
|
||||
int pageNum = 1;
|
||||
for (int i = 0; i < splitDocumentsBoas.size(); i++) {
|
||||
ByteArrayOutputStream baos = splitDocumentsBoas.get(i);
|
||||
int sectionNum = (i % (horiz * verti)) + 1;
|
||||
String fileName = filename + "_" + pageNum + "_" + sectionNum + ".pdf";
|
||||
byte[] pdf = baos.toByteArray();
|
||||
ZipEntry pdfEntry = new ZipEntry(fileName);
|
||||
zipOut.putNextEntry(pdfEntry);
|
||||
zipOut.write(pdf);
|
||||
zipOut.closeEntry();
|
||||
PDFormXObject form = layerUtility.importPageAsForm(sourceDoc, pageIndex);
|
||||
|
||||
if (sectionNum == horiz * verti) pageNum++;
|
||||
for (int i = 0; i < totalHoriz; i++) {
|
||||
for (int j = 0; j < totalVert; j++) {
|
||||
PDPage subPage = new PDPage(new PDRectangle(subPageWidth, subPageHeight));
|
||||
targetDoc.addPage(subPage);
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
targetDoc, subPage, AppendMode.APPEND, true, true)) {
|
||||
float translateX = -subPageWidth * i;
|
||||
float translateY = -subPageHeight * (totalVert - 1 - j);
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.addRect(0, 0, subPageWidth, subPageHeight);
|
||||
contentStream.clip();
|
||||
contentStream.transform(new Matrix(1, 0, 0, 1, translateX, translateY));
|
||||
contentStream.drawForm(form);
|
||||
contentStream.restoreGraphicsState();
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
"Error adding split section ({}, {}) for page {}", i, j, pageIndex, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + "_split.zip");
|
||||
}
|
||||
|
||||
private void addSingleSectionToTarget(
|
||||
PDDocument sourceDoc,
|
||||
int pageIndex,
|
||||
PDDocument targetDoc,
|
||||
LayerUtility layerUtility,
|
||||
int horizIndex,
|
||||
int vertIndex,
|
||||
int totalHoriz,
|
||||
int totalVert)
|
||||
throws IOException {
|
||||
PDPage sourcePage = sourceDoc.getPage(pageIndex);
|
||||
PDRectangle mediaBox = sourcePage.getMediaBox();
|
||||
float subPageWidth = mediaBox.getWidth() / totalHoriz;
|
||||
float subPageHeight = mediaBox.getHeight() / totalVert;
|
||||
|
||||
PDPage subPage = new PDPage(new PDRectangle(subPageWidth, subPageHeight));
|
||||
targetDoc.addPage(subPage);
|
||||
|
||||
PDFormXObject form = layerUtility.importPageAsForm(sourceDoc, pageIndex);
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(targetDoc, subPage, AppendMode.APPEND, true, true)) {
|
||||
float translateX = -subPageWidth * horizIndex;
|
||||
float translateY = -subPageHeight * (totalVert - 1 - vertIndex);
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.addRect(0, 0, subPageWidth, subPageHeight);
|
||||
contentStream.clip();
|
||||
contentStream.transform(new Matrix(1, 0, 0, 1, translateX, translateY));
|
||||
contentStream.drawForm(form);
|
||||
contentStream.restoreGraphicsState();
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
"Error adding single section ({}, {}) for page {} to target",
|
||||
horizIndex,
|
||||
vertIndex,
|
||||
pageIndex,
|
||||
e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private void saveDocToZip(PDDocument doc, ZipOutputStream zipOut, String entryName)
|
||||
throws IOException {
|
||||
ZipEntry entry = new ZipEntry(entryName);
|
||||
zipOut.putNextEntry(entry);
|
||||
doc.save(zipOut);
|
||||
zipOut.closeEntry();
|
||||
}
|
||||
|
||||
// Based on the mode, get the pages that need to be split and return the pages set
|
||||
@@ -140,27 +298,19 @@ public class SplitPdfBySectionsController {
|
||||
break;
|
||||
|
||||
case SPLIT_ALL:
|
||||
for (int i = 0; i < totalPages; i++) {
|
||||
pagesToSplit.add(i);
|
||||
}
|
||||
pagesToSplit.addAll(IntStream.range(0, totalPages).boxed().toList());
|
||||
break;
|
||||
|
||||
case SPLIT_ALL_EXCEPT_FIRST:
|
||||
for (int i = 1; i < totalPages; i++) {
|
||||
pagesToSplit.add(i);
|
||||
}
|
||||
pagesToSplit.addAll(IntStream.range(1, totalPages).boxed().toList());
|
||||
break;
|
||||
|
||||
case SPLIT_ALL_EXCEPT_LAST:
|
||||
for (int i = 0; i < totalPages - 1; i++) {
|
||||
pagesToSplit.add(i);
|
||||
}
|
||||
pagesToSplit.addAll(IntStream.range(0, totalPages - 1).boxed().toList());
|
||||
break;
|
||||
|
||||
case SPLIT_ALL_EXCEPT_FIRST_AND_LAST:
|
||||
for (int i = 1; i < totalPages - 1; i++) {
|
||||
pagesToSplit.add(i);
|
||||
}
|
||||
pagesToSplit.addAll(IntStream.range(1, totalPages - 1).boxed().toList());
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -170,68 +320,4 @@ public class SplitPdfBySectionsController {
|
||||
|
||||
return pagesToSplit;
|
||||
}
|
||||
|
||||
public List<PDDocument> splitPdfPages(
|
||||
PDDocument document,
|
||||
int horizontalDivisions,
|
||||
int verticalDivisions,
|
||||
Set<Integer> pagesToSplit)
|
||||
throws IOException {
|
||||
List<PDDocument> splitDocuments = new ArrayList<>();
|
||||
|
||||
int pageIndex = 0;
|
||||
for (PDPage originalPage : document.getPages()) {
|
||||
// If current page is not to split, add it to the splitDocuments directly.
|
||||
if (!pagesToSplit.contains(pageIndex)) {
|
||||
PDDocument newDoc = pdfDocumentFactory.createNewDocument();
|
||||
newDoc.addPage(originalPage);
|
||||
splitDocuments.add(newDoc);
|
||||
} else {
|
||||
// Otherwise, split current page.
|
||||
PDRectangle originalMediaBox = originalPage.getMediaBox();
|
||||
float width = originalMediaBox.getWidth();
|
||||
float height = originalMediaBox.getHeight();
|
||||
float subPageWidth = width / horizontalDivisions;
|
||||
float subPageHeight = height / verticalDivisions;
|
||||
|
||||
LayerUtility layerUtility = new LayerUtility(document);
|
||||
|
||||
for (int i = 0; i < horizontalDivisions; i++) {
|
||||
for (int j = 0; j < verticalDivisions; j++) {
|
||||
PDDocument subDoc = new PDDocument();
|
||||
PDPage subPage = new PDPage(new PDRectangle(subPageWidth, subPageHeight));
|
||||
subDoc.addPage(subPage);
|
||||
|
||||
PDFormXObject form =
|
||||
layerUtility.importPageAsForm(
|
||||
document, document.getPages().indexOf(originalPage));
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
subDoc, subPage, AppendMode.APPEND, true, true)) {
|
||||
// Set clipping area and position
|
||||
float translateX = -subPageWidth * i;
|
||||
|
||||
// float translateY = height - subPageHeight * (verticalDivisions - j);
|
||||
float translateY = -subPageHeight * (verticalDivisions - 1 - j);
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.addRect(0, 0, subPageWidth, subPageHeight);
|
||||
contentStream.clip();
|
||||
contentStream.transform(new Matrix(1, 0, 0, 1, translateX, translateY));
|
||||
|
||||
// Draw the form
|
||||
contentStream.drawForm(form);
|
||||
contentStream.restoreGraphicsState();
|
||||
}
|
||||
|
||||
splitDocuments.add(subDoc);
|
||||
}
|
||||
}
|
||||
}
|
||||
pageIndex++;
|
||||
}
|
||||
|
||||
return splitDocuments;
|
||||
}
|
||||
}
|
||||
|
||||
+291
-237
@@ -124,124 +124,168 @@ public class SplitPdfBySizeController {
|
||||
throws IOException {
|
||||
log.debug("Starting handleSplitBySize with maxBytes={}", maxBytes);
|
||||
|
||||
PDDocument currentDoc =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
int fileIndex = 1;
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
int pageAdded = 0;
|
||||
class DocHolder implements AutoCloseable {
|
||||
private PDDocument doc;
|
||||
|
||||
// Smart size check frequency - check more often with larger documents
|
||||
int baseCheckFrequency = 5;
|
||||
public DocHolder(PDDocument doc) {
|
||||
this.doc = doc;
|
||||
}
|
||||
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
PDPage page = sourceDocument.getPage(pageIndex);
|
||||
log.debug("Processing page {} of {}", pageIndex + 1, totalPages);
|
||||
public PDDocument getDoc() {
|
||||
return doc;
|
||||
}
|
||||
|
||||
// Add the page to current document
|
||||
PDPage newPage = new PDPage(page.getCOSObject());
|
||||
currentDoc.addPage(newPage);
|
||||
pageAdded++;
|
||||
|
||||
// Dynamic size checking based on document size and page count
|
||||
boolean shouldCheckSize =
|
||||
(pageAdded % baseCheckFrequency == 0)
|
||||
|| (pageIndex == totalPages - 1)
|
||||
|| (pageAdded >= 20); // Always check after 20 pages
|
||||
|
||||
if (shouldCheckSize) {
|
||||
log.debug("Performing size check after {} pages", pageAdded);
|
||||
ByteArrayOutputStream checkSizeStream = new ByteArrayOutputStream();
|
||||
currentDoc.save(checkSizeStream);
|
||||
long actualSize = checkSizeStream.size();
|
||||
log.debug("Current document size: {} bytes (max: {} bytes)", actualSize, maxBytes);
|
||||
|
||||
if (actualSize > maxBytes) {
|
||||
// We exceeded the limit - remove the last page and save
|
||||
if (currentDoc.getNumberOfPages() > 1) {
|
||||
currentDoc.removePage(currentDoc.getNumberOfPages() - 1);
|
||||
pageIndex--; // Process this page again in the next document
|
||||
log.debug("Size limit exceeded - removed last page");
|
||||
public void setDoc(PDDocument doc) {
|
||||
if (this.doc != null) {
|
||||
try {
|
||||
this.doc.close();
|
||||
} catch (IOException e) {
|
||||
log.error("Error closing document", e);
|
||||
}
|
||||
}
|
||||
this.doc = doc;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (doc != null) {
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int fileIndex = 1;
|
||||
try (DocHolder holder =
|
||||
new DocHolder(
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument))) {
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
int pageAdded = 0;
|
||||
|
||||
// Smart size check frequency - check more often with larger documents
|
||||
int baseCheckFrequency = 5;
|
||||
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
PDPage page = sourceDocument.getPage(pageIndex);
|
||||
log.debug("Processing page {} of {}", pageIndex + 1, totalPages);
|
||||
|
||||
// Add the page to current document
|
||||
PDPage newPage = new PDPage(page.getCOSObject());
|
||||
holder.getDoc().addPage(newPage);
|
||||
pageAdded++;
|
||||
|
||||
// Dynamic size checking based on document size and page count
|
||||
boolean shouldCheckSize =
|
||||
(pageAdded % baseCheckFrequency == 0)
|
||||
|| (pageIndex == totalPages - 1)
|
||||
|| (pageAdded >= 20); // Always check after 20 pages
|
||||
|
||||
if (shouldCheckSize) {
|
||||
log.debug("Performing size check after {} pages", pageAdded);
|
||||
long actualSize;
|
||||
try (ByteArrayOutputStream checkSizeStream = new ByteArrayOutputStream()) {
|
||||
holder.getDoc().save(checkSizeStream);
|
||||
actualSize = checkSizeStream.size();
|
||||
}
|
||||
log.debug(
|
||||
"Saving document with {} pages as part {}",
|
||||
currentDoc.getNumberOfPages(),
|
||||
fileIndex);
|
||||
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
|
||||
currentDoc = new PDDocument();
|
||||
pageAdded = 0;
|
||||
} else if (pageIndex < totalPages - 1) {
|
||||
// We're under the limit, calculate if we might fit more pages
|
||||
// Try to predict how many more similar pages might fit
|
||||
if (actualSize < maxBytes * 0.75 && pageAdded > 0) {
|
||||
// Rather than using a ratio, look ahead to test actual upcoming pages
|
||||
int pagesToLookAhead = Math.min(5, totalPages - pageIndex - 1);
|
||||
"Current document size: {} bytes (max: {} bytes)",
|
||||
actualSize,
|
||||
maxBytes);
|
||||
|
||||
if (pagesToLookAhead > 0) {
|
||||
log.debug(
|
||||
"Testing {} upcoming pages for potential addition",
|
||||
pagesToLookAhead);
|
||||
if (actualSize > maxBytes) {
|
||||
// We exceeded the limit - remove the last page and save
|
||||
if (holder.getDoc().getNumberOfPages() > 1) {
|
||||
holder.getDoc().removePage(holder.getDoc().getNumberOfPages() - 1);
|
||||
pageIndex--; // Process this page again in the next document
|
||||
log.debug("Size limit exceeded - removed last page");
|
||||
}
|
||||
|
||||
// Create a temp document with current pages + look-ahead pages
|
||||
PDDocument testDoc = new PDDocument();
|
||||
// First copy existing pages
|
||||
for (int i = 0; i < currentDoc.getNumberOfPages(); i++) {
|
||||
testDoc.addPage(new PDPage(currentDoc.getPage(i).getCOSObject()));
|
||||
}
|
||||
log.debug(
|
||||
"Saving document with {} pages as part {}",
|
||||
holder.getDoc().getNumberOfPages(),
|
||||
fileIndex);
|
||||
saveDocumentToZip(holder.getDoc(), zipOut, baseFilename, fileIndex++);
|
||||
holder.setDoc(new PDDocument());
|
||||
pageAdded = 0;
|
||||
} else if (pageIndex < totalPages - 1) {
|
||||
// We're under the limit, calculate if we might fit more pages
|
||||
// Try to predict how many more similar pages might fit
|
||||
if (actualSize < maxBytes * 0.75 && pageAdded > 0) {
|
||||
// Rather than using a ratio, look ahead to test actual upcoming pages
|
||||
int pagesToLookAhead = Math.min(5, totalPages - pageIndex - 1);
|
||||
|
||||
// Try adding look-ahead pages one by one
|
||||
int extraPagesAdded = 0;
|
||||
for (int i = 0; i < pagesToLookAhead; i++) {
|
||||
int testPageIndex = pageIndex + 1 + i;
|
||||
PDPage testPage = sourceDocument.getPage(testPageIndex);
|
||||
testDoc.addPage(new PDPage(testPage.getCOSObject()));
|
||||
if (pagesToLookAhead > 0) {
|
||||
log.debug(
|
||||
"Testing {} upcoming pages for potential addition",
|
||||
pagesToLookAhead);
|
||||
|
||||
// Check if we're still under size
|
||||
ByteArrayOutputStream testStream = new ByteArrayOutputStream();
|
||||
testDoc.save(testStream);
|
||||
long testSize = testStream.size();
|
||||
// Create a temp document with current pages + look-ahead pages
|
||||
try (PDDocument testDoc = new PDDocument()) {
|
||||
// First copy existing pages
|
||||
for (int i = 0; i < holder.getDoc().getNumberOfPages(); i++) {
|
||||
testDoc.addPage(
|
||||
new PDPage(
|
||||
holder.getDoc().getPage(i).getCOSObject()));
|
||||
}
|
||||
|
||||
if (testSize <= maxBytes) {
|
||||
extraPagesAdded++;
|
||||
log.debug(
|
||||
"Test: Can add page {} (size would be {})",
|
||||
testPageIndex + 1,
|
||||
testSize);
|
||||
} else {
|
||||
log.debug(
|
||||
"Test: Cannot add page {} (size would be {})",
|
||||
testPageIndex + 1,
|
||||
testSize);
|
||||
break;
|
||||
// Try adding look-ahead pages one by one
|
||||
int extraPagesAdded = 0;
|
||||
for (int i = 0; i < pagesToLookAhead; i++) {
|
||||
int testPageIndex = pageIndex + 1 + i;
|
||||
PDPage testPage = sourceDocument.getPage(testPageIndex);
|
||||
testDoc.addPage(new PDPage(testPage.getCOSObject()));
|
||||
|
||||
// Check if we're still under size
|
||||
long testSize;
|
||||
try (ByteArrayOutputStream testStream =
|
||||
new ByteArrayOutputStream()) {
|
||||
testDoc.save(testStream);
|
||||
testSize = testStream.size();
|
||||
}
|
||||
|
||||
if (testSize <= maxBytes) {
|
||||
extraPagesAdded++;
|
||||
log.debug(
|
||||
"Test: Can add page {} (size would be {})",
|
||||
testPageIndex + 1,
|
||||
testSize);
|
||||
} else {
|
||||
log.debug(
|
||||
"Test: Cannot add page {} (size would be {})",
|
||||
testPageIndex + 1,
|
||||
testSize);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Add the pages we verified would fit
|
||||
if (extraPagesAdded > 0) {
|
||||
log.debug(
|
||||
"Adding {} verified pages ahead", extraPagesAdded);
|
||||
for (int i = 0; i < extraPagesAdded; i++) {
|
||||
int extraPageIndex = pageIndex + 1 + i;
|
||||
PDPage extraPage =
|
||||
sourceDocument.getPage(extraPageIndex);
|
||||
holder.getDoc()
|
||||
.addPage(new PDPage(extraPage.getCOSObject()));
|
||||
}
|
||||
pageIndex += extraPagesAdded;
|
||||
pageAdded += extraPagesAdded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testDoc.close();
|
||||
|
||||
// Add the pages we verified would fit
|
||||
if (extraPagesAdded > 0) {
|
||||
log.debug("Adding {} verified pages ahead", extraPagesAdded);
|
||||
for (int i = 0; i < extraPagesAdded; i++) {
|
||||
int extraPageIndex = pageIndex + 1 + i;
|
||||
PDPage extraPage = sourceDocument.getPage(extraPageIndex);
|
||||
currentDoc.addPage(new PDPage(extraPage.getCOSObject()));
|
||||
}
|
||||
pageIndex += extraPagesAdded;
|
||||
pageAdded += extraPagesAdded;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save final document if it has any pages
|
||||
if (currentDoc.getNumberOfPages() > 0) {
|
||||
log.debug(
|
||||
"Saving final document with {} pages as part {}",
|
||||
currentDoc.getNumberOfPages(),
|
||||
fileIndex);
|
||||
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
|
||||
// Save final document if it has any pages
|
||||
if (holder.getDoc() != null && holder.getDoc().getNumberOfPages() > 0) {
|
||||
log.debug(
|
||||
"Saving final document with {} pages as part {}",
|
||||
holder.getDoc().getNumberOfPages(),
|
||||
fileIndex);
|
||||
saveDocumentToZip(holder.getDoc(), zipOut, baseFilename, fileIndex++);
|
||||
holder.setDoc(null);
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("Completed handleSplitBySize with {} document parts created", fileIndex - 1);
|
||||
@@ -252,96 +296,103 @@ public class SplitPdfBySizeController {
|
||||
throws IOException {
|
||||
log.debug("Starting handleSplitByPageCount with pageCount={}", pageCount);
|
||||
int currentPageCount = 0;
|
||||
log.debug("Creating initial output document");
|
||||
PDDocument currentDoc;
|
||||
try {
|
||||
currentDoc = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
log.debug("Successfully created initial output document");
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("initial output document creation", e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
|
||||
PDDocument currentDoc = null;
|
||||
int fileIndex = 1;
|
||||
int pageIndex = 0;
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
log.debug("Processing {} pages", totalPages);
|
||||
|
||||
try {
|
||||
for (PDPage page : sourceDocument.getPages()) {
|
||||
pageIndex++;
|
||||
log.debug("Processing page {} of {}", pageIndex, totalPages);
|
||||
log.debug("Creating initial output document");
|
||||
try {
|
||||
currentDoc = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
log.debug("Successfully created initial output document");
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("initial output document creation", e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("Adding page {} to current document", pageIndex);
|
||||
currentDoc.addPage(page);
|
||||
log.debug("Successfully added page {} to current document", pageIndex);
|
||||
} catch (Exception e) {
|
||||
log.error("Error adding page {} to current document", pageIndex, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
int pageIndex = 0;
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
log.debug("Processing {} pages", totalPages);
|
||||
|
||||
currentPageCount++;
|
||||
log.debug("Current page count: {}/{}", currentPageCount, pageCount);
|
||||
|
||||
if (currentPageCount == pageCount) {
|
||||
log.debug(
|
||||
"Reached target page count ({}), saving current document as part {}",
|
||||
pageCount,
|
||||
fileIndex);
|
||||
try {
|
||||
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
|
||||
log.debug("Successfully saved document part {}", fileIndex - 1);
|
||||
} catch (Exception e) {
|
||||
log.error("Error saving document part {}", fileIndex - 1, e);
|
||||
throw e;
|
||||
}
|
||||
try {
|
||||
for (PDPage page : sourceDocument.getPages()) {
|
||||
pageIndex++;
|
||||
log.debug("Processing page {} of {}", pageIndex, totalPages);
|
||||
|
||||
try {
|
||||
log.debug("Creating new document for next part");
|
||||
currentDoc = new PDDocument();
|
||||
log.debug("Successfully created new document");
|
||||
log.debug("Adding page {} to current document", pageIndex);
|
||||
currentDoc.addPage(page);
|
||||
log.debug("Successfully added page {} to current document", pageIndex);
|
||||
} catch (Exception e) {
|
||||
log.error("Error creating new document for next part", e);
|
||||
log.error("Error adding page {} to current document", pageIndex, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
|
||||
currentPageCount = 0;
|
||||
log.debug("Reset current page count to 0");
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error iterating through pages", e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
currentPageCount++;
|
||||
log.debug("Current page count: {}/{}", currentPageCount, pageCount);
|
||||
|
||||
// Add the last document if it contains any pages
|
||||
try {
|
||||
if (currentDoc.getPages().getCount() != 0) {
|
||||
log.debug(
|
||||
"Saving final document with {} pages as part {}",
|
||||
currentDoc.getPages().getCount(),
|
||||
fileIndex);
|
||||
try {
|
||||
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
|
||||
log.debug("Successfully saved final document part {}", fileIndex - 1);
|
||||
} catch (Exception e) {
|
||||
log.error("Error saving final document part {}", fileIndex - 1, e);
|
||||
throw e;
|
||||
if (currentPageCount == pageCount) {
|
||||
log.debug(
|
||||
"Reached target page count ({}), saving current document as part {}",
|
||||
pageCount,
|
||||
fileIndex);
|
||||
try {
|
||||
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
|
||||
currentDoc = null; // Document is closed by saveDocumentToZip
|
||||
log.debug("Successfully saved document part {}", fileIndex - 1);
|
||||
} catch (Exception e) {
|
||||
log.error("Error saving document part {}", fileIndex - 1, e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("Creating new document for next part");
|
||||
currentDoc = new PDDocument();
|
||||
log.debug("Successfully created new document");
|
||||
} catch (Exception e) {
|
||||
log.error("Error creating new document for next part", e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
|
||||
currentPageCount = 0;
|
||||
log.debug("Reset current page count to 0");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.debug("Final document has no pages, skipping");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error checking or saving final document", e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
} finally {
|
||||
try {
|
||||
log.debug("Closing final document");
|
||||
currentDoc.close();
|
||||
log.debug("Successfully closed final document");
|
||||
} catch (Exception e) {
|
||||
log.error("Error closing final document", e);
|
||||
log.error("Error iterating through pages", e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
|
||||
// Add the last document if it contains any pages
|
||||
try {
|
||||
if (currentDoc != null && currentDoc.getPages().getCount() != 0) {
|
||||
log.debug(
|
||||
"Saving final document with {} pages as part {}",
|
||||
currentDoc.getPages().getCount(),
|
||||
fileIndex);
|
||||
try {
|
||||
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
|
||||
currentDoc = null; // Document is closed by saveDocumentToZip
|
||||
log.debug("Successfully saved final document part {}", fileIndex - 1);
|
||||
} catch (Exception e) {
|
||||
log.error("Error saving final document part {}", fileIndex - 1, e);
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
log.debug("Final document has no pages, skipping");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error checking or saving final document", e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
} finally {
|
||||
if (currentDoc != null) {
|
||||
try {
|
||||
log.debug("Closing remaining document");
|
||||
currentDoc.close();
|
||||
log.debug("Successfully closed remaining document");
|
||||
} catch (Exception e) {
|
||||
log.error("Error closing remaining document", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,42 +418,52 @@ public class SplitPdfBySizeController {
|
||||
|
||||
for (int i = 0; i < documentCount; i++) {
|
||||
log.debug("Creating document {} of {}", i + 1, documentCount);
|
||||
PDDocument currentDoc;
|
||||
PDDocument currentDoc = null;
|
||||
try {
|
||||
currentDoc = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
log.debug("Successfully created document {} of {}", i + 1, documentCount);
|
||||
|
||||
int pagesToAdd = pagesPerDocument + (i < extraPages ? 1 : 0);
|
||||
log.debug("Adding {} pages to document {}", pagesToAdd, i + 1);
|
||||
|
||||
for (int j = 0; j < pagesToAdd; j++) {
|
||||
try {
|
||||
log.debug(
|
||||
"Adding page {} (index {}) to document {}",
|
||||
j + 1,
|
||||
currentPageIndex,
|
||||
i + 1);
|
||||
currentDoc.addPage(sourceDocument.getPage(currentPageIndex));
|
||||
log.debug("Successfully added page {} to document {}", j + 1, i + 1);
|
||||
currentPageIndex++;
|
||||
} catch (Exception e) {
|
||||
log.error("Error adding page {} to document {}", j + 1, i + 1, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("Saving document {} with {} pages", i + 1, pagesToAdd);
|
||||
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
|
||||
// saveDocumentToZip closes the document
|
||||
currentDoc = null;
|
||||
log.debug("Successfully saved document {}", i + 1);
|
||||
} catch (Exception e) {
|
||||
log.error("Error saving document {}", i + 1, e);
|
||||
throw e;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error creating document {} of {}", i + 1, documentCount, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
|
||||
int pagesToAdd = pagesPerDocument + (i < extraPages ? 1 : 0);
|
||||
log.debug("Adding {} pages to document {}", pagesToAdd, i + 1);
|
||||
|
||||
for (int j = 0; j < pagesToAdd; j++) {
|
||||
try {
|
||||
log.debug(
|
||||
"Adding page {} (index {}) to document {}",
|
||||
j + 1,
|
||||
currentPageIndex,
|
||||
i + 1);
|
||||
currentDoc.addPage(sourceDocument.getPage(currentPageIndex));
|
||||
log.debug("Successfully added page {} to document {}", j + 1, i + 1);
|
||||
currentPageIndex++;
|
||||
} catch (Exception e) {
|
||||
log.error("Error adding page {} to document {}", j + 1, i + 1, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
} finally {
|
||||
if (currentDoc != null) {
|
||||
try {
|
||||
currentDoc.close();
|
||||
} catch (IOException e) {
|
||||
log.error("Error closing document {} of {}", i + 1, documentCount, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("Saving document {} with {} pages", i + 1, pagesToAdd);
|
||||
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
|
||||
log.debug("Successfully saved document {}", i + 1);
|
||||
} catch (Exception e) {
|
||||
log.error("Error saving document {}", i + 1, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("Completed handleSplitByDocCount with {} documents created", documentCount);
|
||||
@@ -412,43 +473,36 @@ public class SplitPdfBySizeController {
|
||||
PDDocument document, ZipOutputStream zipOut, String baseFilename, int index)
|
||||
throws IOException {
|
||||
log.debug("Starting saveDocumentToZip for document part {}", index);
|
||||
ByteArrayOutputStream outStream = new ByteArrayOutputStream();
|
||||
try (ByteArrayOutputStream outStream = new ByteArrayOutputStream()) {
|
||||
|
||||
try {
|
||||
log.debug("Saving document part {} to byte array", index);
|
||||
document.save(outStream);
|
||||
log.debug("Successfully saved document part {} ({} bytes)", index, outStream.size());
|
||||
} catch (Exception e) {
|
||||
log.error("Error saving document part {} to byte array", index, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
try (PDDocument doc = document) {
|
||||
log.debug("Saving document part {} to byte array", index);
|
||||
doc.save(outStream);
|
||||
log.debug(
|
||||
"Successfully saved document part {} ({} bytes)", index, outStream.size());
|
||||
} catch (Exception e) {
|
||||
log.error("Error saving document part {} to byte array", index, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("Closing document part {}", index);
|
||||
document.close();
|
||||
log.debug("Successfully closed document part {}", index);
|
||||
} catch (Exception e) {
|
||||
log.error("Error closing document part {}", index, e);
|
||||
// Continue despite close error
|
||||
}
|
||||
try {
|
||||
// Create a new zip entry
|
||||
String entryName = baseFilename + "_" + index + ".pdf";
|
||||
log.debug("Creating ZIP entry: {}", entryName);
|
||||
ZipEntry zipEntry = new ZipEntry(entryName);
|
||||
zipOut.putNextEntry(zipEntry);
|
||||
|
||||
try {
|
||||
// Create a new zip entry
|
||||
String entryName = baseFilename + "_" + index + ".pdf";
|
||||
log.debug("Creating ZIP entry: {}", entryName);
|
||||
ZipEntry zipEntry = new ZipEntry(entryName);
|
||||
zipOut.putNextEntry(zipEntry);
|
||||
byte[] bytes = outStream.toByteArray();
|
||||
log.debug("Writing {} bytes to ZIP entry", bytes.length);
|
||||
zipOut.write(bytes);
|
||||
|
||||
byte[] bytes = outStream.toByteArray();
|
||||
log.debug("Writing {} bytes to ZIP entry", bytes.length);
|
||||
zipOut.write(bytes);
|
||||
|
||||
log.debug("Closing ZIP entry");
|
||||
zipOut.closeEntry();
|
||||
log.debug("Successfully added document part {} to ZIP", index);
|
||||
} catch (Exception e) {
|
||||
log.error("Error adding document part {} to ZIP", index, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
log.debug("Closing ZIP entry");
|
||||
zipOut.closeEntry();
|
||||
log.debug("Successfully added document part {} to ZIP", index);
|
||||
} catch (Exception e) {
|
||||
log.error("Error adding document part {} to ZIP", index, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+47
-47
@@ -7,7 +7,6 @@ import java.io.IOException;
|
||||
import org.apache.pdfbox.multipdf.LayerUtility;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -47,53 +46,54 @@ public class ToSinglePageController {
|
||||
throws IOException {
|
||||
|
||||
// Load the source document
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(request);
|
||||
try (PDDocument sourceDocument = pdfDocumentFactory.load(request)) {
|
||||
// Calculate total height and max width
|
||||
float totalHeight = 0;
|
||||
float maxWidth = 0;
|
||||
for (PDPage page : sourceDocument.getPages()) {
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
totalHeight += pageSize.getHeight();
|
||||
maxWidth = Math.max(maxWidth, pageSize.getWidth());
|
||||
}
|
||||
|
||||
// Calculate total height and max width
|
||||
float totalHeight = 0;
|
||||
float maxWidth = 0;
|
||||
for (PDPage page : sourceDocument.getPages()) {
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
totalHeight += pageSize.getHeight();
|
||||
maxWidth = Math.max(maxWidth, pageSize.getWidth());
|
||||
// Create new document and page with calculated dimensions
|
||||
try (PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
|
||||
PDPage newPage = new PDPage(new PDRectangle(maxWidth, totalHeight));
|
||||
newDocument.addPage(newPage);
|
||||
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
float yOffset = totalHeight;
|
||||
|
||||
// For each page, copy its content to the new page at the correct offset
|
||||
try {
|
||||
layerUtility.wrapInSaveRestore(newPage);
|
||||
} catch (NullPointerException e) {
|
||||
}
|
||||
|
||||
int pageIndex = 0;
|
||||
for (PDPage page : sourceDocument.getPages()) {
|
||||
PDFormXObject form = layerUtility.importPageAsForm(sourceDocument, pageIndex);
|
||||
if (form != null) {
|
||||
AffineTransform af =
|
||||
AffineTransform.getTranslateInstance(
|
||||
0, yOffset - page.getMediaBox().getHeight());
|
||||
String defaultLayerName = "Layer" + pageIndex;
|
||||
layerUtility.appendFormAsLayer(newPage, form, af, defaultLayerName);
|
||||
}
|
||||
yOffset -= page.getMediaBox().getHeight();
|
||||
pageIndex++;
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
GeneralUtils.generateFilename(
|
||||
request.getFileInput().getOriginalFilename(), "_singlePage.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
// Create new document and page with calculated dimensions
|
||||
PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
PDPage newPage = new PDPage(new PDRectangle(maxWidth, totalHeight));
|
||||
newDocument.addPage(newPage);
|
||||
|
||||
// Initialize the content stream of the new page
|
||||
PDPageContentStream contentStream = new PDPageContentStream(newDocument, newPage);
|
||||
contentStream.close();
|
||||
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
float yOffset = totalHeight;
|
||||
|
||||
// For each page, copy its content to the new page at the correct offset
|
||||
int pageIndex = 0;
|
||||
for (PDPage page : sourceDocument.getPages()) {
|
||||
PDFormXObject form = layerUtility.importPageAsForm(sourceDocument, pageIndex);
|
||||
AffineTransform af =
|
||||
AffineTransform.getTranslateInstance(
|
||||
0, yOffset - page.getMediaBox().getHeight());
|
||||
layerUtility.wrapInSaveRestore(newPage);
|
||||
String defaultLayerName = "Layer" + pageIndex;
|
||||
layerUtility.appendFormAsLayer(newPage, form, af, defaultLayerName);
|
||||
yOffset -= page.getMediaBox().getHeight();
|
||||
pageIndex++;
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
newDocument.close();
|
||||
sourceDocument.close();
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
GeneralUtils.generateFilename(
|
||||
request.getFileInput().getOriginalFilename(), "_singlePage.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +91,7 @@ public class UIDataController {
|
||||
LicensesData data = new LicensesData();
|
||||
Resource resource = new ClassPathResource("static/3rdPartyLicenses.json");
|
||||
|
||||
try {
|
||||
InputStream is = resource.getInputStream();
|
||||
try (InputStream is = resource.getInputStream()) {
|
||||
String json = new String(is.readAllBytes(), StandardCharsets.UTF_8);
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
Map<String, List<Dependency>> licenseData =
|
||||
@@ -192,7 +191,7 @@ public class UIDataController {
|
||||
}
|
||||
|
||||
private List<String> getAvailableTesseractLanguages() {
|
||||
String tessdataDir = applicationProperties.getSystem().getTessdataDir();
|
||||
String tessdataDir = runtimePathConfig.getTessDataPath();
|
||||
java.io.File[] files = new java.io.File(tessdataDir).listFiles();
|
||||
if (files == null) {
|
||||
return Collections.emptyList();
|
||||
|
||||
+28
-24
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -41,12 +42,12 @@ public class ConvertEmlToPDF {
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/eml/pdf")
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Convert EML to PDF",
|
||||
summary = "Convert EML/MSG to PDF",
|
||||
description =
|
||||
"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")
|
||||
"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")
|
||||
public ResponseEntity<byte[]> convertEmlToPdf(@ModelAttribute EmlToPdfRequest request) {
|
||||
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
@@ -54,7 +55,7 @@ public class ConvertEmlToPDF {
|
||||
|
||||
// Validate input
|
||||
if (inputFile.isEmpty()) {
|
||||
log.error("No file provided for EML to PDF conversion.");
|
||||
log.error("No file provided for EML/MSG to PDF conversion.");
|
||||
return ResponseEntity.badRequest()
|
||||
.body("No file provided".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
@@ -65,12 +66,12 @@ public class ConvertEmlToPDF {
|
||||
.body("Please provide a valid filename".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
// Validate file type - support EML
|
||||
// Validate file type - support EML and MSG (Outlook) files
|
||||
String lowerFilename = originalFilename.toLowerCase(Locale.ROOT);
|
||||
if (!lowerFilename.endsWith(".eml")) {
|
||||
log.error("Invalid file type for EML to PDF: {}", originalFilename);
|
||||
if (!lowerFilename.endsWith(".eml") && !lowerFilename.endsWith(".msg")) {
|
||||
log.error("Invalid file type for EML/MSG to PDF: {}", originalFilename);
|
||||
return ResponseEntity.badRequest()
|
||||
.body("Please upload a valid EML file".getBytes(StandardCharsets.UTF_8));
|
||||
.body("Please upload a valid EML or MSG file".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
String baseFilename = Filenames.toSimpleFileName(originalFilename); // Use Filenames utility
|
||||
@@ -81,7 +82,7 @@ public class ConvertEmlToPDF {
|
||||
if (request.isDownloadHtml()) {
|
||||
try {
|
||||
String htmlContent = EmlToPdf.convertEmlToHtml(fileBytes, request);
|
||||
log.info("Successfully converted EML to HTML: {}", originalFilename);
|
||||
log.info("Successfully converted email to HTML: {}", originalFilename);
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
htmlContent.getBytes(StandardCharsets.UTF_8),
|
||||
baseFilename + ".html",
|
||||
@@ -95,12 +96,11 @@ public class ConvertEmlToPDF {
|
||||
}
|
||||
}
|
||||
|
||||
// Convert EML to PDF with enhanced options
|
||||
// Convert EML/MSG to PDF with enhanced options
|
||||
try {
|
||||
byte[] pdfBytes =
|
||||
EmlToPdf.convertEmlToPdf(
|
||||
runtimePathConfig
|
||||
.getWeasyPrintPath(), // Use configured WeasyPrint path
|
||||
runtimePathConfig.getWeasyPrintPath(),
|
||||
request,
|
||||
fileBytes,
|
||||
originalFilename,
|
||||
@@ -115,19 +115,19 @@ public class ConvertEmlToPDF {
|
||||
"PDF conversion failed - empty output"
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
log.info("Successfully converted EML to PDF: {}", originalFilename);
|
||||
log.info("Successfully converted email to PDF: {}", originalFilename);
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
pdfBytes, baseFilename + ".pdf", MediaType.APPLICATION_PDF);
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.error("EML to PDF conversion was interrupted for {}", originalFilename, e);
|
||||
log.error("Email 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(
|
||||
"EML to PDF conversion failed for {}: {}",
|
||||
"Email 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(
|
||||
"EML to PDF conversion failed for {}: {}",
|
||||
"Email to PDF conversion failed for {}: {}",
|
||||
originalFilename,
|
||||
errorMessage,
|
||||
e);
|
||||
@@ -145,27 +145,31 @@ public class ConvertEmlToPDF {
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("File processing error for EML to PDF: {}", originalFilename, e);
|
||||
log.error("File processing error for email 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 (e.getMessage() != null && e.getMessage().contains("Invalid EML")) {
|
||||
if (exceptionMessage != null && exceptionMessage.contains("Invalid EML")) {
|
||||
errorMessage =
|
||||
"Invalid EML file format. Please ensure you've uploaded a valid email"
|
||||
+ " file ("
|
||||
+ originalFilename
|
||||
+ safeFilename
|
||||
+ ").";
|
||||
} else if (e.getMessage() != null && e.getMessage().contains("WeasyPrint")) {
|
||||
} else if (exceptionMessage != null && exceptionMessage.contains("WeasyPrint")) {
|
||||
errorMessage =
|
||||
"PDF generation failed for "
|
||||
+ originalFilename
|
||||
+ safeFilename
|
||||
+ ". This may be due to complex email formatting.";
|
||||
} else {
|
||||
errorMessage = "Conversion failed for " + originalFilename + ": " + e.getMessage();
|
||||
errorMessage = "Conversion failed for " + safeFilename + ": " + safeExceptionMessage;
|
||||
}
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
+24
-28
@@ -188,15 +188,15 @@ public class ConvertImgPDFController {
|
||||
bodyBytes = Files.readAllBytes(webpFilePath);
|
||||
} else {
|
||||
// Create a ZIP file containing all WebP images
|
||||
ByteArrayOutputStream zipOutputStream = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(zipOutputStream)) {
|
||||
try (ByteArrayOutputStream zipOutputStream = new ByteArrayOutputStream();
|
||||
ZipOutputStream zos = new ZipOutputStream(zipOutputStream)) {
|
||||
for (Path webpFile : webpFiles) {
|
||||
zos.putNextEntry(new ZipEntry(webpFile.getFileName().toString()));
|
||||
Files.copy(webpFile, zos);
|
||||
zos.closeEntry();
|
||||
}
|
||||
bodyBytes = zipOutputStream.toByteArray();
|
||||
}
|
||||
bodyBytes = zipOutputStream.toByteArray();
|
||||
}
|
||||
// Clean up the temporary files
|
||||
Files.deleteIfExists(tempFile);
|
||||
@@ -380,7 +380,7 @@ public class ConvertImgPDFController {
|
||||
/**
|
||||
* Rearranges the pages of the given PDF document based on the specified page order.
|
||||
*
|
||||
* @param pdfBytes The byte array of the original PDF file.
|
||||
* @param pdfFile The MultipartFile of the original PDF file.
|
||||
* @param pageOrderArr An array of page numbers indicating the new order.
|
||||
* @return A byte array of the rearranged PDF.
|
||||
* @throws IOException If an error occurs while processing the PDF.
|
||||
@@ -388,35 +388,31 @@ public class ConvertImgPDFController {
|
||||
private byte[] rearrangePdfPages(MultipartFile pdfFile, String[] pageOrderArr)
|
||||
throws IOException {
|
||||
// Load the input PDF
|
||||
PDDocument document = pdfDocumentFactory.load(pdfFile);
|
||||
int totalPages = document.getNumberOfPages();
|
||||
List<Integer> newPageOrder = GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
int totalPages = document.getNumberOfPages();
|
||||
List<Integer> newPageOrder =
|
||||
GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
|
||||
|
||||
// Create a new list to hold the pages in the new order
|
||||
List<PDPage> newPages = new ArrayList<>();
|
||||
for (int pageIndex : newPageOrder) {
|
||||
newPages.add(document.getPage(pageIndex));
|
||||
}
|
||||
// Create a new list to hold the pages in the new order
|
||||
List<PDPage> newPages = new ArrayList<>();
|
||||
for (int pageIndex : newPageOrder) {
|
||||
newPages.add(document.getPage(pageIndex));
|
||||
}
|
||||
|
||||
// Remove all the pages from the original document
|
||||
for (int i = document.getNumberOfPages() - 1; i >= 0; i--) {
|
||||
document.removePage(i);
|
||||
}
|
||||
// Remove all the pages from the original document
|
||||
for (int i = document.getNumberOfPages() - 1; i >= 0; i--) {
|
||||
document.removePage(i);
|
||||
}
|
||||
|
||||
// Add the pages in the new order
|
||||
for (PDPage page : newPages) {
|
||||
document.addPage(page);
|
||||
}
|
||||
// Add the pages in the new order
|
||||
for (PDPage page : newPages) {
|
||||
document.addPage(page);
|
||||
}
|
||||
|
||||
// Convert PDDocument to byte array
|
||||
byte[] newPdfBytes;
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
// Convert PDDocument to byte array
|
||||
document.save(baos);
|
||||
newPdfBytes = baos.toByteArray();
|
||||
} finally {
|
||||
document.close();
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
return newPdfBytes;
|
||||
}
|
||||
}
|
||||
|
||||
+41
-43
@@ -36,6 +36,8 @@ 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
|
||||
@@ -47,6 +49,7 @@ 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")
|
||||
@@ -68,29 +71,24 @@ public class ConvertOfficeController {
|
||||
}
|
||||
String extensionLower = extension.toLowerCase(Locale.ROOT);
|
||||
|
||||
String baseName = FilenameUtils.getBaseName(originalFilename);
|
||||
if (baseName == null || baseName.isBlank()) {
|
||||
baseName = "input";
|
||||
}
|
||||
|
||||
// create temporary working directory
|
||||
// Create work directory - caller (processFileToPDF) is responsible for cleanup
|
||||
Path workDir = Files.createTempDirectory("office2pdf_");
|
||||
Path inputPath = workDir.resolve(baseName + "." + extensionLower);
|
||||
Path outputPath = workDir.resolve(baseName + ".pdf");
|
||||
Path inputPath = Files.createTempFile(workDir, "input_", "." + extensionLower);
|
||||
Path outputPath = Files.createTempFile(workDir, "output_", ".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()) {
|
||||
@@ -109,21 +107,23 @@ public class ConvertOfficeController {
|
||||
.runCommandWithOutputHandling(command);
|
||||
} // Run soffice command
|
||||
else {
|
||||
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());
|
||||
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());
|
||||
|
||||
result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
|
||||
.runCommandWithOutputHandling(command);
|
||||
result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
|
||||
.runCommandWithOutputHandling(command);
|
||||
}
|
||||
}
|
||||
|
||||
// Check the result
|
||||
@@ -162,15 +162,12 @@ public class ConvertOfficeController {
|
||||
|
||||
return outputPath.toFile();
|
||||
} finally {
|
||||
// Clean up the temporary files
|
||||
// Clean up the temporary input file (output and workDir cleaned by caller)
|
||||
try {
|
||||
Files.deleteIfExists(inputPath);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temp input file: {}", inputPath, e);
|
||||
}
|
||||
if (libreOfficeProfile != null) {
|
||||
FileUtils.deleteQuietly(libreOfficeProfile.toFile());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,11 +193,12 @@ public class ConvertOfficeController {
|
||||
try {
|
||||
file = convertToPdf(inputFile);
|
||||
|
||||
PDDocument doc = pdfDocumentFactory.load(file);
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
doc,
|
||||
GeneralUtils.generateFilename(
|
||||
inputFile.getOriginalFilename(), "_convertedToPDF.pdf"));
|
||||
try (PDDocument doc = pdfDocumentFactory.load(file)) {
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
doc,
|
||||
GeneralUtils.generateFilename(
|
||||
inputFile.getOriginalFilename(), "_convertedToPDF.pdf"));
|
||||
}
|
||||
} finally {
|
||||
if (file != null && file.getParent() != null) {
|
||||
FileUtils.deleteDirectory(file.getParentFile());
|
||||
|
||||
+1110
-342
File diff suppressed because it is too large
Load Diff
+7
-11
@@ -1,5 +1,6 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
@@ -106,7 +107,6 @@ public class ConvertWebsiteToPDF {
|
||||
|
||||
Path tempOutputFile = null;
|
||||
Path tempHtmlInput = null;
|
||||
PDDocument doc = null;
|
||||
try {
|
||||
// Download the remote content first to ensure we don't allow dangerous schemes
|
||||
String htmlContent = fetchRemoteHtml(URL);
|
||||
@@ -140,18 +140,14 @@ public class ConvertWebsiteToPDF {
|
||||
.runCommandWithOutputHandling(command);
|
||||
|
||||
// Load the PDF using pdfDocumentFactory
|
||||
doc = pdfDocumentFactory.load(tempOutputFile.toFile());
|
||||
try (PDDocument doc = pdfDocumentFactory.load(tempOutputFile.toFile());
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
// Convert URL to a safe filename
|
||||
String outputFilename = convertURLToFileName(URL);
|
||||
|
||||
// Convert URL to a safe filename
|
||||
String outputFilename = convertURLToFileName(URL);
|
||||
|
||||
ResponseEntity<byte[]> response =
|
||||
WebResponseUtils.pdfDocToWebResponse(doc, outputFilename);
|
||||
if (response == null) {
|
||||
// Defensive fallback - should not happen but avoids null returns breaking tests
|
||||
return ResponseEntity.ok(new byte[0]);
|
||||
doc.save(baos);
|
||||
return WebResponseUtils.baosToWebResponse(baos, outputFilename);
|
||||
}
|
||||
return response;
|
||||
} finally {
|
||||
if (tempHtmlInput != null) {
|
||||
try {
|
||||
|
||||
+162
-9
@@ -1,5 +1,6 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -17,8 +18,12 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.controller.api.converters.ConvertPDFToPDFA;
|
||||
import stirling.software.SPDF.model.api.misc.AddAttachmentRequest;
|
||||
import stirling.software.SPDF.model.api.misc.DeleteAttachmentRequest;
|
||||
import stirling.software.SPDF.model.api.misc.ExtractAttachmentsRequest;
|
||||
import stirling.software.SPDF.model.api.misc.ListAttachmentsRequest;
|
||||
import stirling.software.SPDF.model.api.misc.RenameAttachmentRequest;
|
||||
import stirling.software.SPDF.service.AttachmentServiceInterface;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.MiscApi;
|
||||
@@ -36,6 +41,8 @@ public class AttachmentController {
|
||||
|
||||
private final AttachmentServiceInterface pdfAttachmentService;
|
||||
|
||||
private final ConvertPDFToPDFA convertPDFToPDFA;
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/add-attachments")
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
@@ -43,19 +50,87 @@ public class AttachmentController {
|
||||
description =
|
||||
"This endpoint adds attachments to a PDF. Input:PDF, Output:PDF Type:MISO")
|
||||
public ResponseEntity<byte[]> addAttachments(@ModelAttribute AddAttachmentRequest request)
|
||||
throws IOException {
|
||||
throws Exception {
|
||||
MultipartFile fileInput = request.getFileInput();
|
||||
List<MultipartFile> attachments = request.getAttachments();
|
||||
boolean convertToPdfA3b = request.isConvertToPdfA3b();
|
||||
|
||||
PDDocument document =
|
||||
pdfAttachmentService.addAttachment(
|
||||
pdfDocumentFactory.load(fileInput, false), attachments);
|
||||
validateAttachmentRequest(attachments);
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
Filenames.toSimpleFileName(fileInput.getOriginalFilename()),
|
||||
"_with_attachments.pdf"));
|
||||
String originalFileName = Filenames.toSimpleFileName(fileInput.getOriginalFilename());
|
||||
if (originalFileName == null || originalFileName.isEmpty()) {
|
||||
originalFileName = "document";
|
||||
}
|
||||
String baseFileName =
|
||||
originalFileName.contains(".")
|
||||
? originalFileName.substring(0, originalFileName.lastIndexOf('.'))
|
||||
: originalFileName;
|
||||
|
||||
if (convertToPdfA3b) {
|
||||
byte[] pdfaBytes;
|
||||
try (PDDocument document = pdfDocumentFactory.load(request, false)) {
|
||||
pdfaBytes = convertPDFToPDFA.convertPDDocumentToPDFA(document, "pdfa-3b");
|
||||
}
|
||||
|
||||
try (PDDocument pdfaDocument = org.apache.pdfbox.Loader.loadPDF(pdfaBytes)) {
|
||||
pdfAttachmentService.addAttachment(pdfaDocument, attachments);
|
||||
|
||||
convertPDFToPDFA.ensureEmbeddedFileCompliance(pdfaDocument);
|
||||
|
||||
ConvertPDFToPDFA.fixType1FontCharSet(pdfaDocument);
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
pdfaDocument.save(baos);
|
||||
byte[] resultBytes = baos.toByteArray();
|
||||
|
||||
String outputFilename = baseFileName + "_with_attachments_PDFA-3b.pdf";
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
resultBytes, outputFilename, MediaType.APPLICATION_PDF);
|
||||
}
|
||||
} else {
|
||||
try (PDDocument document = pdfDocumentFactory.load(request, false)) {
|
||||
pdfAttachmentService.addAttachment(document, attachments);
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
Filenames.toSimpleFileName(fileInput.getOriginalFilename()),
|
||||
"_with_attachments.pdf"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateAttachmentRequest(List<MultipartFile> attachments) {
|
||||
if (attachments == null || attachments.isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.attachmentsRequired", "At least one attachment is required");
|
||||
}
|
||||
|
||||
final long maxAttachmentSize = 50L * 1024 * 1024; // 50 MB per attachment
|
||||
final long maxTotalSize = 200L * 1024 * 1024; // 200 MB total
|
||||
|
||||
long totalSize = 0;
|
||||
for (MultipartFile attachment : attachments) {
|
||||
if (attachment == null || attachment.isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.attachmentEmpty", "Attachment files cannot be null or empty");
|
||||
}
|
||||
if (attachment.getSize() > maxAttachmentSize) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.attachmentTooLarge",
|
||||
"Attachment ''{0}'' exceeds maximum size of {1} bytes",
|
||||
attachment.getOriginalFilename(),
|
||||
maxAttachmentSize);
|
||||
}
|
||||
totalSize += attachment.getSize();
|
||||
}
|
||||
|
||||
if (totalSize > maxTotalSize) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.totalAttachmentsTooLarge",
|
||||
"Total attachment size {0} exceeds maximum of {1} bytes",
|
||||
totalSize,
|
||||
maxTotalSize);
|
||||
}
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(
|
||||
@@ -88,4 +163,82 @@ public class AttachmentController {
|
||||
extracted.get(), outputName, MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/list-attachments")
|
||||
@Operation(
|
||||
summary = "List attachments in PDF",
|
||||
description =
|
||||
"This endpoint lists all embedded attachments in a PDF. Input:PDF Output:JSON Type:SISO")
|
||||
public ResponseEntity<List<stirling.software.SPDF.model.api.misc.AttachmentInfo>>
|
||||
listAttachments(@ModelAttribute ListAttachmentsRequest request) throws IOException {
|
||||
try (PDDocument document = pdfDocumentFactory.load(request, true)) {
|
||||
List<stirling.software.SPDF.model.api.misc.AttachmentInfo> attachments =
|
||||
pdfAttachmentService.listAttachments(document);
|
||||
|
||||
return ResponseEntity.ok(attachments);
|
||||
}
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/rename-attachment")
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Rename attachment in PDF",
|
||||
description =
|
||||
"This endpoint renames an embedded attachment in a PDF. Input:PDF Output:PDF Type:MISO")
|
||||
public ResponseEntity<byte[]> renameAttachment(@ModelAttribute RenameAttachmentRequest request)
|
||||
throws Exception {
|
||||
MultipartFile fileInput = request.getFileInput();
|
||||
String attachmentName = request.getAttachmentName();
|
||||
String newName = request.getNewName();
|
||||
|
||||
if (attachmentName == null || attachmentName.isBlank()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.attachmentNameRequired", "Attachment name cannot be null or empty");
|
||||
}
|
||||
if (newName == null || newName.isBlank()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.newNameRequired", "New attachment name cannot be null or empty");
|
||||
}
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(request, false)) {
|
||||
pdfAttachmentService.renameAttachment(document, attachmentName, newName);
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
Filenames.toSimpleFileName(fileInput.getOriginalFilename()),
|
||||
"_attachment_renamed.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/delete-attachment")
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Delete attachment from PDF",
|
||||
description =
|
||||
"This endpoint deletes an embedded attachment from a PDF. Input:PDF Output:PDF Type:MISO")
|
||||
public ResponseEntity<byte[]> deleteAttachment(@ModelAttribute DeleteAttachmentRequest request)
|
||||
throws Exception {
|
||||
MultipartFile fileInput = request.getFileInput();
|
||||
String attachmentName = request.getAttachmentName();
|
||||
|
||||
if (attachmentName == null || attachmentName.isBlank()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.attachmentNameRequired", "Attachment name cannot be null or empty");
|
||||
}
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(request, false)) {
|
||||
pdfAttachmentService.deleteAttachment(document, attachmentName);
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
Filenames.toSimpleFileName(fileInput.getOriginalFilename()),
|
||||
"_attachment_deleted.pdf"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+85
-82
@@ -47,102 +47,105 @@ public class AutoRenameController {
|
||||
MultipartFile file = request.getFileInput();
|
||||
boolean useFirstTextAsFallback = Boolean.TRUE.equals(request.getUseFirstTextAsFallback());
|
||||
|
||||
PDDocument document = pdfDocumentFactory.load(file);
|
||||
PDFTextStripper reader =
|
||||
new PDFTextStripper() {
|
||||
List<LineInfo> lineInfos = new ArrayList<>();
|
||||
StringBuilder lineBuilder = new StringBuilder();
|
||||
float lastY = -1;
|
||||
float maxFontSizeInLine = 0.0f;
|
||||
int lineCount = 0;
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
PDFTextStripper reader =
|
||||
new PDFTextStripper() {
|
||||
List<LineInfo> lineInfos = new ArrayList<>();
|
||||
StringBuilder lineBuilder = new StringBuilder();
|
||||
float lastY = -1;
|
||||
float maxFontSizeInLine = 0.0f;
|
||||
int lineCount = 0;
|
||||
|
||||
@Override
|
||||
protected void processTextPosition(TextPosition text) {
|
||||
if (lastY != text.getY() && lineCount < LINE_LIMIT) {
|
||||
processLine();
|
||||
lineBuilder = new StringBuilder(text.getUnicode());
|
||||
maxFontSizeInLine = text.getFontSizeInPt();
|
||||
lastY = text.getY();
|
||||
lineCount++;
|
||||
} else if (lineCount < LINE_LIMIT) {
|
||||
lineBuilder.append(text.getUnicode());
|
||||
if (text.getFontSizeInPt() > maxFontSizeInLine) {
|
||||
@Override
|
||||
protected void processTextPosition(TextPosition text) {
|
||||
if (lastY != text.getY() && lineCount < LINE_LIMIT) {
|
||||
processLine();
|
||||
lineBuilder = new StringBuilder(text.getUnicode());
|
||||
maxFontSizeInLine = text.getFontSizeInPt();
|
||||
lastY = text.getY();
|
||||
lineCount++;
|
||||
} else if (lineCount < LINE_LIMIT) {
|
||||
lineBuilder.append(text.getUnicode());
|
||||
if (text.getFontSizeInPt() > maxFontSizeInLine) {
|
||||
maxFontSizeInLine = text.getFontSizeInPt();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void processLine() {
|
||||
if (!lineBuilder.isEmpty() && lineCount < LINE_LIMIT) {
|
||||
lineInfos.add(new LineInfo(lineBuilder.toString(), maxFontSizeInLine));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getText(PDDocument doc) throws IOException {
|
||||
this.lineInfos.clear();
|
||||
this.lineBuilder = new StringBuilder();
|
||||
this.lastY = -1;
|
||||
this.maxFontSizeInLine = 0.0f;
|
||||
this.lineCount = 0;
|
||||
super.getText(doc);
|
||||
processLine(); // Process the last line
|
||||
|
||||
// Merge lines with same font size
|
||||
List<LineInfo> mergedLineInfos = new ArrayList<>();
|
||||
for (int i = 0; i < lineInfos.size(); i++) {
|
||||
StringBuilder mergedText = new StringBuilder(lineInfos.get(i).text);
|
||||
float fontSize = lineInfos.get(i).fontSize;
|
||||
while (i + 1 < lineInfos.size()
|
||||
&& lineInfos.get(i + 1).fontSize == fontSize) {
|
||||
mergedText.append(" ").append(lineInfos.get(i + 1).text);
|
||||
i++;
|
||||
private void processLine() {
|
||||
if (!lineBuilder.isEmpty() && lineCount < LINE_LIMIT) {
|
||||
lineInfos.add(
|
||||
new LineInfo(lineBuilder.toString(), maxFontSizeInLine));
|
||||
}
|
||||
mergedLineInfos.add(new LineInfo(mergedText.toString(), fontSize));
|
||||
}
|
||||
|
||||
// Sort lines by font size in descending order and get the first one
|
||||
mergedLineInfos.sort(
|
||||
Comparator.comparing((LineInfo li) -> li.fontSize).reversed());
|
||||
String title =
|
||||
mergedLineInfos.isEmpty() ? null : mergedLineInfos.get(0).text;
|
||||
@Override
|
||||
public String getText(PDDocument doc) throws IOException {
|
||||
this.lineInfos.clear();
|
||||
this.lineBuilder = new StringBuilder();
|
||||
this.lastY = -1;
|
||||
this.maxFontSizeInLine = 0.0f;
|
||||
this.lineCount = 0;
|
||||
super.getText(doc);
|
||||
processLine(); // Process the last line
|
||||
|
||||
return title != null
|
||||
? title
|
||||
: (useFirstTextAsFallback
|
||||
? (mergedLineInfos.isEmpty()
|
||||
? null
|
||||
: mergedLineInfos.get(mergedLineInfos.size() - 1)
|
||||
.text)
|
||||
: null);
|
||||
}
|
||||
// Merge lines with same font size
|
||||
List<LineInfo> mergedLineInfos = new ArrayList<>();
|
||||
for (int i = 0; i < lineInfos.size(); i++) {
|
||||
StringBuilder mergedText = new StringBuilder(lineInfos.get(i).text);
|
||||
float fontSize = lineInfos.get(i).fontSize;
|
||||
while (i + 1 < lineInfos.size()
|
||||
&& lineInfos.get(i + 1).fontSize == fontSize) {
|
||||
mergedText.append(" ").append(lineInfos.get(i + 1).text);
|
||||
i++;
|
||||
}
|
||||
mergedLineInfos.add(new LineInfo(mergedText.toString(), fontSize));
|
||||
}
|
||||
|
||||
class LineInfo {
|
||||
String text;
|
||||
float fontSize;
|
||||
// Sort lines by font size in descending order and get the first one
|
||||
mergedLineInfos.sort(
|
||||
Comparator.comparing((LineInfo li) -> li.fontSize).reversed());
|
||||
String title =
|
||||
mergedLineInfos.isEmpty() ? null : mergedLineInfos.get(0).text;
|
||||
|
||||
LineInfo(String text, float fontSize) {
|
||||
this.text = text;
|
||||
this.fontSize = fontSize;
|
||||
return title != null
|
||||
? title
|
||||
: (useFirstTextAsFallback
|
||||
? (mergedLineInfos.isEmpty()
|
||||
? null
|
||||
: mergedLineInfos.get(
|
||||
mergedLineInfos.size() - 1)
|
||||
.text)
|
||||
: null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
String header = reader.getText(document);
|
||||
class LineInfo {
|
||||
String text;
|
||||
float fontSize;
|
||||
|
||||
// Sanitize the header string by removing characters not allowed in a filename.
|
||||
if (header != null && header.length() < 255) {
|
||||
header =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getSafeFilenamePattern()
|
||||
.matcher(header)
|
||||
.replaceAll("")
|
||||
.trim();
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, header + ".pdf");
|
||||
} else {
|
||||
log.info("File has no good title to be found");
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document, Filenames.toSimpleFileName(file.getOriginalFilename()));
|
||||
LineInfo(String text, float fontSize) {
|
||||
this.text = text;
|
||||
this.fontSize = fontSize;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
String header = reader.getText(document);
|
||||
|
||||
// Sanitize the header string by removing characters not allowed in a filename.
|
||||
if (header != null && header.length() < 255) {
|
||||
header =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getSafeFilenamePattern()
|
||||
.matcher(header)
|
||||
.replaceAll("")
|
||||
.trim();
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, header + ".pdf");
|
||||
} else {
|
||||
log.info("File has no good title to be found");
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document, Filenames.toSimpleFileName(file.getOriginalFilename()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-19
@@ -119,13 +119,9 @@ public class AutoSplitPdfController {
|
||||
MultipartFile file = request.getFileInput();
|
||||
boolean duplexMode = Boolean.TRUE.equals(request.getDuplexMode());
|
||||
|
||||
PDDocument document = null;
|
||||
List<PDDocument> splitDocuments = new ArrayList<>();
|
||||
TempFile outputTempFile = null;
|
||||
|
||||
try {
|
||||
outputTempFile = new TempFile(tempFileManager, ".zip");
|
||||
document = pdfDocumentFactory.load(file.getInputStream());
|
||||
try (TempFile outputTempFile = new TempFile(tempFileManager, ".zip");
|
||||
PDDocument document = pdfDocumentFactory.load(file.getInputStream())) {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true);
|
||||
|
||||
@@ -201,15 +197,7 @@ public class AutoSplitPdfController {
|
||||
log.error("Error in auto split", e);
|
||||
throw e;
|
||||
} finally {
|
||||
// Clean up resources
|
||||
if (document != null) {
|
||||
try {
|
||||
document.close();
|
||||
} catch (IOException e) {
|
||||
log.error("Error closing main PDDocument", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up split documents
|
||||
for (PDDocument splitDoc : splitDocuments) {
|
||||
try {
|
||||
splitDoc.close();
|
||||
@@ -217,10 +205,6 @@ public class AutoSplitPdfController {
|
||||
log.error("Error closing split PDDocument", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (outputTempFile != null) {
|
||||
outputTempFile.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-8
@@ -53,18 +53,20 @@ public class BlankPageController {
|
||||
|
||||
// Convert to binary image based on the threshold
|
||||
int whitePixels = 0;
|
||||
int totalPixels = image.getWidth() * image.getHeight();
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
int[] pixels = new int[width * height];
|
||||
|
||||
for (int i = 0; i < image.getHeight(); i++) {
|
||||
for (int j = 0; j < image.getWidth(); j++) {
|
||||
int color = image.getRGB(j, i) & 0xFF;
|
||||
if (color >= 255 - threshold) {
|
||||
whitePixels++;
|
||||
}
|
||||
image.getRGB(0, 0, width, height, pixels, 0, width);
|
||||
|
||||
for (int pixel : pixels) {
|
||||
int blue = pixel & 0xFF;
|
||||
if (blue >= 255 - threshold) {
|
||||
whitePixels++;
|
||||
}
|
||||
}
|
||||
|
||||
double whitePixelPercentage = (whitePixels / (double) totalPixels) * 100;
|
||||
double whitePixelPercentage = (whitePixels / (double) (width * height)) * 100;
|
||||
log.info(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
|
||||
+3
-2
@@ -1100,8 +1100,9 @@ public class CompressController {
|
||||
inputFile.getOriginalFilename(), "_Optimized.pdf");
|
||||
|
||||
try {
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
pdfDocumentFactory.load(currentFile.toFile()), outputFilename);
|
||||
try (PDDocument document = pdfDocumentFactory.load(currentFile.toFile())) {
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, outputFilename);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw ExceptionUtils.handlePdfException(e, "PDF optimization");
|
||||
}
|
||||
|
||||
+27
@@ -71,6 +71,30 @@ public class ConfigController {
|
||||
configData.put("contextPath", appConfig.getContextPath());
|
||||
configData.put("serverPort", appConfig.getServerPort());
|
||||
|
||||
// Add frontendUrl for mobile scanner QR codes
|
||||
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
||||
configData.put("frontendUrl", frontendUrl != null ? frontendUrl : "");
|
||||
|
||||
// Add mobile scanner settings
|
||||
configData.put(
|
||||
"enableMobileScanner",
|
||||
applicationProperties.getSystem().isEnableMobileScanner());
|
||||
configData.put(
|
||||
"mobileScannerConvertToPdf",
|
||||
applicationProperties.getSystem().getMobileScannerSettings().isConvertToPdf());
|
||||
configData.put(
|
||||
"mobileScannerImageResolution",
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.getMobileScannerSettings()
|
||||
.getImageResolution());
|
||||
configData.put(
|
||||
"mobileScannerPageFormat",
|
||||
applicationProperties.getSystem().getMobileScannerSettings().getPageFormat());
|
||||
configData.put(
|
||||
"mobileScannerStretchToFit",
|
||||
applicationProperties.getSystem().getMobileScannerSettings().isStretchToFit());
|
||||
|
||||
// Extract values from ApplicationProperties
|
||||
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
|
||||
configData.put("languages", applicationProperties.getUi().getLanguages());
|
||||
@@ -84,6 +108,9 @@ public class ConfigController {
|
||||
boolean enableLogin =
|
||||
applicationProperties.getSecurity().isEnableLogin() && userService != null;
|
||||
configData.put("enableLogin", enableLogin);
|
||||
configData.put(
|
||||
"showSettingsWhenNoLogin",
|
||||
applicationProperties.getSystem().isShowSettingsWhenNoLogin());
|
||||
|
||||
// Mail settings - check both SMTP enabled AND invites enabled
|
||||
boolean smtpEnabled = applicationProperties.getMail().isEnabled();
|
||||
|
||||
+91
-81
@@ -49,93 +49,103 @@ public class FlattenController {
|
||||
public ResponseEntity<byte[]> flatten(@ModelAttribute FlattenRequest request) throws Exception {
|
||||
MultipartFile file = request.getFileInput();
|
||||
|
||||
PDDocument document = pdfDocumentFactory.load(file);
|
||||
Boolean flattenOnlyForms = request.getFlattenOnlyForms();
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
Boolean flattenOnlyForms = request.getFlattenOnlyForms();
|
||||
|
||||
if (Boolean.TRUE.equals(flattenOnlyForms)) {
|
||||
PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm();
|
||||
if (acroForm != null) {
|
||||
acroForm.flatten();
|
||||
}
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document, Filenames.toSimpleFileName(file.getOriginalFilename()));
|
||||
} else {
|
||||
// flatten whole page aka convert each page to image and re-add it (making text
|
||||
// unselectable)
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document);
|
||||
if (Boolean.TRUE.equals(flattenOnlyForms)) {
|
||||
PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm();
|
||||
if (acroForm != null) {
|
||||
acroForm.flatten();
|
||||
}
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document, Filenames.toSimpleFileName(file.getOriginalFilename()));
|
||||
} else {
|
||||
// flatten whole page aka convert each page to image and re-add it (making text
|
||||
// unselectable)
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(
|
||||
true); // Enable subsampling to reduce memory usage
|
||||
|
||||
int defaultRenderDpi = 100; // Default fallback
|
||||
ApplicationProperties properties =
|
||||
ApplicationContextProvider.getBean(ApplicationProperties.class);
|
||||
Integer configuredMaxDpi = null;
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
configuredMaxDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
try (PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document)) {
|
||||
|
||||
int maxDpi =
|
||||
(configuredMaxDpi != null && configuredMaxDpi > 0)
|
||||
? configuredMaxDpi
|
||||
: defaultRenderDpi;
|
||||
|
||||
Integer requestedDpi = request.getRenderDpi();
|
||||
int renderDpiTemp = maxDpi;
|
||||
if (requestedDpi != null) {
|
||||
renderDpiTemp = Math.min(requestedDpi, maxDpi);
|
||||
renderDpiTemp = Math.max(renderDpiTemp, 72);
|
||||
}
|
||||
final int renderDpi = renderDpiTemp;
|
||||
|
||||
int numPages = document.getNumberOfPages();
|
||||
for (int i = 0; i < numPages; i++) {
|
||||
final int pageIndex = i;
|
||||
BufferedImage image = null;
|
||||
try {
|
||||
// Validate dimensions BEFORE rendering to prevent OOM
|
||||
ExceptionUtils.validateRenderingDimensions(
|
||||
document.getPage(pageIndex), pageIndex + 1, renderDpi);
|
||||
|
||||
// Wrap entire rendering operation to catch OutOfMemoryError from any depth
|
||||
image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
renderDpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, renderDpi, ImageType.RGB));
|
||||
|
||||
PDPage page = new PDPage();
|
||||
page.setMediaBox(document.getPage(i).getMediaBox());
|
||||
newDocument.addPage(page);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(newDocument, page)) {
|
||||
PDImageXObject pdImage = JPEGFactory.createFromImage(newDocument, image);
|
||||
float pageWidth = page.getMediaBox().getWidth();
|
||||
float pageHeight = page.getMediaBox().getHeight();
|
||||
|
||||
contentStream.drawImage(pdImage, 0, 0, pageWidth, pageHeight);
|
||||
int defaultRenderDpi = 100; // Default fallback
|
||||
ApplicationProperties properties =
|
||||
ApplicationContextProvider.getBean(ApplicationProperties.class);
|
||||
Integer configuredMaxDpi = null;
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
configuredMaxDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
|
||||
// Re-throw OutOfMemoryDpiException to be handled by GlobalExceptionHandler
|
||||
newDocument.close();
|
||||
document.close();
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
log.error("IOException during page processing: ", e);
|
||||
// Continue processing other pages
|
||||
} catch (OutOfMemoryError e) {
|
||||
// Catch any OutOfMemoryError that escaped the inner try block
|
||||
newDocument.close();
|
||||
document.close();
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e);
|
||||
} finally {
|
||||
// Help GC by clearing the image reference
|
||||
image = null;
|
||||
|
||||
int maxDpi =
|
||||
(configuredMaxDpi != null && configuredMaxDpi > 0)
|
||||
? configuredMaxDpi
|
||||
: defaultRenderDpi;
|
||||
|
||||
Integer requestedDpi = request.getRenderDpi();
|
||||
int renderDpiTemp = maxDpi;
|
||||
if (requestedDpi != null) {
|
||||
renderDpiTemp = Math.min(requestedDpi, maxDpi);
|
||||
renderDpiTemp = Math.max(renderDpiTemp, 72);
|
||||
}
|
||||
final int renderDpi = renderDpiTemp;
|
||||
|
||||
int numPages = document.getNumberOfPages();
|
||||
for (int i = 0; i < numPages; i++) {
|
||||
final int pageIndex = i;
|
||||
BufferedImage image = null;
|
||||
try {
|
||||
// Validate dimensions BEFORE rendering to prevent OOM
|
||||
ExceptionUtils.validateRenderingDimensions(
|
||||
document.getPage(pageIndex), pageIndex + 1, renderDpi);
|
||||
|
||||
// Wrap entire rendering operation to catch OutOfMemoryError from any
|
||||
// depth
|
||||
image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
pageIndex + 1,
|
||||
renderDpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
pageIndex, renderDpi, ImageType.RGB));
|
||||
|
||||
PDPage page = new PDPage();
|
||||
page.setMediaBox(document.getPage(i).getMediaBox());
|
||||
newDocument.addPage(page);
|
||||
// resetContext=true: Ensure clean graphics state when overwriting.
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
newDocument,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true,
|
||||
true)) {
|
||||
PDImageXObject pdImage =
|
||||
JPEGFactory.createFromImage(newDocument, image);
|
||||
float pageWidth = page.getMediaBox().getWidth();
|
||||
float pageHeight = page.getMediaBox().getHeight();
|
||||
|
||||
contentStream.drawImage(pdImage, 0, 0, pageWidth, pageHeight);
|
||||
}
|
||||
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
|
||||
// Re-throw OutOfMemoryDpiException to be handled by
|
||||
// GlobalExceptionHandler
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
log.error("IOException during page processing: ", e);
|
||||
// Continue processing other pages
|
||||
} catch (OutOfMemoryError e) {
|
||||
// Catch any OutOfMemoryError that escaped the inner try block
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e);
|
||||
} finally {
|
||||
// Help GC by clearing the image reference
|
||||
image = null;
|
||||
}
|
||||
}
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
newDocument, Filenames.toSimpleFileName(file.getOriginalFilename()));
|
||||
}
|
||||
}
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
newDocument, Filenames.toSimpleFileName(file.getOriginalFilename()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+91
-82
@@ -84,93 +84,102 @@ public class MetadataController {
|
||||
if (allRequestParams == null) {
|
||||
allRequestParams = new java.util.HashMap<String, String>();
|
||||
}
|
||||
// Load the PDF file into a PDDocument
|
||||
PDDocument document = pdfDocumentFactory.load(pdfFile, true);
|
||||
// Load the PDF file into a PDDocument with proper resource management
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile, true)) {
|
||||
|
||||
// Get the document information from the PDF
|
||||
PDDocumentInformation info = document.getDocumentInformation();
|
||||
// Get the document information from the PDF
|
||||
PDDocumentInformation info = document.getDocumentInformation();
|
||||
|
||||
// Check if each metadata value is "undefined" and set it to null if it is
|
||||
author = checkUndefined(author);
|
||||
creationDate = checkUndefined(creationDate);
|
||||
creator = checkUndefined(creator);
|
||||
keywords = checkUndefined(keywords);
|
||||
modificationDate = checkUndefined(modificationDate);
|
||||
producer = checkUndefined(producer);
|
||||
subject = checkUndefined(subject);
|
||||
title = checkUndefined(title);
|
||||
trapped = checkUndefined(trapped);
|
||||
// Check if each metadata value is "undefined" and set it to null if it is
|
||||
author = checkUndefined(author);
|
||||
creationDate = checkUndefined(creationDate);
|
||||
creator = checkUndefined(creator);
|
||||
keywords = checkUndefined(keywords);
|
||||
modificationDate = checkUndefined(modificationDate);
|
||||
producer = checkUndefined(producer);
|
||||
subject = checkUndefined(subject);
|
||||
title = checkUndefined(title);
|
||||
trapped = checkUndefined(trapped);
|
||||
|
||||
// If the "deleteAll" flag is set, remove all metadata from the document
|
||||
// information
|
||||
if (deleteAll) {
|
||||
for (String key : info.getMetadataKeys()) {
|
||||
info.setCustomMetadataValue(key, null);
|
||||
}
|
||||
// Remove metadata from the PDF history
|
||||
document.getDocumentCatalog().getCOSObject().removeItem(COSName.getPDFName("Metadata"));
|
||||
document.getDocumentCatalog()
|
||||
.getCOSObject()
|
||||
.removeItem(COSName.getPDFName("PieceInfo"));
|
||||
author = null;
|
||||
creationDate = null;
|
||||
creator = null;
|
||||
keywords = null;
|
||||
modificationDate = null;
|
||||
producer = null;
|
||||
subject = null;
|
||||
title = null;
|
||||
trapped = null;
|
||||
} else {
|
||||
// Iterate through the request parameters and set the metadata values
|
||||
for (Entry<String, String> entry : allRequestParams.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
// Check if the key is a standard metadata key
|
||||
if (!"Author".equalsIgnoreCase(key)
|
||||
&& !"CreationDate".equalsIgnoreCase(key)
|
||||
&& !"Creator".equalsIgnoreCase(key)
|
||||
&& !"Keywords".equalsIgnoreCase(key)
|
||||
&& !"modificationDate".equalsIgnoreCase(key)
|
||||
&& !"Producer".equalsIgnoreCase(key)
|
||||
&& !"Subject".equalsIgnoreCase(key)
|
||||
&& !"Title".equalsIgnoreCase(key)
|
||||
&& !"Trapped".equalsIgnoreCase(key)
|
||||
&& !key.contains("customKey")
|
||||
&& !key.contains("customValue")) {
|
||||
info.setCustomMetadataValue(key, entry.getValue());
|
||||
} else if (key.contains("customKey")) {
|
||||
int number =
|
||||
Integer.parseInt(
|
||||
RegexPatternUtils.getInstance()
|
||||
.getNumericExtractionPattern()
|
||||
.matcher(key)
|
||||
.replaceAll(""));
|
||||
String customKey = entry.getValue();
|
||||
String customValue = allRequestParams.get("customValue" + number);
|
||||
info.setCustomMetadataValue(customKey, customValue);
|
||||
// If the "deleteAll" flag is set, remove all metadata from the document
|
||||
// information
|
||||
if (deleteAll) {
|
||||
for (String key : info.getMetadataKeys()) {
|
||||
info.setCustomMetadataValue(key, null);
|
||||
}
|
||||
// Remove metadata from the PDF history
|
||||
document.getDocumentCatalog()
|
||||
.getCOSObject()
|
||||
.removeItem(COSName.getPDFName("Metadata"));
|
||||
document.getDocumentCatalog()
|
||||
.getCOSObject()
|
||||
.removeItem(COSName.getPDFName("PieceInfo"));
|
||||
author = null;
|
||||
creationDate = null;
|
||||
creator = null;
|
||||
keywords = null;
|
||||
modificationDate = null;
|
||||
producer = null;
|
||||
subject = null;
|
||||
title = null;
|
||||
trapped = null;
|
||||
} else {
|
||||
// Iterate through the request parameters and set the metadata values
|
||||
for (Entry<String, String> entry : allRequestParams.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
// Check if the key is a standard metadata key
|
||||
if (!"Author".equalsIgnoreCase(key)
|
||||
&& !"CreationDate".equalsIgnoreCase(key)
|
||||
&& !"Creator".equalsIgnoreCase(key)
|
||||
&& !"Keywords".equalsIgnoreCase(key)
|
||||
&& !"modificationDate".equalsIgnoreCase(key)
|
||||
&& !"Producer".equalsIgnoreCase(key)
|
||||
&& !"Subject".equalsIgnoreCase(key)
|
||||
&& !"Title".equalsIgnoreCase(key)
|
||||
&& !"Trapped".equalsIgnoreCase(key)
|
||||
&& !key.contains("customKey")
|
||||
&& !key.contains("customValue")) {
|
||||
info.setCustomMetadataValue(key, entry.getValue());
|
||||
} else if (key.contains("customKey")) {
|
||||
try {
|
||||
int number =
|
||||
Integer.parseInt(
|
||||
RegexPatternUtils.getInstance()
|
||||
.getNumericExtractionPattern()
|
||||
.matcher(key)
|
||||
.replaceAll(""));
|
||||
String customKey = entry.getValue();
|
||||
String customValue = allRequestParams.get("customValue" + number);
|
||||
info.setCustomMetadataValue(customKey, customValue);
|
||||
} catch (NumberFormatException e) {
|
||||
// Skip invalid custom key entries that don't have valid numeric
|
||||
// suffixes
|
||||
log.warn("Skipping invalid custom key '{}': {}", key, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Set creation date using utility method
|
||||
Calendar creationDateCal = PdfMetadataService.parseToCalendar(creationDate);
|
||||
info.setCreationDate(creationDateCal);
|
||||
|
||||
// Set modification date using utility method
|
||||
Calendar modificationDateCal = PdfMetadataService.parseToCalendar(modificationDate);
|
||||
info.setModificationDate(modificationDateCal);
|
||||
info.setCreator(creator);
|
||||
info.setKeywords(keywords);
|
||||
info.setAuthor(author);
|
||||
info.setProducer(producer);
|
||||
info.setSubject(subject);
|
||||
info.setTitle(title);
|
||||
info.setTrapped(trapped);
|
||||
|
||||
document.setDocumentInformation(info);
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.removeExtension(
|
||||
Filenames.toSimpleFileName(pdfFile.getOriginalFilename()))
|
||||
+ "_metadata.pdf");
|
||||
}
|
||||
// Set creation date using utility method
|
||||
Calendar creationDateCal = PdfMetadataService.parseToCalendar(creationDate);
|
||||
info.setCreationDate(creationDateCal);
|
||||
|
||||
// Set modification date using utility method
|
||||
Calendar modificationDateCal = PdfMetadataService.parseToCalendar(modificationDate);
|
||||
info.setModificationDate(modificationDateCal);
|
||||
info.setCreator(creator);
|
||||
info.setKeywords(keywords);
|
||||
info.setAuthor(author);
|
||||
info.setProducer(producer);
|
||||
info.setSubject(subject);
|
||||
info.setTitle(title);
|
||||
info.setTrapped(trapped);
|
||||
|
||||
document.setDocumentInformation(info);
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.removeExtension(
|
||||
Filenames.toSimpleFileName(pdfFile.getOriginalFilename()))
|
||||
+ "_metadata.pdf");
|
||||
}
|
||||
}
|
||||
|
||||
+360
@@ -0,0 +1,360 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.MobileScannerService;
|
||||
import stirling.software.common.service.MobileScannerService.FileMetadata;
|
||||
|
||||
/**
|
||||
* REST controller for mobile scanner functionality. Allows mobile devices to upload scanned images
|
||||
* that can be retrieved by desktop clients via a session-based system. No authentication required
|
||||
* for peer-to-peer scanning workflow.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/mobile-scanner")
|
||||
@Tag(
|
||||
name = "Mobile Scanner",
|
||||
description =
|
||||
"Endpoints for mobile-to-desktop file transfer via QR code scanning. "
|
||||
+ "Files are temporarily stored and automatically cleaned up after 10 minutes.")
|
||||
@Hidden
|
||||
@Slf4j
|
||||
public class MobileScannerController {
|
||||
|
||||
private final MobileScannerService mobileScannerService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
public MobileScannerController(
|
||||
MobileScannerService mobileScannerService,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.mobileScannerService = mobileScannerService;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if mobile scanner feature is enabled
|
||||
*
|
||||
* @return Error response if disabled, null if enabled
|
||||
*/
|
||||
private ResponseEntity<Map<String, Object>> checkFeatureEnabled() {
|
||||
if (!applicationProperties.getSystem().isEnableMobileScanner()) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Mobile scanner feature is not enabled",
|
||||
"enabled",
|
||||
false));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session (called by desktop when QR code is generated)
|
||||
*
|
||||
* @param sessionId Unique session identifier
|
||||
* @return Session information with expiry time
|
||||
*/
|
||||
@PostMapping("/create-session/{sessionId}")
|
||||
@Operation(
|
||||
summary = "Create a new mobile scanner session",
|
||||
description = "Desktop clients call this when generating a QR code")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Session created successfully",
|
||||
content = @Content(schema = @Schema(implementation = SessionInfoResponse.class)))
|
||||
@ApiResponse(responseCode = "400", description = "Invalid session ID")
|
||||
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||
public ResponseEntity<Map<String, Object>> createSession(
|
||||
@Parameter(description = "Session ID for QR code", required = true) @PathVariable
|
||||
String sessionId) {
|
||||
|
||||
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
|
||||
if (featureCheck != null) {
|
||||
return featureCheck;
|
||||
}
|
||||
|
||||
try {
|
||||
MobileScannerService.SessionInfo sessionInfo =
|
||||
mobileScannerService.createSession(sessionId);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("sessionId", sessionInfo.getSessionId());
|
||||
response.put("createdAt", sessionInfo.getCreatedAt());
|
||||
response.put("expiresAt", sessionInfo.getExpiresAt());
|
||||
response.put("timeoutMs", sessionInfo.getTimeoutMs());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Invalid session creation request: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a session exists and is not expired
|
||||
*
|
||||
* @param sessionId Session identifier to validate
|
||||
* @return Session information if valid, error if invalid/expired
|
||||
*/
|
||||
@GetMapping("/validate-session/{sessionId}")
|
||||
@Operation(
|
||||
summary = "Validate a mobile scanner session",
|
||||
description = "Check if session exists and is not expired")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Session is valid",
|
||||
content = @Content(schema = @Schema(implementation = SessionInfoResponse.class)))
|
||||
@ApiResponse(responseCode = "404", description = "Session not found or expired")
|
||||
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||
public ResponseEntity<Map<String, Object>> validateSession(
|
||||
@Parameter(description = "Session ID to validate", required = true) @PathVariable
|
||||
String sessionId) {
|
||||
|
||||
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
|
||||
if (featureCheck != null) {
|
||||
return featureCheck;
|
||||
}
|
||||
|
||||
MobileScannerService.SessionInfo sessionInfo =
|
||||
mobileScannerService.validateSession(sessionId);
|
||||
|
||||
if (sessionInfo == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("valid", false, "error", "Session not found or expired"));
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("valid", true);
|
||||
response.put("sessionId", sessionInfo.getSessionId());
|
||||
response.put("createdAt", sessionInfo.getCreatedAt());
|
||||
response.put("expiresAt", sessionInfo.getExpiresAt());
|
||||
response.put("timeoutMs", sessionInfo.getTimeoutMs());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload files from mobile device
|
||||
*
|
||||
* @param sessionId Unique session identifier from QR code
|
||||
* @param files Files to upload
|
||||
* @return Upload status
|
||||
*/
|
||||
@PostMapping("/upload/{sessionId}")
|
||||
@Operation(
|
||||
summary = "Upload scanned files from mobile device",
|
||||
description = "Mobile devices upload scanned images to a temporary session")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Files uploaded successfully",
|
||||
content = @Content(schema = @Schema(implementation = UploadResponse.class)))
|
||||
@ApiResponse(responseCode = "400", description = "Invalid session ID or files")
|
||||
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||
@ApiResponse(responseCode = "500", description = "Upload failed")
|
||||
public ResponseEntity<Map<String, Object>> uploadFiles(
|
||||
@Parameter(description = "Session ID from QR code", required = true) @PathVariable
|
||||
String sessionId,
|
||||
@Parameter(description = "Files to upload", required = true) @RequestParam("files")
|
||||
List<MultipartFile> files) {
|
||||
|
||||
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
|
||||
if (featureCheck != null) {
|
||||
return featureCheck;
|
||||
}
|
||||
|
||||
try {
|
||||
if (files == null || files.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "No files provided"));
|
||||
}
|
||||
|
||||
mobileScannerService.uploadFiles(sessionId, files);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("sessionId", sessionId);
|
||||
response.put("filesUploaded", files.size());
|
||||
response.put("message", "Files uploaded successfully");
|
||||
|
||||
log.info("Mobile scanner upload: session={}, files={}", sessionId, files.size());
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Invalid mobile scanner upload request: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to upload files for session: {}", sessionId, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to save files"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of uploaded files for a session
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @return List of file metadata
|
||||
*/
|
||||
@GetMapping("/files/{sessionId}")
|
||||
@Operation(
|
||||
summary = "Get uploaded files for a session",
|
||||
description = "Desktop clients poll this endpoint to check for new uploads")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "File list retrieved",
|
||||
content = @Content(schema = @Schema(implementation = FileListResponse.class)))
|
||||
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||
public ResponseEntity<Map<String, Object>> getSessionFiles(
|
||||
@Parameter(description = "Session ID", required = true) @PathVariable
|
||||
String sessionId) {
|
||||
|
||||
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
|
||||
if (featureCheck != null) {
|
||||
return featureCheck;
|
||||
}
|
||||
|
||||
List<FileMetadata> files = mobileScannerService.getSessionFiles(sessionId);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("sessionId", sessionId);
|
||||
response.put("files", files);
|
||||
response.put("count", files.size());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a specific file from a session
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @param filename Filename to download
|
||||
* @return File content
|
||||
*/
|
||||
@GetMapping("/download/{sessionId}/{filename}")
|
||||
@Operation(
|
||||
summary = "Download a specific file",
|
||||
description =
|
||||
"Download a file that was uploaded to a session. File is automatically deleted after download.")
|
||||
@ApiResponse(responseCode = "200", description = "File downloaded successfully")
|
||||
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||
@ApiResponse(responseCode = "404", description = "File or session not found")
|
||||
public ResponseEntity<Resource> downloadFile(
|
||||
@Parameter(description = "Session ID", required = true) @PathVariable String sessionId,
|
||||
@Parameter(description = "Filename to download", required = true) @PathVariable
|
||||
String filename) {
|
||||
|
||||
if (!applicationProperties.getSystem().isEnableMobileScanner()) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
|
||||
try {
|
||||
Path filePath = mobileScannerService.getFile(sessionId, filename);
|
||||
|
||||
// Read file into memory first, so we can delete it before sending
|
||||
byte[] fileBytes = Files.readAllBytes(filePath);
|
||||
|
||||
String contentType = Files.probeContentType(filePath);
|
||||
if (contentType == null) {
|
||||
contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
|
||||
}
|
||||
|
||||
// Delete file immediately after reading into memory (server-side cleanup)
|
||||
mobileScannerService.deleteFileAfterDownload(sessionId, filename);
|
||||
|
||||
// Serve from memory
|
||||
Resource resource = new org.springframework.core.io.ByteArrayResource(fileBytes);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(contentType))
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + filename + "\"")
|
||||
.body(resource);
|
||||
|
||||
} catch (IOException e) {
|
||||
log.warn("File not found: session={}, file={}", sessionId, filename);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session and all its files
|
||||
*
|
||||
* @param sessionId Session to delete
|
||||
* @return Deletion status
|
||||
*/
|
||||
@DeleteMapping("/session/{sessionId}")
|
||||
@Operation(
|
||||
summary = "Delete a session",
|
||||
description = "Manually delete a session and all its uploaded files")
|
||||
@ApiResponse(responseCode = "200", description = "Session deleted successfully")
|
||||
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
|
||||
public ResponseEntity<Map<String, Object>> deleteSession(
|
||||
@Parameter(description = "Session ID to delete", required = true) @PathVariable
|
||||
String sessionId) {
|
||||
|
||||
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
|
||||
if (featureCheck != null) {
|
||||
return featureCheck;
|
||||
}
|
||||
|
||||
mobileScannerService.deleteSession(sessionId);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of("success", true, "sessionId", sessionId, "message", "Session deleted"));
|
||||
}
|
||||
|
||||
// Response schemas for OpenAPI documentation
|
||||
private static class SessionInfoResponse {
|
||||
public boolean success;
|
||||
public String sessionId;
|
||||
public long createdAt;
|
||||
public long expiresAt;
|
||||
public long timeoutMs;
|
||||
}
|
||||
|
||||
private static class UploadResponse {
|
||||
public boolean success;
|
||||
public String sessionId;
|
||||
public int filesUploaded;
|
||||
public String message;
|
||||
}
|
||||
|
||||
private static class FileListResponse {
|
||||
public String sessionId;
|
||||
public List<FileMetadata> files;
|
||||
public int count;
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -15,6 +15,7 @@ import java.util.zip.ZipOutputStream;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.apache.pdfbox.io.IOUtils;
|
||||
import org.apache.pdfbox.multipdf.PDFMergerUtility;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
@@ -255,6 +256,7 @@ public class OCRController {
|
||||
command.add("--force-ocr");
|
||||
}
|
||||
}
|
||||
command.add("--invalidate-digital-signatures");
|
||||
|
||||
command.addAll(
|
||||
Arrays.asList(
|
||||
@@ -325,6 +327,8 @@ public class OCRController {
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(tempInputFile.toFile())) {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(
|
||||
true); // Enable subsampling to reduce memory usage
|
||||
int pageCount = document.getNumberOfPages();
|
||||
|
||||
for (int pageNum = 0; pageNum < pageCount; pageNum++) {
|
||||
@@ -414,7 +418,7 @@ public class OCRController {
|
||||
}
|
||||
|
||||
// Merge all pages into final PDF
|
||||
merger.mergeDocuments(null);
|
||||
merger.mergeDocuments(IOUtils.createTempFileOnlyStreamCache());
|
||||
|
||||
// Copy final output to the expected location
|
||||
Files.copy(
|
||||
|
||||
+104
-103
@@ -65,113 +65,114 @@ public class PageNumbersController {
|
||||
}
|
||||
}
|
||||
|
||||
PDDocument document = pdfDocumentFactory.load(file);
|
||||
|
||||
float marginFactor =
|
||||
switch (customMargin == null ? "" : customMargin.toLowerCase(Locale.ROOT)) {
|
||||
case "small" -> 0.02f;
|
||||
case "large" -> 0.05f;
|
||||
case "x-large" -> 0.075f;
|
||||
case "medium" -> 0.035f;
|
||||
default -> 0.035f;
|
||||
};
|
||||
|
||||
if (pagesToNumber == null || pagesToNumber.isEmpty()) {
|
||||
pagesToNumber = "all";
|
||||
}
|
||||
if (customText == null || customText.isEmpty()) {
|
||||
customText = "{n}";
|
||||
}
|
||||
|
||||
final String baseFilename =
|
||||
Filenames.toSimpleFileName(file.getOriginalFilename())
|
||||
.replaceFirst("[.][^.]+$", "");
|
||||
|
||||
List<Integer> pagesToNumberList =
|
||||
GeneralUtils.parsePageList(pagesToNumber.split(","), document.getNumberOfPages());
|
||||
|
||||
// Clamp position to 1..9 (1 = top-left, 9 = bottom-right)
|
||||
int pos = Math.max(1, Math.min(9, position));
|
||||
|
||||
for (int i : pagesToNumberList) {
|
||||
PDPage page = document.getPage(i);
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
|
||||
String text =
|
||||
customText
|
||||
.replace("{n}", String.valueOf(pageNumber))
|
||||
.replace("{total}", String.valueOf(document.getNumberOfPages()))
|
||||
.replace(
|
||||
"{filename}",
|
||||
GeneralUtils.removeExtension(
|
||||
Filenames.toSimpleFileName(
|
||||
file.getOriginalFilename())));
|
||||
|
||||
PDType1Font currentFont =
|
||||
switch (fontType == null ? "" : fontType.toLowerCase(Locale.ROOT)) {
|
||||
case "courier" -> new PDType1Font(Standard14Fonts.FontName.COURIER);
|
||||
case "times" -> new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN);
|
||||
default -> new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
float marginFactor =
|
||||
switch (customMargin == null ? "" : customMargin.toLowerCase(Locale.ROOT)) {
|
||||
case "small" -> 0.02f;
|
||||
case "large" -> 0.05f;
|
||||
case "x-large" -> 0.075f;
|
||||
case "medium" -> 0.035f;
|
||||
default -> 0.035f;
|
||||
};
|
||||
|
||||
// Text dimensions and font metrics
|
||||
float textWidth = currentFont.getStringWidth(text) / 1000f * fontSize;
|
||||
float ascent = currentFont.getFontDescriptor().getAscent() / 1000f * fontSize;
|
||||
float descent = currentFont.getFontDescriptor().getDescent() / 1000f * fontSize;
|
||||
|
||||
// Derive column/row in range 1..3 (1 = left/top, 2 = center/middle, 3 = right/bottom)
|
||||
int col = ((pos - 1) % 3) + 1; // 1 = left, 2 = center, 3 = right
|
||||
int row = ((pos - 1) / 3) + 1; // 1 = top, 2 = middle, 3 = bottom
|
||||
|
||||
// Anchor coordinates with margin
|
||||
float leftX = pageSize.getLowerLeftX() + marginFactor * pageSize.getWidth();
|
||||
float midX = pageSize.getLowerLeftX() + pageSize.getWidth() / 2f;
|
||||
float rightX = pageSize.getUpperRightX() - marginFactor * pageSize.getWidth();
|
||||
|
||||
float botY = pageSize.getLowerLeftY() + marginFactor * pageSize.getHeight();
|
||||
float midY = pageSize.getLowerLeftY() + pageSize.getHeight() / 2f;
|
||||
float topY = pageSize.getUpperRightY() - marginFactor * pageSize.getHeight();
|
||||
|
||||
// Horizontal alignment: left = anchor, center = centered, right = right-aligned
|
||||
float x =
|
||||
switch (col) {
|
||||
case 1 -> leftX;
|
||||
case 2 -> midX - textWidth / 2f;
|
||||
default -> rightX - textWidth;
|
||||
};
|
||||
|
||||
// Vertical alignment (baseline!):
|
||||
// top = align text top at topY,
|
||||
// middle = optical middle using ascent/descent,
|
||||
// bottom = baseline at botY
|
||||
float y =
|
||||
switch (row) {
|
||||
case 1 -> topY - ascent;
|
||||
case 2 -> midY - (ascent + descent) / 2f;
|
||||
default -> botY;
|
||||
};
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
|
||||
contentStream.beginText();
|
||||
contentStream.setFont(currentFont, fontSize);
|
||||
contentStream.setNonStrokingColor(color);
|
||||
contentStream.newLineAtOffset(x, y);
|
||||
contentStream.showText(text);
|
||||
contentStream.endText();
|
||||
if (pagesToNumber == null || pagesToNumber.isEmpty()) {
|
||||
pagesToNumber = "all";
|
||||
}
|
||||
if (customText == null || customText.isEmpty()) {
|
||||
customText = "{n}";
|
||||
}
|
||||
|
||||
pageNumber++;
|
||||
List<Integer> pagesToNumberList =
|
||||
GeneralUtils.parsePageList(
|
||||
pagesToNumber.split(","), document.getNumberOfPages());
|
||||
|
||||
// Clamp position to 1..9 (1 = top-left, 9 = bottom-right)
|
||||
int pos = Math.max(1, Math.min(9, position));
|
||||
|
||||
for (int i : pagesToNumberList) {
|
||||
PDPage page = document.getPage(i);
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
|
||||
String text =
|
||||
customText
|
||||
.replace("{n}", String.valueOf(pageNumber))
|
||||
.replace("{total}", String.valueOf(document.getNumberOfPages()))
|
||||
.replace(
|
||||
"{filename}",
|
||||
GeneralUtils.removeExtension(
|
||||
Filenames.toSimpleFileName(
|
||||
file.getOriginalFilename())));
|
||||
|
||||
PDType1Font currentFont =
|
||||
switch (fontType == null ? "" : fontType.toLowerCase(Locale.ROOT)) {
|
||||
case "courier" -> new PDType1Font(Standard14Fonts.FontName.COURIER);
|
||||
case "times" -> new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN);
|
||||
default -> new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
};
|
||||
|
||||
// Text dimensions and font metrics
|
||||
float textWidth = currentFont.getStringWidth(text) / 1000f * fontSize;
|
||||
float ascent = currentFont.getFontDescriptor().getAscent() / 1000f * fontSize;
|
||||
float descent = currentFont.getFontDescriptor().getDescent() / 1000f * fontSize;
|
||||
|
||||
// Derive column/row in range 1..3 (1 = left/top, 2 = center/middle, 3 =
|
||||
// right/bottom)
|
||||
int col = ((pos - 1) % 3) + 1; // 1 = left, 2 = center, 3 = right
|
||||
int row = ((pos - 1) / 3) + 1; // 1 = top, 2 = middle, 3 = bottom
|
||||
|
||||
// Anchor coordinates with margin
|
||||
float leftX = pageSize.getLowerLeftX() + marginFactor * pageSize.getWidth();
|
||||
float midX = pageSize.getLowerLeftX() + pageSize.getWidth() / 2f;
|
||||
float rightX = pageSize.getUpperRightX() - marginFactor * pageSize.getWidth();
|
||||
|
||||
float botY = pageSize.getLowerLeftY() + marginFactor * pageSize.getHeight();
|
||||
float midY = pageSize.getLowerLeftY() + pageSize.getHeight() / 2f;
|
||||
float topY = pageSize.getUpperRightY() - marginFactor * pageSize.getHeight();
|
||||
|
||||
// Horizontal alignment: left = anchor, center = centered, right = right-aligned
|
||||
float x =
|
||||
switch (col) {
|
||||
case 1 -> leftX;
|
||||
case 2 -> midX - textWidth / 2f;
|
||||
default -> rightX - textWidth;
|
||||
};
|
||||
|
||||
// Vertical alignment (baseline!):
|
||||
// top = align text top at topY,
|
||||
// middle = optical middle using ascent/descent,
|
||||
// bottom = baseline at botY
|
||||
float y =
|
||||
switch (row) {
|
||||
case 1 -> topY - ascent;
|
||||
case 2 -> midY - (ascent + descent) / 2f;
|
||||
default -> botY;
|
||||
};
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true)) {
|
||||
contentStream.beginText();
|
||||
contentStream.setFont(currentFont, fontSize);
|
||||
contentStream.setNonStrokingColor(color);
|
||||
contentStream.newLineAtOffset(x, y);
|
||||
contentStream.showText(text);
|
||||
contentStream.endText();
|
||||
}
|
||||
|
||||
pageNumber++;
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
baos.toByteArray(),
|
||||
GeneralUtils.generateFilename(
|
||||
file.getOriginalFilename(), "_page_numbers_added.pdf"));
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
document.close();
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
baos.toByteArray(),
|
||||
GeneralUtils.generateFilename(
|
||||
file.getOriginalFilename(), "_page_numbers_added.pdf"));
|
||||
}
|
||||
}
|
||||
|
||||
+43
-30
@@ -7,7 +7,10 @@ import java.awt.print.Printable;
|
||||
import java.awt.print.PrinterException;
|
||||
import java.awt.print.PrinterJob;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
|
||||
@@ -77,38 +80,48 @@ public class PrintFileController {
|
||||
log.info("Selected Printer: {}", selectedService.getName());
|
||||
|
||||
if (MediaType.APPLICATION_PDF_VALUE.equals(contentType)) {
|
||||
PDDocument document = Loader.loadPDF(file.getBytes());
|
||||
PrinterJob job = PrinterJob.getPrinterJob();
|
||||
job.setPrintService(selectedService);
|
||||
job.setPageable(new PDFPageable(document));
|
||||
job.print();
|
||||
document.close();
|
||||
// Use Stream-to-File pattern: write to temp file first, then load from file
|
||||
Path tempFile = Files.createTempFile("print-", ".pdf");
|
||||
try {
|
||||
Files.copy(
|
||||
file.getInputStream(), tempFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
try (PDDocument document = Loader.loadPDF(tempFile.toFile())) {
|
||||
PrinterJob job = PrinterJob.getPrinterJob();
|
||||
job.setPrintService(selectedService);
|
||||
job.setPageable(new PDFPageable(document));
|
||||
job.print();
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(tempFile);
|
||||
}
|
||||
} else if (contentType.startsWith("image/")) {
|
||||
BufferedImage image = ImageIO.read(file.getInputStream());
|
||||
PrinterJob job = PrinterJob.getPrinterJob();
|
||||
job.setPrintService(selectedService);
|
||||
job.setPrintable(
|
||||
new Printable() {
|
||||
public int print(
|
||||
Graphics graphics, PageFormat pageFormat, int pageIndex)
|
||||
throws PrinterException {
|
||||
if (pageIndex != 0) {
|
||||
return NO_SUCH_PAGE;
|
||||
try (var inputStream = file.getInputStream()) {
|
||||
BufferedImage image = ImageIO.read(inputStream);
|
||||
PrinterJob job = PrinterJob.getPrinterJob();
|
||||
job.setPrintService(selectedService);
|
||||
job.setPrintable(
|
||||
new Printable() {
|
||||
public int print(
|
||||
Graphics graphics, PageFormat pageFormat, int pageIndex)
|
||||
throws PrinterException {
|
||||
if (pageIndex != 0) {
|
||||
return NO_SUCH_PAGE;
|
||||
}
|
||||
Graphics2D g2d = (Graphics2D) graphics;
|
||||
g2d.translate(
|
||||
pageFormat.getImageableX(), pageFormat.getImageableY());
|
||||
g2d.drawImage(
|
||||
image,
|
||||
0,
|
||||
0,
|
||||
(int) pageFormat.getImageableWidth(),
|
||||
(int) pageFormat.getImageableHeight(),
|
||||
null);
|
||||
return PAGE_EXISTS;
|
||||
}
|
||||
Graphics2D g2d = (Graphics2D) graphics;
|
||||
g2d.translate(
|
||||
pageFormat.getImageableX(), pageFormat.getImageableY());
|
||||
g2d.drawImage(
|
||||
image,
|
||||
0,
|
||||
0,
|
||||
(int) pageFormat.getImageableWidth(),
|
||||
(int) pageFormat.getImageableHeight(),
|
||||
null);
|
||||
return PAGE_EXISTS;
|
||||
}
|
||||
});
|
||||
job.print();
|
||||
});
|
||||
job.print();
|
||||
}
|
||||
}
|
||||
return new ResponseEntity<>(
|
||||
"File printed successfully to " + selectedService.getName(), HttpStatus.OK);
|
||||
|
||||
+7
-1
@@ -463,7 +463,13 @@ public class ScannerEffectController {
|
||||
PDPage newPage = new PDPage(new PDRectangle(page.origW, page.origH));
|
||||
document.addPage(newPage);
|
||||
|
||||
try (PDPageContentStream contentStream = new PDPageContentStream(document, newPage)) {
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
newPage,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true,
|
||||
true)) {
|
||||
PDImageXObject pdImage = LosslessFactory.createFromImage(document, page.image);
|
||||
contentStream.drawImage(
|
||||
pdImage, page.offsetX, page.offsetY, page.drawW, page.drawH);
|
||||
|
||||
+51
-46
@@ -134,60 +134,65 @@ public class StampController {
|
||||
};
|
||||
|
||||
// Load the input PDF
|
||||
PDDocument document = pdfDocumentFactory.load(pdfFile);
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
|
||||
|
||||
List<Integer> pageNumbers = request.getPageNumbersList(document, true);
|
||||
List<Integer> pageNumbers = request.getPageNumbersList(document, true);
|
||||
|
||||
for (int pageIndex : pageNumbers) {
|
||||
int zeroBasedIndex = pageIndex - 1;
|
||||
if (zeroBasedIndex >= 0 && zeroBasedIndex < document.getNumberOfPages()) {
|
||||
PDPage page = document.getPage(zeroBasedIndex);
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
float margin = marginFactor * (pageSize.getWidth() + pageSize.getHeight()) / 2;
|
||||
for (int pageIndex : pageNumbers) {
|
||||
int zeroBasedIndex = pageIndex - 1;
|
||||
if (zeroBasedIndex >= 0 && zeroBasedIndex < document.getNumberOfPages()) {
|
||||
PDPage page = document.getPage(zeroBasedIndex);
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
float margin = marginFactor * (pageSize.getWidth() + pageSize.getHeight()) / 2;
|
||||
|
||||
PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document, page, PDPageContentStream.AppendMode.APPEND, true, true);
|
||||
PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true);
|
||||
|
||||
PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState();
|
||||
graphicsState.setNonStrokingAlphaConstant(opacity);
|
||||
contentStream.setGraphicsStateParameters(graphicsState);
|
||||
PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState();
|
||||
graphicsState.setNonStrokingAlphaConstant(opacity);
|
||||
contentStream.setGraphicsStateParameters(graphicsState);
|
||||
|
||||
if ("text".equalsIgnoreCase(stampType)) {
|
||||
addTextStamp(
|
||||
contentStream,
|
||||
stampText,
|
||||
document,
|
||||
page,
|
||||
rotation,
|
||||
position,
|
||||
fontSize,
|
||||
alphabet,
|
||||
overrideX,
|
||||
overrideY,
|
||||
margin,
|
||||
customColor);
|
||||
} else if ("image".equalsIgnoreCase(stampType)) {
|
||||
addImageStamp(
|
||||
contentStream,
|
||||
stampImage,
|
||||
document,
|
||||
page,
|
||||
rotation,
|
||||
position,
|
||||
fontSize,
|
||||
overrideX,
|
||||
overrideY,
|
||||
margin);
|
||||
if ("text".equalsIgnoreCase(stampType)) {
|
||||
addTextStamp(
|
||||
contentStream,
|
||||
stampText,
|
||||
document,
|
||||
page,
|
||||
rotation,
|
||||
position,
|
||||
fontSize,
|
||||
alphabet,
|
||||
overrideX,
|
||||
overrideY,
|
||||
margin,
|
||||
customColor);
|
||||
} else if ("image".equalsIgnoreCase(stampType)) {
|
||||
addImageStamp(
|
||||
contentStream,
|
||||
stampImage,
|
||||
document,
|
||||
page,
|
||||
rotation,
|
||||
position,
|
||||
fontSize,
|
||||
overrideX,
|
||||
overrideY,
|
||||
margin);
|
||||
}
|
||||
|
||||
contentStream.close();
|
||||
}
|
||||
|
||||
contentStream.close();
|
||||
}
|
||||
// Return the stamped PDF as a response
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_stamped.pdf"));
|
||||
}
|
||||
// Return the stamped PDF as a response
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_stamped.pdf"));
|
||||
}
|
||||
|
||||
private void addTextStamp(
|
||||
|
||||
+8
-7
@@ -133,17 +133,18 @@ public class CertSignController {
|
||||
signature.setReason(reason);
|
||||
signature.setSignDate(Calendar.getInstance()); // PDFBox requires Calendar
|
||||
if (Boolean.TRUE.equals(showSignature)) {
|
||||
SignatureOptions signatureOptions = new SignatureOptions();
|
||||
signatureOptions.setVisualSignature(
|
||||
instance.createVisibleSignature(doc, signature, pageNumber, showLogo));
|
||||
signatureOptions.setPage(pageNumber);
|
||||
|
||||
doc.addSignature(signature, instance, signatureOptions);
|
||||
try (SignatureOptions signatureOptions = new SignatureOptions()) {
|
||||
signatureOptions.setVisualSignature(
|
||||
instance.createVisibleSignature(doc, signature, pageNumber, showLogo));
|
||||
signatureOptions.setPage(pageNumber);
|
||||
|
||||
doc.addSignature(signature, instance, signatureOptions);
|
||||
doc.saveIncremental(output);
|
||||
}
|
||||
} else {
|
||||
doc.addSignature(signature, instance);
|
||||
doc.saveIncremental(output);
|
||||
}
|
||||
doc.saveIncremental(output);
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("PDF signing", e);
|
||||
}
|
||||
|
||||
+30
-4
@@ -3,6 +3,8 @@ package stirling.software.SPDF.controller.api.security;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
@@ -14,7 +16,8 @@ import java.util.regex.Pattern;
|
||||
import org.apache.pdfbox.cos.COSInputStream;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.cos.COSString;
|
||||
import org.apache.pdfbox.io.RandomAccessReadBuffer;
|
||||
import org.apache.pdfbox.io.RandomAccessRead;
|
||||
import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
|
||||
import org.apache.pdfbox.pdmodel.*;
|
||||
import org.apache.pdfbox.pdmodel.common.PDMetadata;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
@@ -198,10 +201,20 @@ public class GetInfoOnPDF {
|
||||
return false;
|
||||
}
|
||||
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
document.save(baos);
|
||||
// Use Stream-to-File pattern: save to temp file instead of loading into memory
|
||||
// This prevents OutOfMemoryError on large PDFs
|
||||
Path tempFile = null;
|
||||
try {
|
||||
tempFile = Files.createTempFile("preflight-", ".pdf");
|
||||
|
||||
try (RandomAccessReadBuffer source = new RandomAccessReadBuffer(baos.toByteArray())) {
|
||||
// Save document to temp file (avoids loading entire document into memory)
|
||||
try (var outputStream = Files.newOutputStream(tempFile)) {
|
||||
document.save(outputStream);
|
||||
}
|
||||
|
||||
// Use RandomAccessReadBufferedFile for efficient file-based reading
|
||||
// This avoids Windows file locking issues that occur with memory-mapped files
|
||||
try (RandomAccessRead source = new RandomAccessReadBufferedFile(tempFile.toFile())) {
|
||||
PreflightParser parser = new PreflightParser(source);
|
||||
|
||||
try (PDDocument parsedDocument = parser.parse()) {
|
||||
@@ -243,6 +256,19 @@ public class GetInfoOnPDF {
|
||||
log.debug("IOException during PDF/A validation: {}", e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.debug("Unexpected error during PDF/A validation: {}", e.getMessage());
|
||||
} finally {
|
||||
// Explicitly clean up temp file to prevent disk exhaustion
|
||||
// This must be in finally block to ensure cleanup even on exceptions
|
||||
if (tempFile != null) {
|
||||
try {
|
||||
Files.deleteIfExists(tempFile);
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Failed to delete temp file during PDF/A validation cleanup: {}",
|
||||
tempFile,
|
||||
e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
+31
-34
@@ -42,25 +42,17 @@ public class PasswordController {
|
||||
MultipartFile fileInput = request.getFileInput();
|
||||
String password = request.getPassword();
|
||||
|
||||
PDDocument document;
|
||||
try {
|
||||
document = pdfDocumentFactory.load(fileInput, password);
|
||||
} catch (IOException e) {
|
||||
// Handle password errors specifically
|
||||
if (ExceptionUtils.isPasswordError(e)) {
|
||||
throw ExceptionUtils.createPdfPasswordException(e);
|
||||
}
|
||||
throw ExceptionUtils.handlePdfException(e);
|
||||
}
|
||||
|
||||
try {
|
||||
try (PDDocument document = pdfDocumentFactory.load(fileInput, password)) {
|
||||
document.setAllSecurityToBeRemoved(true);
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
fileInput.getOriginalFilename(), "_password_removed.pdf"));
|
||||
} catch (IOException e) {
|
||||
document.close();
|
||||
// Handle password errors specifically
|
||||
if (ExceptionUtils.isPasswordError(e)) {
|
||||
throw ExceptionUtils.createPdfPasswordException(e);
|
||||
}
|
||||
ExceptionUtils.logException("password removal", e);
|
||||
throw ExceptionUtils.handlePdfException(e);
|
||||
}
|
||||
@@ -91,31 +83,36 @@ public class PasswordController {
|
||||
boolean preventPrinting = Boolean.TRUE.equals(request.getPreventPrinting());
|
||||
boolean preventPrintingFaithful = Boolean.TRUE.equals(request.getPreventPrintingFaithful());
|
||||
|
||||
PDDocument document = pdfDocumentFactory.load(fileInput);
|
||||
AccessPermission ap = new AccessPermission();
|
||||
ap.setCanAssembleDocument(!preventAssembly);
|
||||
ap.setCanExtractContent(!preventExtractContent);
|
||||
ap.setCanExtractForAccessibility(!preventExtractForAccessibility);
|
||||
ap.setCanFillInForm(!preventFillInForm);
|
||||
ap.setCanModify(!preventModify);
|
||||
ap.setCanModifyAnnotations(!preventModifyAnnotations);
|
||||
ap.setCanPrint(!preventPrinting);
|
||||
ap.setCanPrintFaithful(!preventPrintingFaithful);
|
||||
StandardProtectionPolicy spp = new StandardProtectionPolicy(ownerPassword, password, ap);
|
||||
try (PDDocument document = pdfDocumentFactory.load(fileInput)) {
|
||||
AccessPermission ap = new AccessPermission();
|
||||
ap.setCanAssembleDocument(!preventAssembly);
|
||||
ap.setCanExtractContent(!preventExtractContent);
|
||||
ap.setCanExtractForAccessibility(!preventExtractForAccessibility);
|
||||
ap.setCanFillInForm(!preventFillInForm);
|
||||
ap.setCanModify(!preventModify);
|
||||
ap.setCanModifyAnnotations(!preventModifyAnnotations);
|
||||
ap.setCanPrint(!preventPrinting);
|
||||
ap.setCanPrintFaithful(!preventPrintingFaithful);
|
||||
StandardProtectionPolicy spp =
|
||||
new StandardProtectionPolicy(ownerPassword, password, ap);
|
||||
|
||||
if (!"".equals(ownerPassword) || !"".equals(password)) {
|
||||
spp.setEncryptionKeyLength(keyLength);
|
||||
}
|
||||
spp.setPermissions(ap);
|
||||
document.protect(spp);
|
||||
if ((ownerPassword != null && ownerPassword.length() > 0)
|
||||
|| (password != null && password.length() > 0)) {
|
||||
spp.setEncryptionKeyLength(keyLength);
|
||||
}
|
||||
spp.setPermissions(ap);
|
||||
document.protect(spp);
|
||||
|
||||
if ("".equals(ownerPassword) && "".equals(password))
|
||||
if ((ownerPassword == null || ownerPassword.length() == 0)
|
||||
&& (password == null || password.length() == 0))
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
fileInput.getOriginalFilename(), "_permissions.pdf"));
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(
|
||||
fileInput.getOriginalFilename(), "_permissions.pdf"));
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
GeneralUtils.generateFilename(fileInput.getOriginalFilename(), "_passworded.pdf"));
|
||||
fileInput.getOriginalFilename(), "_passworded.pdf"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user