Compare commits

..
1201 changed files with 266641 additions and 358859 deletions
+8 -5
View File
@@ -2,17 +2,20 @@
* @Frooodle @Ludy87 @jbrunton96 @ConnorYoh * @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
# Backend # Backend
/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh @balazs-szucs /app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
#V1 frontend
/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
/app/core/src/main/resources/templates/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
#V2 frontend #V2 frontend
/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @balazs-szucs /frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle
/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87 @balazs-szucs
#V2 docker #V2 docker
/docker/backend/** @Frooodle @Ludy87 @DarioGii /docker/backend/** @Frooodle @Ludy87 @DarioGii @Ludy87
/docker/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87 /docker/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
/docker/compose/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87 /docker/compose/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
#GHA (All users) #GHA (All users)
/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87 @balazs-szucs /.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
+13 -35
View File
@@ -2,28 +2,26 @@ build: &build
- build.gradle - build.gradle
- app/(common|core|proprietary)/build.gradle - app/(common|core|proprietary)/build.gradle
openapi: &openapi app: &app
- *build
- app/(common|core|proprietary)/src/main/java/** - app/(common|core|proprietary)/src/main/java/**
docker: &docker openapi: &openapi
- Dockerfile - build.gradle
- Dockerfile.fat - app/(common|core|proprietary)/build.gradle
- Dockerfile.ultra-lite - app/(common|core|proprietary)/src/main/java/**
- ".github/workflows/build.yml"
- scripts/init.sh
- scripts/init-without-ocr.sh
- exampleYmlFiles/**
project: &project project: &project
- app/(common|core|proprietary)/src/(main|test)/java/** - app/(common|core|proprietary)/src/(main|test)/java/**
- *build - app/(common|core|proprietary)/build.gradle
- "app/(common|core|proprietary)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*" - 'app/(common|core|proprietary)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*'
- exampleYmlFiles/** - exampleYmlFiles/**
- gradle/** - gradle/**
- libs/** - libs/**
- "testing/**/!(requirements*.txt|requirements*.in)*" - testing/**
- *docker - build.gradle
- Dockerfile
- Dockerfile.fat
- Dockerfile.ultra-lite
- gradle.properties - gradle.properties
- gradlew - gradlew
- gradlew.bat - gradlew.bat
@@ -31,30 +29,10 @@ project: &project
- settings.gradle - settings.gradle
- frontend/** - frontend/**
- docker/** - docker/**
- scripts/RestartHelper.java - testing/**
frontend: &frontend frontend: &frontend
- frontend/** - frontend/**
- .github/workflows/testdriver.yml - .github/workflows/testdriver.yml
- testing/** - testing/**
- docker/** - 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
View File
@@ -1 +1 @@
allow-ghsas: GHSA-wrw7-89jp-8q8g allow-ghsas: GHSA-wrw7-89jp-8q8g
+1 -8
View File
@@ -1,9 +1,4 @@
{ {
"label_changer": [
"Frooodle",
"Ludy87",
"balazs-szucs"
],
"repo_devs": [ "repo_devs": [
"Frooodle", "Frooodle",
"sf298", "sf298",
@@ -13,8 +8,6 @@
"reecebrowne", "reecebrowne",
"DarioGii", "DarioGii",
"ConnorYoh", "ConnorYoh",
"EthanHealy01", "EthanHealy01"
"jbrunton96",
"balazs-szucs"
] ]
} }
+2 -49
View File
@@ -6,65 +6,18 @@
version: 2 version: 2
updates: updates:
- package-ecosystem: "gradle" # See documentation for possible values - package-ecosystem: "gradle" # See documentation for possible values
directories: directory: "/" # Location of package manifests
- "/" # Location of package manifests
- "/app/common"
- "/app/core"
- "/app/proprietary"
schedule: schedule:
interval: "weekly" interval: "weekly"
open-pull-requests-limit: 10
rebase-strategy: "auto" rebase-strategy: "auto"
- package-ecosystem: "docker" - package-ecosystem: "docker"
directory: "/" # Location of Dockerfile directory: "/" # Location of Dockerfile
schedule: schedule:
interval: "weekly" interval: "weekly"
rebase-strategy: "auto"
- package-ecosystem: github-actions - package-ecosystem: github-actions
directory: / directory: /
schedule: schedule:
interval: weekly 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"
+10 -15
View File
@@ -46,31 +46,26 @@ labels:
- label: 'API' - label: 'API'
title: '.*openapi.*|.*swagger.*|.*api.*' title: '.*openapi.*|.*swagger.*|.*api.*'
- label: 'v3' - label: 'v2'
base-branch: 'V3' base-branch: 'V2'
- label: 'Translation' - label: 'Translation'
files: files:
- 'frontend/public/locales/[a-zA-Z]{2}-[a-zA-Z\-]{2,7}/translation.toml' - 'app/core/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}.properties'
- 'scripts/ignore_translation.toml' - 'scripts/ignore_translation.toml'
- 'scripts/remove_translation_keys.sh' - 'app/core/src/main/resources/templates/fragments/languages.html'
- 'scripts/replace_translation_line.sh' - '.github/scripts/check_language_properties.py'
- 'scripts/translations/.*'
- '.github/scripts/check_language_toml.py'
- 'scripts/counter_translation_v3.py'
- label: 'Front End' - label: 'Front End'
files: files:
- 'app/core/src/main/resources/templates/.*'
- 'app/proprietary/src/main/resources/templates/.*'
- 'app/core/src/main/resources/static/.*' - 'app/core/src/main/resources/static/.*'
- 'app/proprietary/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/.*'
- 'frontend/**/.*'
- label: 'Tauri'
files:
- 'frontend/src-tauri/**'
- 'frontend/src-tauri/.*'
- label: 'Java' - label: 'Java'
files: files:
-17
View File
@@ -84,9 +84,6 @@
- name: "v2" - name: "v2"
color: "FFFF00" color: "FFFF00"
description: "Issues or pull requests related to the v2 branch" 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" - name: "wontfix"
description: "This will not be worked on" description: "This will not be worked on"
color: "FFFFFF" color: "FFFFFF"
@@ -187,17 +184,3 @@
- name: "codex" - name: "codex"
color: "ededed" color: "ededed"
description: "chatgpt AI generated code" description: "chatgpt AI generated code"
- 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"
-4
View File
@@ -27,10 +27,6 @@ Closes #(issue_number)
- [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed)
- [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only)
### Translations (if applicable)
- [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable) ### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR)
-4
View File
@@ -1,9 +1,5 @@
changelog: changelog:
categories: categories:
- title: Breaking Changes
labels:
- break-change
- title: Bug Fixes - title: Bug Fixes
labels: labels:
- Bug - Bug
+38 -74
View File
@@ -11,16 +11,15 @@ adjusting the format.
Usage: Usage:
python check_language_toml.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>] 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: # 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 # python .github/scripts/check_language_toml.py --reference-file frontend/public/locales/en-GB/translation.toml --branch "" --files frontend/public/locales/de-DE/translation.toml frontend/public/locales/fr-FR/translation.toml
import argparse import copy
import glob import glob
import os import os
import argparse
import re import re
from pathlib import Path import json
import tomllib # Python 3.11+ (stdlib) import tomllib # Python 3.11+ (stdlib)
import tomli_w # For writing TOML files import tomli_w # For writing TOML files
@@ -39,8 +38,7 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
duplicates = [] duplicates = []
# Load TOML file # Load TOML file
file_path = Path(file_path) with open(file_path, 'rb') as file:
with file_path.open("rb") as file:
data = tomllib.load(file) data = tomllib.load(file)
def process_dict(obj, current_prefix=""): def process_dict(obj, current_prefix=""):
@@ -59,8 +57,8 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
return duplicates return duplicates
# Maximum size for TOML files (e.g., 1 MB) # Maximum size for TOML files (e.g., 500 KB)
MAX_FILE_SIZE = 1000 * 1024 MAX_FILE_SIZE = 500 * 1024
def parse_toml_file(file_path): def parse_toml_file(file_path):
@@ -69,8 +67,7 @@ def parse_toml_file(file_path):
:param file_path: Path to the TOML file. :param file_path: Path to the TOML file.
:return: Dictionary with flattened keys. :return: Dictionary with flattened keys.
""" """
file_path = Path(file_path) with open(file_path, 'rb') as file:
with file_path.open("rb") as file:
data = tomllib.load(file) data = tomllib.load(file)
def flatten_dict(d, parent_key="", sep="."): def flatten_dict(d, parent_key="", sep="."):
@@ -113,8 +110,7 @@ def write_toml_file(file_path, updated_properties):
""" """
nested_data = unflatten_dict(updated_properties) nested_data = unflatten_dict(updated_properties)
file_path = Path(file_path) with open(file_path, "wb") as file:
with file_path.open("wb") as file:
tomli_w.dump(nested_data, file) tomli_w.dump(nested_data, file)
@@ -125,23 +121,18 @@ def update_missing_keys(reference_file, file_list, branch=""):
:param file_list: List of translation files to update. :param file_list: List of translation files to update.
:param branch: Branch where the files are located. :param branch: Branch where the files are located.
""" """
reference_file = Path(reference_file)
reference_properties = parse_toml_file(reference_file) reference_properties = parse_toml_file(reference_file)
branch_path = Path(branch) if branch else Path()
for file_path in file_list: for file_path in file_list:
file_path = Path(file_path) basename_current_file = os.path.basename(os.path.join(branch, file_path))
language_dir = file_path.parent.name
reference_lang_dir = reference_file.parent.name
if ( if (
language_dir == reference_lang_dir basename_current_file == os.path.basename(reference_file)
or file_path.suffix != ".toml" or not file_path.endswith(".toml")
or file_path.parents[1].name != "locales" or not os.path.dirname(file_path).endswith("locales")
): ):
print(f"Skipping file: {file_path}")
continue continue
current_properties = parse_toml_file(branch_path / file_path) current_properties = parse_toml_file(os.path.join(branch, file_path))
updated_properties = {} updated_properties = {}
for ref_key, ref_value in reference_properties.items(): for ref_key, ref_value in reference_properties.items():
@@ -152,7 +143,7 @@ def update_missing_keys(reference_file, file_list, branch=""):
# Add missing key with reference value # Add missing key with reference value
updated_properties[ref_key] = ref_value updated_properties[ref_key] = ref_value
write_toml_file(branch_path / file_path, updated_properties) write_toml_file(os.path.join(branch, file_path), updated_properties)
def check_for_missing_keys(reference_file, file_list, branch): def check_for_missing_keys(reference_file, file_list, branch):
@@ -160,17 +151,14 @@ def check_for_missing_keys(reference_file, file_list, branch):
def read_toml_keys(file_path): def read_toml_keys(file_path):
file_path = Path(file_path) if os.path.isfile(file_path) and os.path.exists(file_path):
if file_path.is_file():
return parse_toml_file(file_path) return parse_toml_file(file_path)
return {} return {}
def check_for_differences(reference_file, file_list, branch, actor): def check_for_differences(reference_file, file_list, branch, actor):
reference_branch = branch reference_branch = branch
reference_file = Path(reference_file) basename_reference_file = os.path.basename(reference_file)
basename_reference_file = reference_file.name
branch_path = Path(branch) if branch else Path()
report = [] report = []
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`") report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
@@ -184,44 +172,39 @@ def check_for_differences(reference_file, file_list, branch, actor):
if len(file_list) == 1: if len(file_list) == 1:
file_arr = file_list[0].split() file_arr = file_list[0].split()
base_dir = Path.cwd() / "frontend" / "public" / "locales" base_dir = os.path.abspath(
os.path.join(os.getcwd(), "frontend", "public", "locales")
)
for file_path in file_arr: for file_path in file_arr:
file_path = Path(file_path) file_normpath = os.path.normpath(file_path)
file_normpath = file_path absolute_path = os.path.abspath(file_normpath)
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 # Verify that file is within the expected directory
if not absolute_path.is_relative_to(base_dir): if not absolute_path.startswith(base_dir):
has_differences = True raise ValueError(f"Unsafe file found: {file_normpath}")
report.append(
f"\n⚠️ Unsafe file found: `{locale_dir}/{basename_current_file}`\n\n---\n"
)
continue
# Verify file size before processing # Verify file size before processing
if (branch_path / file_normpath).stat().st_size > MAX_FILE_SIZE: if os.path.getsize(os.path.join(branch, file_normpath)) > MAX_FILE_SIZE:
has_differences = True raise ValueError(
report.append( f"The file {file_normpath} is too large and could pose a security risk."
f"\n⚠️ The file `{locale_dir}/{basename_current_file}` is too large and could pose a security risk.\n\n---\n"
) )
continue
if basename_current_file == basename_reference_file and locale_dir == "en-GB": basename_current_file = os.path.basename(os.path.join(branch, file_normpath))
continue locale_dir = os.path.basename(os.path.dirname(file_normpath))
if ( if (
file_normpath.suffix != ".toml" basename_current_file == basename_reference_file
or basename_current_file != "translation.toml" and locale_dir == "en-GB"
): ):
continue continue
if not file_normpath.endswith(".toml") or basename_current_file != "translation.toml":
continue
only_reference_file = False only_reference_file = False
current_keys = read_toml_keys(branch_path / file_path) report.append(f"#### 📃 **File Check:** `{locale_dir}/{basename_current_file}`")
current_keys = read_toml_keys(os.path.join(branch, file_path))
reference_key_count = len(reference_keys) reference_key_count = len(reference_keys)
current_key_count = len(current_keys) current_key_count = len(current_keys)
@@ -259,37 +242,20 @@ def check_for_differences(reference_file, file_list, branch, actor):
report.append( report.append(
f" - **_Extra keys in `{locale_dir}/{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**." f" - **_Extra keys in `{locale_dir}/{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**."
) )
report.append("")
report.append(" Use the following command to remove them:")
report.append(
f" `python scripts/translations/translation_merger.py {locale_dir} remove-unused`"
)
report.append("")
if extra_keys_list: if extra_keys_list:
report.append( report.append(
f" - **_Missing keys in `{locale_dir}/{basename_current_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_reference_file}`_**." f" - **_Missing keys in `{locale_dir}/{basename_current_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_reference_file}`_**."
) )
report.append("")
report.append(" Use the following command to add them:")
report.append(
f" `python scripts/translations/translation_merger.py {locale_dir} add-missing`"
)
report.append("")
if missing_keys_list or extra_keys_list:
report.append(
" See: https://github.com/Stirling-Tools/Stirling-PDF/tree/main/scripts/translations#2-translation_mergerpy"
)
else: else:
report.append("2. **Test Status:** ✅ **_Passed_**") report.append("2. **Test Status:** ✅ **_Passed_**")
if find_duplicate_keys(branch_path / file_normpath): if find_duplicate_keys(os.path.join(branch, file_normpath)):
has_differences = True has_differences = True
output = "\n".join( output = "\n".join(
[ [
f" - `{key}`: first at {first}, duplicate at `{duplicate}`" f" - `{key}`: first at {first}, duplicate at `{duplicate}`"
for key, first, duplicate in find_duplicate_keys( for key, first, duplicate in find_duplicate_keys(
branch_path / file_normpath os.path.join(branch, file_normpath)
) )
] ]
) )
@@ -322,9 +288,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description="Find missing keys in TOML translation files")
description="Find missing keys in TOML translation files"
)
parser.add_argument( parser.add_argument(
"--actor", "--actor",
required=False, required=False,
-1
View File
@@ -6,4 +6,3 @@ pillow
unoserver unoserver
opencv-python-headless opencv-python-headless
pre-commit pre-commit
brotli @ git+https://github.com/google/brotli.git@028fb5a23661f123017c060daa546b55cf4bde29
File diff suppressed because it is too large Load Diff
+21 -21
View File
@@ -4,33 +4,33 @@
# #
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' --strip-extras '.github\scripts\requirements_pre_commit.in' # pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' --strip-extras '.github\scripts\requirements_pre_commit.in'
# #
cfgv==3.5.0 \ cfgv==3.4.0 \
--hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \ --hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \
--hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132 --hash=sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560
# via pre-commit # via pre-commit
distlib==0.4.0 \ distlib==0.4.0 \
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \ --hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d --hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
# via virtualenv # via virtualenv
filelock==3.20.3 \ filelock==3.19.1 \
--hash=sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1 \ --hash=sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58 \
--hash=sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1 --hash=sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d
# via virtualenv # via virtualenv
identify==2.6.16 \ identify==2.6.15 \
--hash=sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0 \ --hash=sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757 \
--hash=sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980 --hash=sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf
# via pre-commit # via pre-commit
nodeenv==1.10.0 \ nodeenv==1.9.1 \
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \ --hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb --hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9
# via pre-commit # via pre-commit
platformdirs==4.5.1 \ platformdirs==4.4.0 \
--hash=sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda \ --hash=sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85 \
--hash=sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31 --hash=sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf
# via virtualenv # via virtualenv
pre-commit==4.5.1 \ pre-commit==4.3.0 \
--hash=sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77 \ --hash=sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8 \
--hash=sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61 --hash=sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16
# via -r .github/scripts/requirements_pre_commit.in # via -r .github/scripts/requirements_pre_commit.in
pyyaml==6.0.3 \ pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
@@ -107,7 +107,7 @@ pyyaml==6.0.3 \
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
# via pre-commit # via pre-commit
virtualenv==20.36.1 \ virtualenv==20.34.0 \
--hash=sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f \ --hash=sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026 \
--hash=sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba --hash=sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a
# via pre-commit # via pre-commit
@@ -1,2 +1 @@
tomlkit tomlkit
tomli-w
+3 -7
View File
@@ -4,11 +4,7 @@
# #
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_sync_readme.txt' --strip-extras '.github\scripts\requirements_sync_readme.in' # 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 \ tomlkit==0.13.3 \
--hash=sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90 \ --hash=sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1 \
--hash=sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021 --hash=sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0
# via -r .github/scripts/requirements_sync_readme.in
tomlkit==0.14.0 \
--hash=sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680 \
--hash=sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064
# via -r .github/scripts/requirements_sync_readme.in # via -r .github/scripts/requirements_sync_readme.in
+126 -69
View File
@@ -11,10 +11,6 @@ on:
allow_fork: allow_fork:
description: "Allow deploying fork PR?" description: "Allow deploying fork PR?"
required: false required: false
type: choice
options:
- "true"
- "false"
default: "false" default: "false"
permissions: permissions:
@@ -35,18 +31,18 @@ jobs:
pr_ref: ${{ steps.resolve.outputs.ref }} pr_ref: ${{ steps.resolve.outputs.ref }}
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
- name: Resolve PR info - name: Resolve PR info
id: resolve id: resolve
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with: with:
script: | script: |
const { owner, repo } = context.repo; const { owner, repo } = context.repo;
let prNumber = context.eventName === 'workflow_dispatch' let prNumber = context.eventName === 'workflow_dispatch'
? parseInt(context.payload.inputs.pr, 10) ? parseInt(process.env.INPUT_PR, 10)
: context.payload.number; : context.payload.number;
if (!Number.isInteger(prNumber)) { core.setFailed('Invalid PR number'); return; } if (!Number.isInteger(prNumber)) { core.setFailed('Invalid PR number'); return; }
@@ -56,6 +52,7 @@ jobs:
core.setOutput('repository', pr.head.repo.full_name); core.setOutput('repository', pr.head.repo.full_name);
core.setOutput('ref', pr.head.ref); core.setOutput('ref', pr.head.ref);
core.setOutput('is_fork', String(pr.head.repo.fork)); core.setOutput('is_fork', String(pr.head.repo.fork));
core.setOutput('base_ref', pr.base.ref);
core.setOutput('author', pr.user.login); core.setOutput('author', pr.user.login);
core.setOutput('state', pr.state); core.setOutput('state', pr.state);
@@ -68,6 +65,10 @@ jobs:
IS_FORK: ${{ steps.resolve.outputs.is_fork }} IS_FORK: ${{ steps.resolve.outputs.is_fork }}
# nur bei workflow_dispatch gesetzt: # nur bei workflow_dispatch gesetzt:
ALLOW_FORK_INPUT: ${{ inputs.allow_fork }} ALLOW_FORK_INPUT: ${{ inputs.allow_fork }}
# für Auto-PR-Logik:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
PR_BASE: ${{ steps.resolve.outputs.base_ref }}
PR_AUTHOR: ${{ steps.resolve.outputs.author }} PR_AUTHOR: ${{ steps.resolve.outputs.author }}
run: | run: |
set -e set -e
@@ -88,8 +89,14 @@ jobs:
else else
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs") auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs")
is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done
if [ "$is_auth" = true ]; then if [ "$PR_BASE" = "V2" ] && [ "$is_auth" = true ]; then
should=true should=true
else
title_has_v2=false; echo "$PR_TITLE" | grep -qiE 'v2|version.?2|version.?two' && title_has_v2=true
branch_has_kw=false; echo "$PR_BRANCH" | grep -qiE 'v2|react' && branch_has_kw=true
if [ "$is_auth" = true ] && { [ "$title_has_v2" = true ] || [ "$branch_has_kw" = true ]; }; then
should=true
fi
fi fi
fi fi
@@ -111,12 +118,12 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
- name: Checkout main repository - name: Checkout main repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
repository: ${{ github.repository }} repository: ${{ github.repository }}
ref: main ref: main
@@ -132,13 +139,13 @@ jobs:
- name: Add deployment started comment - name: Add deployment started comment
id: deployment-started id: deployment-started
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with: with:
github-token: ${{ steps.setup-bot.outputs.token }} github-token: ${{ steps.setup-bot.outputs.token }}
script: | script: |
const { owner, repo } = context.repo; const { owner, repo } = context.repo;
const prNumber = ${{ needs.check-pr.outputs.pr_number }}; const prNumber = ${{ needs.check-pr.outputs.pr_number }};
// Delete previous V2 deployment comments to avoid clutter // Delete previous V2 deployment comments to avoid clutter
const { data: comments } = await github.rest.issues.listComments({ const { data: comments } = await github.rest.issues.listComments({
owner, owner,
@@ -161,26 +168,26 @@ jobs:
comment_id: comment.id comment_id: comment.id
}); });
} }
// Create new deployment started comment // Create new deployment started comment
const { data: newComment } = await github.rest.issues.createComment({ const { data: newComment } = await github.rest.issues.createComment({
owner, owner,
repo, repo,
issue_number: prNumber, issue_number: prNumber,
body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment for approved V2 contributors._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.` body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment triggered by V2/version2 keywords in the PR title or V2/React keywords in the branch name._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.`
}); });
return newComment.id; return newComment.id;
- name: Checkout PR - name: Checkout PR
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
repository: ${{ needs.check-pr.outputs.pr_repository }} repository: ${{ needs.check-pr.outputs.pr_repository }}
ref: ${{ needs.check-pr.outputs.pr_ref }} ref: ${{ needs.check-pr.outputs.pr_ref }}
token: ${{ secrets.GITHUB_TOKEN }} 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 - name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Get version number - name: Get version number
id: versionNumber id: versionNumber
@@ -189,56 +196,93 @@ jobs:
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with: with:
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }} password: ${{ secrets.DOCKER_HUB_API }}
- name: Get commit hash for app - name: Get commit hashes for frontend and backend
id: commit-hash id: commit-hashes
run: | run: |
# Get last commit that touched the application code # Get last commit that touched the frontend folder, docker/frontend, or docker/compose
APP_HASH=$(git log -1 --format="%H" -- . 2>/dev/null || echo "") FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "")
if [ -z "$APP_HASH" ]; then if [ -z "$FRONTEND_HASH" ]; then
APP_HASH="no-changes" FRONTEND_HASH="no-frontend-changes"
fi fi
echo "App hash: $APP_HASH" # Get last commit that touched backend code, docker/backend, or docker/compose
echo "app_hash=$APP_HASH" >> $GITHUB_OUTPUT BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
if [ -z "$BACKEND_HASH" ]; then
# Short hash for tags BACKEND_HASH="no-backend-changes"
if [ "$APP_HASH" = "no-changes" ]; then fi
echo "app_short=no-changes" >> $GITHUB_OUTPUT
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 else
echo "app_short=${APP_HASH:0:8}" >> $GITHUB_OUTPUT 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
echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT
fi fi
- name: Check if image exists - name: Check if frontend image exists
id: check-image id: check-frontend
run: | run: |
if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }} >/dev/null 2>&1; then if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT echo "exists=true" >> $GITHUB_OUTPUT
echo "Image already exists, skipping build" echo "Frontend image already exists, skipping build"
else else
echo "exists=false" >> $GITHUB_OUTPUT echo "exists=false" >> $GITHUB_OUTPUT
echo "Image needs to be built" echo "Frontend image needs to be built"
fi fi
- name: Build and push V2 image - name: Check if backend image exists
if: steps.check-image.outputs.exists == 'false' id: check-backend
run: |
if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Backend image already exists, skipping build"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Backend image needs to be built"
fi
- name: Build and push V2 frontend image
if: steps.check-frontend.outputs.exists == 'false'
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with: with:
context: . context: .
file: ./docker/embedded/Dockerfile file: ./docker/frontend/Dockerfile
push: true push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }} tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Build and push V2 backend image
if: steps.check-backend.outputs.exists == 'false'
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./docker/backend/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
build-args: VERSION_TAG=v2-alpha build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64 platforms: linux/amd64
- name: Set up SSH - name: Set up SSH
run: | run: |
mkdir -p ~/.ssh/ mkdir -p ~/.ssh/
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key sudo chmod 600 ../private.key
- name: Deploy V2 to VPS - name: Deploy V2 to VPS
@@ -246,16 +290,17 @@ jobs:
run: | run: |
# Use same port strategy as regular PRs - just the PR number # Use same port strategy as regular PRs - just the PR number
V2_PORT=${{ needs.check-pr.outputs.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 unified embedded image
# Create docker-compose for V2 with separate frontend and backend
cat > docker-compose.yml << EOF cat > docker-compose.yml << EOF
version: '3.3' version: '3.3'
services: services:
stirling-pdf-v2: stirling-pdf-v2-backend:
container_name: stirling-pdf-v2-pr-${{ needs.check-pr.outputs.pr_number }} container_name: stirling-pdf-v2-backend-pr-${{ needs.check-pr.outputs.pr_number }}
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }} image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
ports: ports:
- "${V2_PORT}:8080" - "${BACKEND_PORT}:8080" # Backend API port
volumes: volumes:
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/data:/usr/share/tessdata:rw - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/data:/usr/share/tessdata:rw
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/config:/configs:rw - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/config:/configs:rw
@@ -267,7 +312,7 @@ jobs:
SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}" SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}"
SYSTEM_DEFAULTLOCALE: en-GB SYSTEM_DEFAULTLOCALE: en-GB
UI_APPNAME: "Stirling-PDF V2 PR#${{ needs.check-pr.outputs.pr_number }}" UI_APPNAME: "Stirling-PDF V2 PR#${{ needs.check-pr.outputs.pr_number }}"
UI_HOMEDESCRIPTION: "V2 PR#${{ needs.check-pr.outputs.pr_number }} - Embedded Architecture" UI_HOMEDESCRIPTION: "V2 PR#${{ needs.check-pr.outputs.pr_number }} - Frontend/Backend Split Architecture"
UI_APPNAMENAVBAR: "V2 PR#${{ needs.check-pr.outputs.pr_number }}" UI_APPNAMENAVBAR: "V2 PR#${{ needs.check-pr.outputs.pr_number }}"
SYSTEM_MAXFILESIZE: "100" SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true" METRICS_ENABLED: "true"
@@ -275,12 +320,23 @@ jobs:
SWAGGER_SERVER_URL: "https://${V2_PORT}.ssl.stirlingpdf.cloud" SWAGGER_SERVER_URL: "https://${V2_PORT}.ssl.stirlingpdf.cloud"
baseUrl: "https://${V2_PORT}.ssl.stirlingpdf.cloud" baseUrl: "https://${V2_PORT}.ssl.stirlingpdf.cloud"
restart: on-failure:5 restart: on-failure:5
stirling-pdf-v2-frontend:
container_name: stirling-pdf-v2-frontend-pr-${{ needs.check-pr.outputs.pr_number }}
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
ports:
- "${V2_PORT}:80" # Frontend port (same as regular PRs)
environment:
VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:${BACKEND_PORT}"
depends_on:
- stirling-pdf-v2-backend
restart: on-failure:5
EOF EOF
# Deploy to VPS # Deploy to VPS
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose-v2.yml scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose-v2.yml
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
# Create V2 PR-specific directories # Create V2 PR-specific directories
mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs} mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs}
@@ -290,15 +346,15 @@ jobs:
# Stop any existing container and clean up # Stop any existing container and clean up
cd /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }} cd /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}
docker-compose down --remove-orphans 2>/dev/null || true docker-compose down --remove-orphans 2>/dev/null || true
# Start the new container # Start the new container
docker-compose pull docker-compose pull
docker-compose up -d docker-compose up -d
# Clean up unused Docker resources to save space # Clean up unused Docker resources to save space
docker system prune -af --volumes || true docker system prune -af --volumes || true
# Clean up old images (older than 2 weeks) # Clean up old backend/frontend images (older than 2 weeks)
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
ENDSSH ENDSSH
@@ -307,14 +363,14 @@ jobs:
- name: Post V2 deployment URL to PR - name: Post V2 deployment URL to PR
if: success() if: success()
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with: with:
github-token: ${{ steps.setup-bot.outputs.token }} github-token: ${{ steps.setup-bot.outputs.token }}
script: | script: |
const { owner, repo } = context.repo; const { owner, repo } = context.repo;
const prNumber = ${{ needs.check-pr.outputs.pr_number }}; const prNumber = ${{ needs.check-pr.outputs.pr_number }};
const v2Port = ${{ steps.deploy.outputs.v2_port }}; const v2Port = ${{ steps.deploy.outputs.v2_port }};
// Delete the "deploying..." comment since we're posting the final result // Delete the "deploying..." comment since we're posting the final result
const deploymentStartedId = ${{ steps.deployment-started.outputs.result }}; const deploymentStartedId = ${{ steps.deployment-started.outputs.result }};
if (deploymentStartedId) { if (deploymentStartedId) {
@@ -329,16 +385,16 @@ jobs:
console.log(`Could not delete deployment started comment: ${error.message}`); console.log(`Could not delete deployment started comment: ${error.message}`);
} }
} }
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`; const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${v2Port}`;
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`; const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` + const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
`Your V2 PR with embedded architecture has been deployed!\n\n` + `Your V2 PR with the new frontend/backend split architecture has been deployed!\n\n` +
`🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` + `🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` +
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` + `🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` + `_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
`🔄 **Auto-deployed** for approved V2 contributors.`; `🔄 **Auto-deployed** because PR title or branch name contains V2/version2/React keywords.`;
await github.rest.issues.createComment({ await github.rest.issues.createComment({
owner, owner,
@@ -357,12 +413,12 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with: with:
egress-policy: audit egress-policy: audit
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup GitHub App Bot - name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]' if: github.actor != 'dependabot[bot]'
@@ -374,25 +430,25 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Clean up V2 deployment comments - name: Clean up V2 deployment comments
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with: with:
github-token: ${{ steps.setup-bot.outputs.token }} github-token: ${{ steps.setup-bot.outputs.token }}
script: | script: |
const { owner, repo } = context.repo; const { owner, repo } = context.repo;
const prNumber = ${{ github.event.pull_request.number }}; const prNumber = ${{ github.event.pull_request.number }};
// Find and delete V2 deployment comments // Find and delete V2 deployment comments
const { data: comments } = await github.rest.issues.listComments({ const { data: comments } = await github.rest.issues.listComments({
owner, owner,
repo, repo,
issue_number: prNumber issue_number: prNumber
}); });
const v2Comments = comments.filter(c => const v2Comments = comments.filter(c =>
c.body?.includes("## 🚀 V2 Auto-Deployment Complete!") && c.body?.includes("## 🚀 V2 Auto-Deployment Complete!") &&
c.user?.type === "Bot" c.user?.type === "Bot"
); );
for (const comment of v2Comments) { for (const comment of v2Comments) {
await github.rest.issues.deleteComment({ await github.rest.issues.deleteComment({
owner, owner,
@@ -405,12 +461,12 @@ jobs:
- name: Set up SSH - name: Set up SSH
run: | run: |
mkdir -p ~/.ssh/ mkdir -p ~/.ssh/
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key sudo chmod 600 ../private.key
- name: Cleanup V2 deployment - name: Cleanup V2 deployment
run: | run: |
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << 'ENDSSH' ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
if [ -d "/stirling/V2-PR-${{ github.event.pull_request.number }}" ]; then if [ -d "/stirling/V2-PR-${{ github.event.pull_request.number }}" ]; then
echo "Found V2 PR directory, proceeding with cleanup..." echo "Found V2 PR directory, proceeding with cleanup..."
@@ -424,8 +480,9 @@ jobs:
# Remove V2 PR-specific directories # Remove V2 PR-specific directories
rm -rf /stirling/V2-PR-${{ github.event.pull_request.number }} rm -rf /stirling/V2-PR-${{ github.event.pull_request.number }}
# Clean up V2 container by name (in case compose cleanup missed it) # Clean up V2 containers by name (in case compose cleanup missed them)
docker rm -f stirling-pdf-v2-pr-${{ github.event.pull_request.number }} || true docker rm -f stirling-pdf-v2-frontend-pr-${{ github.event.pull_request.number }} || true
docker rm -f stirling-pdf-v2-backend-pr-${{ github.event.pull_request.number }} || true
echo "V2 cleanup completed" echo "V2 cleanup completed"
else else
+17 -171
View File
@@ -14,7 +14,6 @@ jobs:
permissions: permissions:
issues: write issues: write
if: | if: |
vars.CI_PROFILE != 'lite' &&
github.event.issue.pull_request && github.event.issue.pull_request &&
( (
contains(github.event.comment.body, 'prdeploy') || contains(github.event.comment.body, 'prdeploy') ||
@@ -25,7 +24,8 @@ jobs:
github.event.comment.user.login == 'frooodle' || github.event.comment.user.login == 'frooodle' ||
github.event.comment.user.login == 'sf298' || github.event.comment.user.login == 'sf298' ||
github.event.comment.user.login == 'Ludy87' || github.event.comment.user.login == 'Ludy87' ||
github.event.comment.user.login == 'balazs-szucs' || github.event.comment.user.login == 'LaserKaspar' ||
github.event.comment.user.login == 'sbplat' ||
github.event.comment.user.login == 'reecebrowne' || github.event.comment.user.login == 'reecebrowne' ||
github.event.comment.user.login == 'DarioGii' || github.event.comment.user.login == 'DarioGii' ||
github.event.comment.user.login == 'EthanHealy01' || github.event.comment.user.login == 'EthanHealy01' ||
@@ -40,12 +40,12 @@ jobs:
enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }} enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }}
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- name: Checkout PR - name: Checkout PR
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup GitHub App Bot - name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]' if: github.actor != 'dependabot[bot]'
@@ -128,12 +128,12 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- name: Checkout PR - name: Checkout PR
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup GitHub App Bot - name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]' if: github.actor != 'dependabot[bot]'
@@ -145,22 +145,17 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Checkout PR - name: Checkout PR
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with: with:
ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge
token: ${{ steps.setup-bot.outputs.token }} token: ${{ steps.setup-bot.outputs.token }}
- name: Set up JDK 21 - name: Set up JDK
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with: with:
java-version: "21" java-version: "17"
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
- name: Run Gradle Command - name: Run Gradle Command
run: | run: |
if [ "${{ needs.check-comment.outputs.disable_security }}" == "true" ]; then if [ "${{ needs.check-comment.outputs.disable_security }}" == "true" ]; then
@@ -168,18 +163,15 @@ jobs:
else else
export DISABLE_ADDITIONAL_FEATURES=false export DISABLE_ADDITIONAL_FEATURES=false
fi fi
./gradlew build ./gradlew clean build
env: env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
STIRLING_PDF_DESKTOP_UI: false STIRLING_PDF_DESKTOP_UI: false
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: with:
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }} password: ${{ secrets.DOCKER_HUB_API }}
@@ -197,7 +189,7 @@ jobs:
- name: Set up SSH - name: Set up SSH
run: | run: |
mkdir -p ~/.ssh/ mkdir -p ~/.ssh/
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key sudo chmod 600 ../private.key
- name: Deploy to VPS - name: Deploy to VPS
@@ -259,9 +251,9 @@ jobs:
EOF EOF
# Then copy the file and execute commands # Then copy the file and execute commands
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose.yml scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose.yml
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
# Create PR-specific directories # Create PR-specific directories
mkdir -p /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/{data,config,logs} mkdir -p /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/{data,config,logs}
@@ -343,7 +335,7 @@ jobs:
const prNumber = ${{ needs.check-comment.outputs.pr_number }}; const prNumber = ${{ needs.check-comment.outputs.pr_number }};
const securityStatus = process.env.security_status || "Security Disabled"; const securityStatus = process.env.security_status || "Security Disabled";
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${prNumber}`; const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${prNumber}`;
const commentBody = `## 🚀 PR Test Deployment\n\n` + const commentBody = `## 🚀 PR Test Deployment\n\n` +
`Your PR has been deployed for testing!\n\n` + `Your PR has been deployed for testing!\n\n` +
`🔗 **Test URL:** [${deploymentUrl}](${deploymentUrl})\n` + `🔗 **Test URL:** [${deploymentUrl}](${deploymentUrl})\n` +
@@ -364,149 +356,3 @@ jobs:
rm -f ../private.key docker-compose.yml rm -f ../private.key docker-compose.yml
echo "Cleanup complete." echo "Cleanup complete."
continue-on-error: true continue-on-error: true
handle-label-commands:
if: ${{ github.event.issue.pull_request != null }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
- name: Check out the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup GitHub App Bot
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Apply label commands
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const fs = require('fs');
const path = require('path');
const { comment, issue } = context.payload;
const commentBody = comment?.body ?? '';
if (!commentBody.includes('::label::')) {
core.info('No label commands detected in comment.');
return;
}
const configPath = path.join(process.env.GITHUB_WORKSPACE, '.github', 'config', 'repo_devs.json');
const repoDevsConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const label_changer = (repoDevsConfig.label_changer || []).map((login) => login.toLowerCase());
const commenter = (comment?.user?.login || '').toLowerCase();
if (!label_changer.includes(commenter)) {
core.info(`User ${commenter} is not authorized to manage labels.`);
return;
}
const labelsConfigPath = path.join(process.env.GITHUB_WORKSPACE, '.github', 'labels.yml');
const labelsFile = fs.readFileSync(labelsConfigPath, 'utf8');
const labelNameMap = new Map();
for (const match of labelsFile.matchAll(/-\s+name:\s*(?:"([^"]+)"|'([^']+)'|([^\n]+))/g)) {
const labelName = (match[1] ?? match[2] ?? match[3] ?? '').trim();
if (!labelName) {
continue;
}
const normalized = labelName.toLowerCase();
if (!labelNameMap.has(normalized)) {
labelNameMap.set(normalized, labelName);
}
}
if (!labelNameMap.size) {
core.warning('No labels could be read from .github/labels.yml; aborting label commands.');
return;
}
let allowedLabelNames = new Set(labelNameMap.values());
const labelsToAdd = new Set();
const labelsToRemove = new Set();
const commandRegex = /^(\w+)::(label)::"([^"]+)"/gim;
let match;
while ((match = commandRegex.exec(commentBody)) !== null) {
core.info(`Found label command: ${match[0]} (action: ${match[1]}, label: ${match[2]}, labelName: ${match[3]})`);
const action = match[1].toLowerCase();
const labelName = match[3].trim();
if (!labelName) {
continue;
}
const normalized = labelName.toLowerCase();
const resolvedLabelName = labelNameMap.get(normalized);
if (action === 'add') {
if (!resolvedLabelName) {
core.warning(`Label "${labelName}" is not defined in .github/labels.yml and cannot be added.`);
continue;
}
if (!allowedLabelNames.has(resolvedLabelName)) {
core.warning(`Label "${resolvedLabelName}" is not allowed for add commands and will be skipped.`);
continue;
}
labelsToAdd.add(resolvedLabelName);
} else if (action === 'rm') {
const labelToRemove = resolvedLabelName ?? labelName;
if (!resolvedLabelName) {
core.warning(`Label "${labelName}" is not defined in .github/labels.yml; attempting to remove as provided.`);
}
labelsToRemove.add(labelToRemove);
}
}
const addLabels = Array.from(labelsToAdd);
const removeLabels = Array.from(labelsToRemove);
if (!addLabels.length && !removeLabels.length) {
core.info('No valid label commands found after parsing.');
return;
}
const issueParams = {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
};
if (addLabels.length) {
core.info(`Adding labels: ${addLabels.join(', ')}`);
await github.rest.issues.addLabels({
...issueParams,
labels: addLabels,
});
}
for (const labelName of removeLabels) {
core.info(`Removing label: ${labelName}`);
try {
await github.rest.issues.removeLabel({
...issueParams,
name: labelName,
});
} catch (error) {
if (error.status === 404) {
core.warning(`Label "${labelName}" was not present on the pull request.`);
} else {
throw error;
}
}
}
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: comment.id,
});
core.info('Processed label commands and deleted the comment.');
+6 -5
View File
@@ -8,7 +8,7 @@ permissions:
contents: read contents: read
env: env:
SERVER_IP: ${{ secrets.NEW_VPS_IP }} # Add this to your GitHub secrets SERVER_IP: ${{ secrets.VPS_IP }} # Add this to your GitHub secrets
CLEANUP_PERFORMED: "false" # Add flag to track if cleanup occurred CLEANUP_PERFORMED: "false" # Add flag to track if cleanup occurred
jobs: jobs:
@@ -21,12 +21,12 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- name: Checkout PR - name: Checkout PR
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup GitHub App Bot - name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]' if: github.actor != 'dependabot[bot]'
@@ -96,18 +96,19 @@ jobs:
const hasDeploymentComment = deploymentComments.length > 0; const hasDeploymentComment = deploymentComments.length > 0;
core.setOutput('present', (hasLabel || hasDeploymentComment) ? 'true' : 'false'); core.setOutput('present', (hasLabel || hasDeploymentComment) ? 'true' : 'false');
- name: Set up SSH - name: Set up SSH
if: steps.remove-label-comment.outputs.present == 'true' if: steps.remove-label-comment.outputs.present == 'true'
run: | run: |
mkdir -p ~/.ssh/ mkdir -p ~/.ssh/
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key sudo chmod 600 ../private.key
- name: Cleanup PR deployment - name: Cleanup PR deployment
if: steps.remove-label-comment.outputs.present == 'true' if: steps.remove-label-comment.outputs.present == 'true'
id: cleanup id: cleanup
run: | run: |
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << 'ENDSSH' ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
if [ -d "/stirling/PR-${{ github.event.pull_request.number }}" ]; then if [ -d "/stirling/PR-${{ github.event.pull_request.number }}" ]; then
echo "Found PR directory, proceeding with cleanup..." echo "Found PR directory, proceeding with cleanup..."
+4 -4
View File
@@ -5,7 +5,7 @@ on:
types: [opened, edited] types: [opened, edited]
branches: [main] branches: [main]
permissions: # required for secure-repo hardening permissions: # required for secure-repo hardening
contents: read contents: read
jobs: jobs:
@@ -19,11 +19,11 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -87,7 +87,7 @@ jobs:
- name: AI PR Title Analysis - name: AI PR Title Analysis
if: steps.actor.outputs.is_repo_dev == 'true' if: steps.actor.outputs.is_repo_dev == 'true'
id: ai-title-analysis id: ai-title-analysis
uses: actions/ai-inference@a6101c89c6feaecc585efdd8d461f18bb7896f20 # v2.0.5 uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1.2.8
with: with:
model: openai/gpt-4o model: openai/gpt-4o
system-prompt-file: ".github/config/system-prompt.txt" system-prompt-file: ".github/config/system-prompt.txt"
+2 -5
View File
@@ -2,9 +2,6 @@ name: "Auto Pull Request Labeler V2"
on: on:
pull_request_target: pull_request_target:
types: [opened, synchronize] types: [opened, synchronize]
branches:
- main
- V3
permissions: permissions:
contents: read contents: read
@@ -16,11 +13,11 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup GitHub App Bot - name: Setup GitHub App Bot
id: setup-bot id: setup-bot
+54 -119
View File
@@ -2,7 +2,7 @@ name: Build and Test Workflow
on: on:
pull_request: pull_request:
branches: ["main"] branches: ["main", "V2", "V2-gha"]
workflow_dispatch: workflow_dispatch:
# cancel in-progress jobs if a new job is triggered # cancel in-progress jobs if a new job is triggered
@@ -27,16 +27,15 @@ jobs:
timeout-minutes: 3 timeout-minutes: 3
outputs: outputs:
build: ${{ steps.changes.outputs.build }} build: ${{ steps.changes.outputs.build }}
app: ${{ steps.changes.outputs.app }}
project: ${{ steps.changes.outputs.project }} project: ${{ steps.changes.outputs.project }}
openapi: ${{ steps.changes.outputs.openapi }} openapi: ${{ steps.changes.outputs.openapi }}
frontend: ${{ steps.changes.outputs.frontend }}
steps: steps:
- name: Harden the runner (Audit all outbound calls) - name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check for file changes - name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
@@ -56,31 +55,25 @@ jobs:
spring-security: [true, false] spring-security: [true, false]
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK ${{ matrix.jdk-version }} - name: Set up JDK ${{ matrix.jdk-version }}
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with: with:
java-version: ${{ matrix.jdk-version }} java-version: ${{ matrix.jdk-version }}
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1 uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with: with:
gradle-version: 8.14 gradle-version: 8.14
- name: Build with Gradle and spring security ${{ matrix.spring-security }} - name: Build with Gradle and spring security ${{ matrix.spring-security }}
run: ./gradlew build -PnoSpotless run: ./gradlew clean build -PnoSpotless
env: env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.spring-security }} DISABLE_ADDITIONAL_FEATURES: ${{ matrix.spring-security }}
- name: Check Test Reports Exist - name: Check Test Reports Exist
if: always() if: always()
run: | run: |
@@ -98,14 +91,12 @@ jobs:
exit 1 exit 1
fi fi
done done
- name: Upload Test Reports - name: Upload Test Reports
if: always() if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }} name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
path: | path: |
app/**/build/reports/jacoco/test
app/**/build/reports/tests/ app/**/build/reports/tests/
app/**/build/test-results/ app/**/build/test-results/
app/**/build/reports/problems/ app/**/build/reports/problems/
@@ -113,71 +104,51 @@ jobs:
retention-days: 3 retention-days: 3
if-no-files-found: warn 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: check-generateOpenApiDocs:
if: needs.files-changed.outputs.openapi == 'true' if: needs.files-changed.outputs.openapi == 'true'
needs: [files-changed] needs: [files-changed]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21 - name: Set up JDK 17
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with: with:
java-version: "21" java-version: "17"
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1 uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
- name: Generate OpenAPI documentation - name: Generate OpenAPI documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs run: ./gradlew :stirling-pdf:generateOpenApiDocs
env: env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
DISABLE_ADDITIONAL_FEATURES: true DISABLE_ADDITIONAL_FEATURES: true
- name: Upload OpenAPI Documentation - name: Upload OpenAPI Documentation
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: openapi-docs name: openapi-docs
path: ./SwaggerDoc.json path: ./SwaggerDoc.json
frontend-validation: frontend-validation:
if: needs.files-changed.outputs.frontend == 'true'
needs: files-changed
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with: with:
node-version: "22" node-version: '22'
cache: "npm" cache: 'npm'
cache-dependency-path: frontend/package-lock.json cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies - name: Install frontend dependencies
run: cd frontend && npm ci run: cd frontend && npm ci
@@ -190,7 +161,7 @@ jobs:
- name: Run frontend tests - name: Run frontend tests
run: cd frontend && npm run test -- --run run: cd frontend && npm run test -- --run
- name: Upload frontend build artifacts - name: Upload frontend build artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: frontend-build name: frontend-build
path: frontend/dist/ path: frontend/dist/
@@ -202,34 +173,23 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 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
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with: with:
java-version: "21" java-version: "17"
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
- name: check the licenses for compatibility - name: check the licenses for compatibility
run: ./gradlew checkLicense run: ./gradlew clean checkLicense
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
- name: FAILED - check the licenses for compatibility - name: FAILED - check the licenses for compatibility
if: failure() if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: dependencies-without-allowed-license.json name: dependencies-without-allowed-license.json
path: build/reports/dependency-license/dependencies-without-allowed-license.json path: build/reports/dependency-license/dependencies-without-allowed-license.json
@@ -256,26 +216,21 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21 - name: Set up Java 17
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with: with:
java-version: "21" java-version: "17"
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Install Docker Compose - name: Install Docker Compose
run: | run: |
@@ -283,10 +238,10 @@ jobs:
sudo chmod +x /usr/local/bin/docker-compose sudo chmod +x /usr/local/bin/docker-compose
- name: Set up Python - name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with: with:
python-version: "3.12" python-version: "3.12"
cache: "pip" # caching pip dependencies cache: 'pip' # caching pip dependencies
cache-dependency-path: ./testing/cucumber/requirements.txt cache-dependency-path: ./testing/cucumber/requirements.txt
- name: Pip requirements - name: Pip requirements
@@ -299,10 +254,6 @@ jobs:
chmod +x ./testing/test.sh chmod +x ./testing/test.sh
chmod +x ./testing/test_disabledEndpoints.sh chmod +x ./testing/test_disabledEndpoints.sh
./testing/test.sh ./testing/test.sh
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
test-build-docker-images: test-build-docker-images:
if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true' if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true'
@@ -311,55 +262,39 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
include: docker-rev: ["docker/embedded/Dockerfile", "docker/embedded/Dockerfile.ultra-lite", "docker/embedded/Dockerfile.fat"]
- docker-rev: docker/embedded/Dockerfile
artifact-suffix: Dockerfile
- docker-rev: docker/embedded/Dockerfile.ultra-lite
artifact-suffix: Dockerfile.ultra-lite
- docker-rev: docker/embedded/Dockerfile.fat
artifact-suffix: Dockerfile.fat
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Free disk space on runner - name: Set up JDK 17
run: | uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
echo "Disk space before cleanup:" && df -h
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/share/boost
docker system prune -af || true
echo "Disk space after cleanup:" && df -h
- name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with: with:
java-version: "21" java-version: "17"
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle - name: Set up Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1 uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with: with:
gradle-version: 8.14 gradle-version: 8.14
- name: Build application - name: Build application
run: ./gradlew build run: ./gradlew clean build
env: env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
DISABLE_ADDITIONAL_FEATURES: true DISABLE_ADDITIONAL_FEATURES: true
STIRLING_PDF_DESKTOP_UI: false STIRLING_PDF_DESKTOP_UI: false
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Build ${{ matrix.docker-rev }} - name: Build ${{ matrix.docker-rev }}
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
@@ -368,17 +303,17 @@ jobs:
context: . context: .
file: ./${{ matrix.docker-rev }} file: ./${{ matrix.docker-rev }}
push: false push: false
cache-from: type=gha,scope=${{ matrix.artifact-suffix }} cache-from: type=gha
cache-to: type=gha,mode=max,scope=${{ matrix.artifact-suffix }} cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64/v8 platforms: linux/amd64,linux/arm64/v8
provenance: true provenance: true
sbom: true sbom: true
- name: Upload Reports - name: Upload Reports
if: always() if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: reports-docker-${{ matrix.artifact-suffix }} name: reports-docker-${{ matrix.docker-rev }}
path: | path: |
build/reports/tests/ build/reports/tests/
build/test-results/ build/test-results/
+5 -6
View File
@@ -7,8 +7,6 @@ on:
types: [opened, synchronize, reopened] types: [opened, synchronize, reopened]
paths: paths:
- "frontend/public/locales/*/translation.toml" - "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 # cancel in-progress jobs if a new job is triggered
concurrency: concurrency:
@@ -27,12 +25,12 @@ jobs:
pull-requests: write # Allow writing to pull requests pull-requests: write # Allow writing to pull requests
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- name: Checkout main branch first - name: Checkout main branch first
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup GitHub App Bot - name: Setup GitHub App Bot
id: setup-bot id: setup-bot
@@ -196,12 +194,13 @@ jobs:
core.exportVariable("REFERENCE_FILE", referenceFilePath); core.exportVariable("REFERENCE_FILE", referenceFilePath);
- name: Set up Python - name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with: with:
python-version: "3.12" python-version: "3.12"
- name: Install Python dependencies - name: Install Python dependencies
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt run: |
pip install tomli-w
- name: Run Python script to check files - name: Run Python script to check files
id: run-check id: run-check
+79
View File
@@ -0,0 +1,79 @@
# 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}}"
+4 -4
View File
@@ -17,13 +17,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- name: "Checkout Repository" - name: "Checkout Repository"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: "Dependency Review" - name: "Dependency Review"
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
with: with:
config-file: "./.github/config/dependency-review-config.yml" config-file: './.github/config/dependency-review-config.yml'
+24 -20
View File
@@ -18,15 +18,15 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with: with:
egress-policy: audit egress-policy: audit
- name: Checkout code - name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 uses: docker/setup-buildx-action@v3
- name: Get commit hashes for frontend and backend - name: Get commit hashes for frontend and backend
id: commit-hashes id: commit-hashes
@@ -36,26 +36,26 @@ jobs:
if [ -z "$FRONTEND_HASH" ]; then if [ -z "$FRONTEND_HASH" ]; then
FRONTEND_HASH="no-frontend-changes" FRONTEND_HASH="no-frontend-changes"
fi fi
# Get last commit that touched backend code, docker/backend, or docker/compose # 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 "") BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
if [ -z "$BACKEND_HASH" ]; then if [ -z "$BACKEND_HASH" ]; then
BACKEND_HASH="no-backend-changes" BACKEND_HASH="no-backend-changes"
fi fi
echo "Frontend hash: $FRONTEND_HASH" echo "Frontend hash: $FRONTEND_HASH"
echo "Backend hash: $BACKEND_HASH" echo "Backend hash: $BACKEND_HASH"
echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
# Short hashes for tags # Short hashes for tags
if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
else else
echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
fi fi
if [ "$BACKEND_HASH" = "no-backend-changes" ]; then if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
echo "backend_short=no-backend" >> $GITHUB_OUTPUT echo "backend_short=no-backend" >> $GITHUB_OUTPUT
else else
@@ -84,15 +84,16 @@ jobs:
echo "Backend image needs to be built" echo "Backend image needs to be built"
fi fi
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }} password: ${{ secrets.DOCKER_HUB_API }}
- name: Build and push frontend image - name: Build and push frontend image
if: steps.check-frontend.outputs.exists == 'false' if: steps.check-frontend.outputs.exists == 'false'
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./docker/frontend/Dockerfile file: ./docker/frontend/Dockerfile
@@ -102,10 +103,10 @@ jobs:
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest
build-args: VERSION_TAG=v2-alpha build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64 platforms: linux/amd64
- name: Build and push backend image - name: Build and push backend image
if: steps.check-backend.outputs.exists == 'false' if: steps.check-backend.outputs.exists == 'false'
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./docker/backend/Dockerfile file: ./docker/backend/Dockerfile
@@ -116,16 +117,18 @@ jobs:
build-args: VERSION_TAG=v2-alpha build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64 platforms: linux/amd64
- name: Set up SSH - name: Set up SSH
run: | run: |
mkdir -p ~/.ssh/ mkdir -p ~/.ssh/
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
chmod 600 ../private.key chmod 600 ../private.key
- name: Deploy to VPS on port 3000 - name: Deploy to VPS on port 3000
run: | run: |
export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml
cat > $UNIQUE_NAME << EOF cat > $UNIQUE_NAME << EOF
version: '3.3' version: '3.3'
services: services:
@@ -151,24 +154,24 @@ jobs:
SWAGGER_SERVER_URL: "https://demo.stirlingpdf.cloud" SWAGGER_SERVER_URL: "https://demo.stirlingpdf.cloud"
baseUrl: "https://demo.stirlingpdf.cloud" baseUrl: "https://demo.stirlingpdf.cloud"
restart: on-failure:5 restart: on-failure:5
frontend: frontend:
container_name: stirling-v2-frontend container_name: stirling-v2-frontend
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
ports: ports:
- "3000:80" - "3000:80"
environment: environment:
VITE_API_BASE_URL: "http://${{ secrets.NEW_VPS_HOST }}:13000" VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:13000"
depends_on: depends_on:
- backend - backend
restart: on-failure:5 restart: on-failure:5
EOF EOF
# Copy to remote with unique name # Copy to remote with unique name
scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/$UNIQUE_NAME scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/$UNIQUE_NAME
# SSH and rename/move atomically to avoid interference # SSH and rename/move atomically to avoid interference
ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
mkdir -p /stirling/V2/{data,config,logs} mkdir -p /stirling/V2/{data,config,logs}
mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml
cd /stirling/V2 cd /stirling/V2
@@ -183,3 +186,4 @@ jobs:
if: always() if: always()
run: | run: |
rm -f ../private.key rm -f ../private.key
@@ -1,518 +0,0 @@
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.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@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "21"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
- name: Check licenses and generate report
id: license-check
run: |
./gradlew checkLicense generateLicenseReport || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.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@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.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 }}
@@ -0,0 +1,282 @@
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 }}
+105
View File
@@ -0,0 +1,105 @@
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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Check out code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
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@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
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 }}
+2 -2
View File
@@ -15,12 +15,12 @@ jobs:
issues: write issues: write
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- name: Check out the repository - name: Check out the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Run Labeler - name: Run Labeler
uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 # v5.3.0 uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 # v5.3.0
+32 -50
View File
@@ -22,7 +22,7 @@ on:
- macos - macos
- linux - linux
push: push:
branches: [main, V2-master] branches: [main, V2, V2-demo, V2-master]
release: release:
types: [created] types: [created]
@@ -31,27 +31,25 @@ permissions:
jobs: jobs:
determine-matrix: determine-matrix:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }} matrix: ${{ steps.set-matrix.outputs.matrix }}
version: ${{ steps.versionNumber.outputs.versionNumber }} version: ${{ steps.versionNumber.outputs.versionNumber }}
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with: with:
java-version: "21" java-version: "21"
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with: with:
gradle-version: 8.14 gradle-version: 8.14
@@ -63,10 +61,6 @@ jobs:
VERSION=$(./gradlew printVersion --quiet | tail -1) VERSION=$(./gradlew printVersion --quiet | tail -1)
echo "Extracted version: $VERSION" echo "Extracted version: $VERSION"
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
- name: Determine build matrix - name: Determine build matrix
id: set-matrix id: set-matrix
@@ -111,37 +105,33 @@ jobs:
file_suffix: "-server" file_suffix: "-server"
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with: with:
java-version: "21" java-version: "21"
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with: with:
gradle-version: 8.14 gradle-version: 8.14
- name: Setup Node.js - name: Setup Node.js
if: matrix.variant.build_frontend == true if: matrix.variant.build_frontend == true
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
cache: "npm" cache: 'npm'
cache-dependency-path: frontend/package-lock.json cache-dependency-path: frontend/package-lock.json
- name: Build JAR - name: Build JAR
run: ./gradlew build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube run: ./gradlew clean build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
env: env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.variant.disable_security }} DISABLE_ADDITIONAL_FEATURES: ${{ matrix.variant.disable_security }}
STIRLING_PDF_DESKTOP_UI: false STIRLING_PDF_DESKTOP_UI: false
@@ -154,7 +144,7 @@ jobs:
cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar
- name: Upload JAR artifacts - name: Upload JAR artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: jar${{ matrix.variant.file_suffix }} name: jar${{ matrix.variant.file_suffix }}
path: ./jar-dist/*.jar path: ./jar-dist/*.jar
@@ -171,12 +161,12 @@ jobs:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with: with:
egress-policy: audit egress-policy: audit
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install dependencies (ubuntu only) - name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04' if: matrix.platform == 'ubuntu-22.04'
@@ -185,36 +175,31 @@ jobs:
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 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 - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
cache: "npm" cache: 'npm'
cache-dependency-path: frontend/package-lock.json cache-dependency-path: frontend/package-lock.json
- name: Setup Rust - name: Setup Rust
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable uses: dtolnay/rust-toolchain@stable
with: with:
toolchain: stable toolchain: stable
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with: with:
java-version: "21" java-version: "21"
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
- name: Build Java backend with JLink - name: Build Java backend with JLink
working-directory: ./ working-directory: ./
shell: bash shell: bash
run: | run: |
chmod +x ./gradlew chmod +x ./gradlew
echo "🔧 Building Stirling-PDF JAR..." echo "🔧 Building Stirling-PDF JAR..."
./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube ./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Find the built JAR # Find the built JAR
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1) STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
@@ -278,20 +263,17 @@ jobs:
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1) RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
echo "📊 Custom JRE size: $RUNTIME_SIZE" echo "📊 Custom JRE size: $RUNTIME_SIZE"
env: env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
DISABLE_ADDITIONAL_FEATURES: true DISABLE_ADDITIONAL_FEATURES: true
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./frontend working-directory: ./frontend
run: npm ci run: npm install
# DigiCert KeyLocker Setup (Cloud HSM) # DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker - name: Setup DigiCert KeyLocker
id: digicert-setup id: digicert-setup
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }} if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
uses: digicert/ssm-code-signing@af7f4b07694120e514fe5b19f0598af3c20256e3 # v1.2.0 uses: digicert/ssm-code-signing@v1.1.0
env: env:
SM_API_KEY: ${{ secrets.SM_API_KEY }} SM_API_KEY: ${{ secrets.SM_API_KEY }}
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }} SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
@@ -399,7 +381,7 @@ jobs:
echo "Certificate imported successfully." echo "Certificate imported successfully."
- name: Build Tauri app - name: Build Tauri app
uses: tauri-apps/tauri-action@51a9f1156b33df106d827c3a78f8f894946c5faa # v0.5.25 uses: tauri-apps/tauri-action@v0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
@@ -533,7 +515,7 @@ jobs:
fi fi
- name: Upload build artifacts - name: Upload build artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: Stirling-PDF-${{ matrix.name }} name: Stirling-PDF-${{ matrix.name }}
path: ./dist/* path: ./dist/*
@@ -547,30 +529,30 @@ jobs:
contents: write contents: write
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- name: Download all Tauri artifacts - name: Download all Tauri artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with: with:
pattern: Stirling-PDF-* pattern: Stirling-PDF-*
path: ./artifacts/tauri path: ./artifacts/tauri
- name: Download JAR artifact (default) - name: Download JAR artifact (default)
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with: with:
name: jar name: jar
path: ./artifacts/jars path: ./artifacts/jars
- name: Download JAR artifact (with login) - name: Download JAR artifact (with login)
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with: with:
name: jar-with-login name: jar-with-login
path: ./artifacts/jars path: ./artifacts/jars
- name: Download JAR artifact (server only) - name: Download JAR artifact (server only)
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with: with:
name: jar-server name: jar-server
path: ./artifacts/jars path: ./artifacts/jars
@@ -579,7 +561,7 @@ jobs:
run: ls -R ./artifacts run: ls -R ./artifacts
- name: Upload binaries to Release - name: Upload binaries to Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
with: with:
tag_name: v${{ needs.determine-matrix.outputs.version }} tag_name: v${{ needs.determine-matrix.outputs.version }}
generate_release_notes: true generate_release_notes: true
+10 -27
View File
@@ -21,15 +21,14 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false
- name: Setup GitHub App Bot - name: Setup GitHub App Bot
id: setup-bot id: setup-bot
@@ -39,43 +38,27 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python - name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with: with:
python-version: 3.12 python-version: 3.12
cache: "pip" # caching pip dependencies cache: 'pip' # caching pip dependencies
cache-dependency-path: ./.github/scripts/requirements_pre_commit.txt cache-dependency-path: ./.github/scripts/requirements_pre_commit.txt
- name: Run Pre-Commit Hooks - name: Run Pre-Commit Hooks
run: | run: |
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_pre_commit.txt pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_pre_commit.txt
- name: Run Pre-Commit - run: pre-commit run --all-files -c .pre-commit-config.yaml
run: |
pre-commit run ruff --all-files -c .pre-commit-config.yaml
pre-commit run ruff-format --all-files -c .pre-commit-config.yaml
pre-commit run codespell --all-files -c .pre-commit-config.yaml
pre-commit run gitleaks --all-files -c .pre-commit-config.yaml
pre-commit run end-of-file-fixer --all-files -c .pre-commit-config.yaml
pre-commit run trailing-whitespace --all-files -c .pre-commit-config.yaml
continue-on-error: true continue-on-error: true
- name: Set up JDK 21 - name: Set up JDK
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with: with:
java-version: 21 java-version: 17
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
- name: Build with Gradle - name: Build with Gradle
run: ./gradlew build run: ./gradlew clean build
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
- name: git add - name: git add
run: | run: |
@@ -84,7 +67,7 @@ jobs:
- name: Create Pull Request - name: Create Pull Request
if: env.CHANGES_DETECTED == 'true' if: env.CHANGES_DETECTED == 'true'
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with: with:
token: ${{ steps.setup-bot.outputs.token }} token: ${{ steps.setup-bot.outputs.token }}
commit-message: ":file_folder: pre-commit" commit-message: ":file_folder: pre-commit"
+245
View File
@@ -0,0 +1,245 @@
name: Push Docker Image - V2 Branch
on:
workflow_dispatch:
push:
branches:
- V2-master
- alljavadocker
# 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:
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 (alljavadocker branch - test)
id: meta-test
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/alljavadocker'
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 (alljavadocker branch - test)
id: meta-fat-test
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/alljavadocker'
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 (alljavadocker branch - test)
id: meta-lite-test
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/alljavadocker'
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
+75 -96
View File
@@ -6,8 +6,6 @@ on:
branches: branches:
- master - master
- main - main
- V2-master
- testMain
# cancel in-progress jobs if a new job is triggered # 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 # This is useful to avoid running multiple builds for the same branch if a new commit is pushed
@@ -26,77 +24,72 @@ permissions:
jobs: jobs:
push: push:
if: ${{ vars.CI_PROFILE != 'lite' }} runs-on: ubuntu-latest
runs-on: ubuntu-24.04-8core
permissions: permissions:
packages: write packages: write
id-token: write id-token: write
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21 - name: Set up JDK 17
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with: with:
java-version: "21" java-version: "17"
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with: with:
gradle-version: 8.14 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@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
with:
cosign-release: "v2.4.1"
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Get version number - name: Get version number
id: versionNumber id: versionNumber
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
- 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: 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 - name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: with:
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }} password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ github.token }} password: ${{ github.token }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Convert repository owner to lowercase - name: Convert repository owner to lowercase
id: repoowner id: repoowner
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
- name: Generate tags for latest - name: Generate tags
id: meta id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref != 'refs/heads/main'
with: with:
images: | images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf ${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
@@ -104,13 +97,13 @@ jobs:
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf ${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: | tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }} type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }} type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/testMain' }}
- name: Build and push Unified Dockerfile (latest variant) - name: Build and push main Dockerfile
id: build-push-latest id: build-push-regular
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
if: github.ref != 'refs/heads/main'
with: with:
builder: ${{ steps.buildx.outputs.name }} builder: ${{ steps.buildx.outputs.name }}
context: . context: .
@@ -126,9 +119,9 @@ jobs:
sbom: true sbom: true
- name: Sign regular images - name: Sign regular images
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' if: github.ref == 'refs/heads/master'
env: env:
DIGEST: ${{ steps.build-push-latest.outputs.digest }} DIGEST: ${{ steps.build-push-regular.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }} TAGS: ${{ steps.meta.outputs.tags }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
@@ -139,10 +132,10 @@ jobs:
"${tag}@${DIGEST}" "${tag}@${DIGEST}"
done done
- name: Generate tags for latest-fat - name: Generate tags ultra-lite
id: meta-fat id: meta2
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' if: github.ref != 'refs/heads/main'
with: with:
images: | images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf ${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
@@ -150,13 +143,43 @@ jobs:
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf ${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: | tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }} type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }} type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' }}
- name: Build and push Unified Dockerfile (fat variant) - name: Build and push Dockerfile-ultra-lite
id: build-push-lite
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
if: github.ref != 'refs/heads/main'
with:
context: .
file: ./docker/embedded/Dockerfile.ultra-lite
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta2.outputs.tags }}
labels: ${{ steps.meta2.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Generate tags fat
id: meta3
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push main Dockerfile fat
id: build-push-fat id: build-push-fat
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
with: with:
builder: ${{ steps.buildx.outputs.name }} builder: ${{ steps.buildx.outputs.name }}
context: . context: .
@@ -164,62 +187,18 @@ jobs:
push: true push: true
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
tags: ${{ steps.meta-fat.outputs.tags }} tags: ${{ steps.meta3.outputs.tags }}
labels: ${{ steps.meta-fat.outputs.labels }} labels: ${{ steps.meta3.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }} build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64,linux/arm64/v8 platforms: linux/amd64,linux/arm64/v8
provenance: true provenance: true
sbom: true sbom: true
- name: Sign fat images - name: Sign fat images
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' if: github.ref == 'refs/heads/master'
env: env:
DIGEST: ${{ steps.build-push-fat.outputs.digest }} DIGEST: ${{ steps.build-push-fat.outputs.digest }}
TAGS: ${{ steps.meta-fat.outputs.tags }} TAGS: ${{ steps.meta3.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_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: | run: |
+535
View File
@@ -0,0 +1,535 @@
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.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@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- 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@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
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@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.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@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.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@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
with:
tag_name: v${{ needs.determine-matrix.outputs.version }}
generate_release_notes: true
files: ./artifacts/**/*
draft: false
prerelease: false
+4 -5
View File
@@ -17,7 +17,6 @@ permissions: read-all
jobs: jobs:
analysis: analysis:
if: ${{ vars.CI_PROFILE != 'lite' }}
name: Scorecard analysis name: Scorecard analysis
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
@@ -35,12 +34,12 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with: with:
persist-credentials: false persist-credentials: false
@@ -67,7 +66,7 @@ jobs:
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab. # format to the repository Actions tab.
- name: "Upload artifact" - name: "Upload artifact"
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: SARIF file name: SARIF file
path: results.sarif path: results.sarif
@@ -75,6 +74,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard. # Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning" - name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v3.29.5 uses: github/codeql-action/upload-sarif@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.29.5
with: with:
sarif_file: results.sarif sarif_file: results.sarif
+75
View File
@@ -0,0 +1,75 @@
name: Run Sonarqube
on:
push:
branches:
- master
pull_request_target:
branches:
- main
workflow_dispatch:
# 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.event.pull_request.number || github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
pull-requests: read
actions: read
jobs:
sonarqube:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
- name: Setup Gradle
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Build and analyze with Gradle
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
DISABLE_ADDITIONAL_FEATURES: false
STIRLING_PDF_DESKTOP_UI: true
run: |
./gradlew clean build sonar \
-Dsonar.projectKey=Stirling-Tools_Stirling-PDF \
-Dsonar.organization=stirling-tools \
-Dsonar.host.url=https://sonarcloud.io \
-Dsonar.login=${SONAR_TOKEN} \
-Dsonar.log.level=DEBUG \
--info
- name: Upload Problems Report on Failure
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: gradle-problems-report
path: build/reports/problems/problems-report.html
retention-days: 7
- name: Upload Sonar Logs on Failure
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: sonar-logs
path: |
.scannerwork/report-task.txt
build/sonar/
retention-days: 7
+2 -3
View File
@@ -10,19 +10,18 @@ permissions:
jobs: jobs:
stale: stale:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
issues: write issues: write
pull-requests: write pull-requests: write
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- name: 30 days stale issues - name: 30 days stale issues
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 30 days-before-stale: 30
+6 -17
View File
@@ -23,26 +23,22 @@ permissions:
jobs: jobs:
push: push:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21 - name: Set up JDK 17
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with: with:
java-version: "21" java-version: "17"
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
- name: Generate Swagger documentation - name: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs run: ./gradlew :stirling-pdf:generateOpenApiDocs
@@ -50,19 +46,12 @@ jobs:
- name: Upload Swagger Documentation to SwaggerHub - name: Upload Swagger Documentation to SwaggerHub
run: ./gradlew swaggerhubUpload run: ./gradlew swaggerhubUpload
env: env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
SWAGGERHUB_API_KEY: ${{ secrets.SWAGGERHUB_API_KEY }} SWAGGERHUB_API_KEY: ${{ secrets.SWAGGERHUB_API_KEY }}
SWAGGERHUB_USER: "Frooodle" SWAGGERHUB_USER: "Frooodle"
- name: Get version number - name: Get version number
id: versionNumber id: versionNumber
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
- name: Set API version as published and default on SwaggerHub - name: Set API version as published and default on SwaggerHub
run: | run: |
+11 -15
View File
@@ -5,11 +5,9 @@ on:
push: push:
branches: branches:
- main - main
- syncLangTest
paths: paths:
- "build.gradle" - "build.gradle"
- "app/common/build.gradle"
- "app/core/build.gradle"
- "app/proprietary/build.gradle"
- "README.md" - "README.md"
- "frontend/public/locales/*/translation.toml" - "frontend/public/locales/*/translation.toml"
- "app/core/src/main/resources/static/3rdPartyLicenses.json" - "app/core/src/main/resources/static/3rdPartyLicenses.json"
@@ -35,13 +33,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with: with:
egress-policy: audit egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
persist-credentials: false
- name: Setup GitHub App Bot - name: Setup GitHub App Bot
id: setup-bot id: setup-bot
@@ -51,27 +47,27 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python - name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with: with:
python-version: "3.12" python-version: "3.12"
cache: "pip" # caching pip dependencies cache: "pip" # caching pip dependencies
- name: Install Python dependencies - name: Install Python dependencies
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt -r ./.github/scripts/requirements_pre_commit.txt run: |
pip install tomli-w
- name: Sync translation TOML files - name: Sync translation TOML files
run: | run: |
python .github/scripts/check_language_toml.py --reference-file "frontend/public/locales/en-GB/translation.toml" --branch main python .github/scripts/check_language_toml.py --reference-file "frontend/public/locales/en-GB/translation.toml" --branch main
- name: pre-commit run
run: |
pre-commit run toml-sort-fix --all-files
- name: Commit translation files - name: Commit translation files
run: | run: |
git add frontend/public/locales/*/translation.toml git add frontend/public/locales/*/translation.toml
git diff --staged --quiet || git commit -m ":memo: Sync translation files (TOML)" || echo "No changes detected" 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 - name: Sync README.md
run: | run: |
python scripts/counter_translation_v3.py python scripts/counter_translation_v3.py
@@ -83,7 +79,7 @@ jobs:
- name: Create Pull Request - name: Create Pull Request
if: always() if: always()
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with: with:
token: ${{ steps.setup-bot.outputs.token }} token: ${{ steps.setup-bot.outputs.token }}
commit-message: Update files commit-message: Update files
@@ -125,4 +121,4 @@ jobs:
add-paths: | add-paths: |
README.md README.md
frontend/public/locales/*/translation.toml frontend/public/locales/*/translation.toml
scripts/ignore_translation.toml scripts/ignore_translation.toml
+32 -147
View File
@@ -14,31 +14,24 @@ on:
- macos - macos
- linux - linux
pull_request: pull_request:
branches: [main, V2-tauri-windows] branches: [main, V2, V2-tauri-windows]
paths: paths:
- "frontend/src-tauri/**" - 'frontend/src-tauri/**'
- "frontend/src/desktop/**" - 'frontend/src/desktop/**'
- "frontend/tsconfig.desktop.json" - 'frontend/tsconfig.desktop.json'
- ".github/workflows/tauri-build.yml" - '.github/workflows/tauri-build.yml'
push: push:
branches: [main] branches: [main, V2]
permissions: permissions:
contents: read contents: read
pull-requests: write
jobs: jobs:
determine-matrix: determine-matrix:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }} matrix: ${{ steps.set-matrix.outputs.matrix }}
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
- name: Determine build matrix - name: Determine build matrix
id: set-matrix id: set-matrix
run: | run: |
@@ -73,12 +66,12 @@ jobs:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with: with:
egress-policy: audit egress-policy: audit
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install dependencies (ubuntu only) - name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04' if: matrix.platform == 'ubuntu-22.04'
@@ -87,50 +80,45 @@ jobs:
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 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 - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 20
cache: "npm" cache: 'npm'
cache-dependency-path: frontend/package-lock.json cache-dependency-path: frontend/package-lock.json
- name: Setup Rust - name: Setup Rust
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable uses: dtolnay/rust-toolchain@stable
with: with:
toolchain: stable toolchain: stable
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with: with:
java-version: "21" java-version: "21"
distribution: "temurin" distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
- name: Build Java backend with JLink - name: Build Java backend with JLink
working-directory: ./ working-directory: ./
shell: bash shell: bash
run: | run: |
chmod +x ./gradlew chmod +x ./gradlew
echo "🔧 Building Stirling-PDF JAR..." echo "🔧 Building Stirling-PDF JAR..."
# STIRLING_PDF_DESKTOP_UI=false ./gradlew bootJar --no-daemon # STIRLING_PDF_DESKTOP_UI=false ./gradlew clean bootJar --no-daemon
./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube ./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Find the built JAR # Find the built JAR
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1) STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
echo "✅ Built JAR: $STIRLING_JAR" echo "✅ Built JAR: $STIRLING_JAR"
# Create Tauri directories # Create Tauri directories
mkdir -p ./frontend/src-tauri/libs mkdir -p ./frontend/src-tauri/libs
mkdir -p ./frontend/src-tauri/runtime mkdir -p ./frontend/src-tauri/runtime
# Copy JAR to Tauri libs # Copy JAR to Tauri libs
cp "$STIRLING_JAR" ./frontend/src-tauri/libs/ cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
echo "✅ JAR copied to Tauri libs" echo "✅ JAR copied to Tauri libs"
# Analyze JAR dependencies for jlink modules # Analyze JAR dependencies for jlink modules
echo "🔍 Analyzing JAR dependencies..." echo "🔍 Analyzing JAR dependencies..."
if command -v jdeps &> /dev/null; then if command -v jdeps &> /dev/null; then
@@ -146,14 +134,14 @@ jobs:
echo "⚠️ jdeps not available, using predefined modules" 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" 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 fi
# Create custom JRE with jlink (always rebuild) # Create custom JRE with jlink (always rebuild)
echo "🔧 Creating custom JRE with jlink..." echo "🔧 Creating custom JRE with jlink..."
echo "📋 Using modules: $MODULES" echo "📋 Using modules: $MODULES"
# Remove any existing JRE # Remove any existing JRE
rm -rf ./frontend/src-tauri/runtime/jre rm -rf ./frontend/src-tauri/runtime/jre
# Create the custom JRE # Create the custom JRE
jlink \ jlink \
--add-modules "$MODULES" \ --add-modules "$MODULES" \
@@ -162,12 +150,12 @@ jobs:
--no-header-files \ --no-header-files \
--no-man-pages \ --no-man-pages \
--output ./frontend/src-tauri/runtime/jre --output ./frontend/src-tauri/runtime/jre
if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
echo "❌ Failed to create JLink runtime" echo "❌ Failed to create JLink runtime"
exit 1 exit 1
fi fi
# Test the bundled runtime # Test the bundled runtime
if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then 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) RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
@@ -176,25 +164,22 @@ jobs:
echo "❌ Custom JRE executable not found" echo "❌ Custom JRE executable not found"
exit 1 exit 1
fi fi
# Calculate runtime size # Calculate runtime size
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1) RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
echo "📊 Custom JRE size: $RUNTIME_SIZE" echo "📊 Custom JRE size: $RUNTIME_SIZE"
env: env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
DISABLE_ADDITIONAL_FEATURES: true DISABLE_ADDITIONAL_FEATURES: true
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./frontend working-directory: ./frontend
run: npm ci run: npm install
# DigiCert KeyLocker Setup (Cloud HSM) # DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker - name: Setup DigiCert KeyLocker
id: digicert-setup id: digicert-setup
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }} if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
uses: digicert/ssm-code-signing@af7f4b07694120e514fe5b19f0598af3c20256e3 # v1.2.0 uses: digicert/ssm-code-signing@v1.1.0
env: env:
SM_API_KEY: ${{ secrets.SM_API_KEY }} SM_API_KEY: ${{ secrets.SM_API_KEY }}
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }} SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
@@ -313,7 +298,7 @@ jobs:
ls -la /usr/bin/hd* || echo "No hd* tools found" ls -la /usr/bin/hd* || echo "No hd* tools found"
- name: Build Tauri app - name: Build Tauri app
uses: tauri-apps/tauri-action@51a9f1156b33df106d827c3a78f8f894946c5faa # v0.5.25 uses: tauri-apps/tauri-action@v0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
@@ -521,6 +506,7 @@ jobs:
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \; find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \; find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
else else
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \; find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \; find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
@@ -587,12 +573,12 @@ jobs:
} }
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: Stirling-PDF-${{ matrix.name }} name: Stirling-PDF-${{ matrix.name }}
path: ./dist/* path: ./dist/*
retention-days: 7 retention-days: 7
- name: Verify build artifacts - name: Verify build artifacts
shell: bash shell: bash
run: | run: |
@@ -608,8 +594,8 @@ jobs:
fi fi
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
echo "Checking for macOS artifacts..." echo "Checking for macOS artifacts..."
find . -name "*.dmg" | head -5 find . -name "*.dmg" -o -name "*.app" | head -5
if [ $(find . -name "*.dmg" | wc -l) -eq 0 ]; then if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then
echo "❌ No macOS artifacts found" echo "❌ No macOS artifacts found"
exit 1 exit 1
fi fi
@@ -640,117 +626,16 @@ jobs:
fi fi
done done
pr-comment:
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && needs.build.result == 'success'
permissions:
pull-requests: write
steps:
- name: Harden the runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
- name: Post/Update PR Comment with Download Links
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = context.issue.number;
const runId = context.runId;
// Fetch artifacts for this workflow run
const { data: artifactsList } = await github.rest.actions.listWorkflowRunArtifacts({
owner,
repo,
run_id: runId
});
// Map of expected artifact names to display info
const artifactMap = {
'Stirling-PDF-windows-x86_64': { icon: '🪟', platform: 'Windows x64', files: '.exe, .msi' },
'Stirling-PDF-macos-aarch64': { icon: '🍎', platform: 'macOS ARM64', files: '.dmg' },
'Stirling-PDF-macos-x86_64': { icon: '🍎', platform: 'macOS Intel', files: '.dmg' },
'Stirling-PDF-linux-x86_64': { icon: '🐧', platform: 'Linux x64', files: '.deb, .AppImage' }
};
let commentBody = `## 📦 Tauri Desktop Builds Ready!\n\n`;
commentBody += `The desktop applications have been built and are ready for testing.\n\n`;
commentBody += `### Download Artifacts:\n\n`;
// Add links for each found artifact
let foundArtifacts = 0;
for (const artifact of artifactsList.artifacts) {
const info = artifactMap[artifact.name];
if (info) {
foundArtifacts++;
// GitHub doesn't provide direct download URLs via API, but we can link to the artifact on the Actions page
const artifactUrl = `https://github.com/${owner}/${repo}/actions/runs/${runId}/artifacts/${artifact.id}`;
commentBody += `${info.icon} **${info.platform}**: [Download ${artifact.name}](${artifactUrl}) `;
commentBody += `(${info.files}) - ${(artifact.size_in_bytes / 1024 / 1024).toFixed(1)} MB\n`;
}
}
if (foundArtifacts === 0) {
commentBody += `⚠️ **Warning**: No artifacts found in workflow run.\n`;
commentBody += `[View workflow run](https://github.com/${owner}/${repo}/actions/runs/${runId})\n`;
}
commentBody += `\n---\n`;
commentBody += `_Built from commit ${context.sha.substring(0, 7)}_\n`;
commentBody += `_Artifacts expire in 7 days_`;
// Find existing comment
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber
});
const botComment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('📦 Tauri Desktop Builds Ready!')
);
if (botComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner,
repo,
comment_id: botComment.id,
body: commentBody
});
console.log('Updated existing comment');
} else {
// Create new comment
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: commentBody
});
console.log('Created new comment');
}
report: report:
needs: build needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: always() if: always()
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
- name: Report build results - name: Report build results
run: | run: |
if [ "${{ needs.build.result }}" = "success" ]; then if [ "${{ needs.build.result }}" = "success" ]; then
echo "✅ All Tauri builds completed successfully!" echo "✅ All Tauri builds completed successfully!"
echo "Artifacts are ready for distribution." echo "Artifacts are ready for distribution."
elif [ "${{ needs.build.result }}" = "skipped" ]; then
echo "⏭️ Tauri builds skipped (CI lite mode enabled)"
else else
echo "❌ Some Tauri builds failed." echo "❌ Some Tauri builds failed."
echo "Please check the logs and fix any issues." echo "Please check the logs and fix any issues."
+22 -31
View File
@@ -21,38 +21,34 @@ permissions:
jobs: jobs:
deploy: deploy:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21 - name: Set up JDK
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with: with:
java-version: "21" java-version: '17'
distribution: "temurin" distribution: 'temurin'
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1 uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with: with:
gradle-version: 8.14 gradle-version: 8.14
- name: Build with Gradle - name: Build with Gradle
run: ./gradlew build run: ./gradlew clean build
env: env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
DISABLE_ADDITIONAL_FEATURES: true DISABLE_ADDITIONAL_FEATURES: true
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Get version number - name: Get version number
id: versionNumber id: versionNumber
@@ -61,7 +57,7 @@ jobs:
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: with:
username: ${{ secrets.DOCKER_HUB_USERNAME }} username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }} password: ${{ secrets.DOCKER_HUB_API }}
@@ -79,7 +75,7 @@ jobs:
- name: Set up SSH - name: Set up SSH
run: | run: |
mkdir -p ~/.ssh/ mkdir -p ~/.ssh/
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key sudo chmod 600 ../private.key
- name: Deploy to VPS - name: Deploy to VPS
@@ -110,9 +106,9 @@ jobs:
restart: on-failure:5 restart: on-failure:5
EOF EOF
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose.yml scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose.yml
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << EOF ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << EOF
mkdir -p /stirling/test-${{ github.sha }}/{data,config,logs} mkdir -p /stirling/test-${{ github.sha }}/{data,config,logs}
mv /tmp/docker-compose.yml /stirling/test-${{ github.sha }}/docker-compose.yml mv /tmp/docker-compose.yml /stirling/test-${{ github.sha }}/docker-compose.yml
cd /stirling/test-${{ github.sha }} cd /stirling/test-${{ github.sha }}
@@ -128,12 +124,7 @@ jobs:
outputs: outputs:
frontend: ${{ steps.changes.outputs.frontend }} frontend: ${{ steps.changes.outputs.frontend }}
steps: steps:
- name: Harden the runner (Audit all outbound calls) - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check for file changes - name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
@@ -148,16 +139,16 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up Node - name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with: with:
cache: "npm" cache: 'npm'
cache-dependency-path: frontend/package-lock.json cache-dependency-path: frontend/package-lock.json
- name: Run TestDriver.ai - name: Run TestDriver.ai
@@ -169,7 +160,7 @@ jobs:
npm install npm install
npm run build npm run build
npm install dashcam-chrome --save npm install dashcam-chrome --save
Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.NEW_VPS_HOST }}:1337" Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.VPS_HOST }}:1337"
Start-Sleep -Seconds 20 Start-Sleep -Seconds 20
prompt: | prompt: |
1. /run testing/testdriver/test.yml 1. /run testing/testdriver/test.yml
@@ -184,20 +175,20 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with: with:
egress-policy: audit egress-policy: audit
- name: Set up SSH - name: Set up SSH
run: | run: |
mkdir -p ~/.ssh/ mkdir -p ~/.ssh/
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key sudo chmod 600 ../private.key
- name: Cleanup deployment - name: Cleanup deployment
if: always() if: always()
run: | run: |
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << EOF ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << EOF
cd /stirling/test-${{ github.sha }} cd /stirling/test-${{ github.sha }}
docker-compose down docker-compose down
cd /stirling cd /stirling
+2 -4
View File
@@ -43,8 +43,6 @@ app/core/src/main/resources/static/og_images/
app/core/src/main/resources/static/samples/ app/core/src/main/resources/static/samples/
app/core/src/main/resources/static/manifest-classic.json app/core/src/main/resources/static/manifest-classic.json
app/core/src/main/resources/static/robots.txt 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. # Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc.
# Gradle # Gradle
@@ -149,7 +147,6 @@ app/proprietary/build
common/build common/build
proprietary/build proprietary/build
stirling-pdf/build stirling-pdf/build
frontend/src-tauri/provisioner/target
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
@@ -214,7 +211,7 @@ id_ed25519.pub
.mypy_cache .mypy_cache
.pytest_cache .pytest_cache
.ipynb_checkpoints .ipynb_checkpoints
.build-cache
**/jcef-bundle/ **/jcef-bundle/
@@ -244,3 +241,4 @@ docs/type3/signatures/
# Type3 sample PDFs (development only) # Type3 sample PDFs (development only)
**/type3/samples/ **/type3/samples/
+5 -11
View File
@@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.14 rev: v0.12.7
hooks: hooks:
- id: ruff - id: ruff
args: args:
@@ -16,17 +16,17 @@ repos:
hooks: hooks:
- id: codespell - id: codespell
args: args:
- --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist - --ignore-words-list=thirdParty,tabEl,tabEls
- --skip="./.*,*.csv,*.json,*.ambr" - --skip="./.*,*.csv,*.json,*.ambr"
- --quiet-level=2 - --quiet-level=2
files: \.(html|css|js|py|md)$ files: \.(html|css|js|py|md)$
exclude: (.vscode|.devcontainer|app/core/src/main/resources|app/proprietary/src/main/resources|frontend/public/vendor|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js) 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 - repo: https://github.com/gitleaks/gitleaks
rev: v8.30.0 rev: v8.28.0
hooks: hooks:
- id: gitleaks - id: gitleaks
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0 rev: v5.0.0
hooks: hooks:
- id: end-of-file-fixer - id: end-of-file-fixer
files: ^.*(\.js|\.java|\.py|\.yml)$ files: ^.*(\.js|\.java|\.py|\.yml)$
@@ -34,12 +34,6 @@ repos:
- id: trailing-whitespace - id: trailing-whitespace
files: ^.*(\.js|\.java|\.py|\.yml)$ files: ^.*(\.js|\.java|\.py|\.yml)$
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$) exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$)
- repo: https://github.com/pappasam/toml-sort
rev: v0.24.3
hooks:
- id: toml-sort-fix
files: frontend/public/locales/.*\.toml$
args: ['--in-place', '--all', '--ignore-case']
# - repo: https://github.com/thibaudcolas/pre-commit-stylelint # - repo: https://github.com/thibaudcolas/pre-commit-stylelint
# rev: v16.21.1 # rev: v16.21.1
# hooks: # hooks:
+1 -1
View File
@@ -13,7 +13,7 @@
"vscjava.vscode-spring-boot-dashboard", // Spring Boot dashboard for managing and visualizing Spring Boot applications "vscjava.vscode-spring-boot-dashboard", // Spring Boot dashboard for managing and visualizing Spring Boot applications
"EditorConfig.EditorConfig", // EditorConfig support for maintaining consistent coding styles "EditorConfig.EditorConfig", // EditorConfig support for maintaining consistent coding styles
"ms-azuretools.vscode-docker", // Docker extension for Visual Studio Code "ms-azuretools.vscode-docker", // Docker extension for Visual Studio Code
"GitHub.copilot-chat", // GitHub Copilot AI pair programmer for Visual Studio Code "GitHub.copilot", // GitHub Copilot AI pair programmer for Visual Studio Code
"GitHub.vscode-pull-request-github", // GitHub Pull Requests extension for Visual Studio Code "GitHub.vscode-pull-request-github", // GitHub Pull Requests extension for Visual Studio Code
"charliermarsh.ruff", // Ruff code formatter for Python to follow the Ruff Style Guide "charliermarsh.ruff", // Ruff code formatter for Python to follow the Ruff Style Guide
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing "yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
+12 -4
View File
@@ -1,6 +1,6 @@
# Adding New React Tools to Stirling PDF # Adding New React Tools to Stirling PDF
This guide covers how to add new PDF tools to the React frontend. This guide covers how to add new PDF tools to the React frontend, either by migrating existing Thymeleaf templates or creating entirely new tools.
## Overview ## Overview
@@ -188,7 +188,7 @@ import { use[ToolName]Tips } from "../components/tooltips/use[ToolName]Tips";
const [ToolName] = (props: BaseToolProps) => { const [ToolName] = (props: BaseToolProps) => {
const tips = use[ToolName]Tips(); const tips = use[ToolName]Tips();
// In your steps array: // In your steps array:
steps: [ steps: [
{ {
@@ -257,14 +257,22 @@ Update translation files. **Important: Only update `en-GB` files** - other langu
- Add `options.*` keys if your tool has settings with descriptions - Add `options.*` keys if your tool has settings with descriptions
**Tooltip Writing Guidelines:** **Tooltip Writing Guidelines:**
- **Use simple, everyday language** - avoid technical terms like "converts interactive elements" - **Use simple, everyday language** - avoid technical terms like "converts interactive elements"
- **Focus on benefits** - explain what the user gains, not how it works internally - **Focus on benefits** - explain what the user gains, not how it works internally
- **Use concrete examples** - "text boxes become regular text" vs "form fields are flattened" - **Use concrete examples** - "text boxes become regular text" vs "form fields are flattened"
- **Answer user questions** - "What does this do?", "When should I use this?", "What's this option for?" - **Answer user questions** - "What does this do?", "When should I use this?", "What's this option for?"
- **Keep descriptions concise** - 1-2 sentences maximum per section - **Keep descriptions concise** - 1-2 sentences maximum per section
- **Use bullet points** for multiple benefits or features - **Use bullet points** for multiple benefits or features
## 6. Testing Your Tool ## 6. Migration from Thymeleaf
When migrating existing Thymeleaf templates:
1. **Identify Form Parameters**: Look at the original `<form>` inputs to determine parameter structure
2. **Extract Translation Keys**: Find `#{key.name}` references and add them to JSON translations (For many tools these translations will already exist but some parts will be missing)
3. **Map API Endpoint**: Note the `th:action` URL for the operation hook
4. **Preserve Functionality**: Ensure all original form behaviour is replicated which is applicable to V2 react UI
## 7. Testing Your Tool
- Verify tool appears in UI with correct icon and description - Verify tool appears in UI with correct icon and description
- Test with various file sizes and types - Test with various file sizes and types
- Confirm translations work - Confirm translations work
+8 -117
View File
@@ -30,91 +30,6 @@ Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security featur
- **Web Server**: `npm run build` then serve dist/ folder - **Web Server**: `npm run build` then serve dist/ folder
- **Development**: `npm run tauri-dev` for desktop dev mode - **Development**: `npm run tauri-dev` for desktop dev mode
#### Import Paths - CRITICAL
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
```typescript
// ✅ CORRECT - Use @app/* for all imports
import { AppLayout } from "@app/components/AppLayout";
import { useFileContext } from "@app/contexts/FileContext";
import { FileContext } from "@app/contexts/FileContext";
// ❌ WRONG - Do not use @core/* or @proprietary/* in normal code
import { AppLayout } from "@core/components/AppLayout";
import { useFileContext } from "@proprietary/contexts/FileContext";
```
**Only use explicit aliases when:**
- Building layer-specific override that wraps a lower layer's component
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
#### Component Override Pattern (Stub/Shadow)
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
**How it works:**
1. Core defines stub component (returns null or no-op)
2. Desktop/proprietary overrides with same path/name
3. Core imports via `@app/*` - higher layer "shadows" core in those builds
4. No `@ts-ignore`, no `isTauri()` checks, no runtime conditionals!
**Example - Desktop-specific footer:**
```typescript
// core/components/rightRail/RightRailFooterExtensions.tsx (stub)
interface RightRailFooterExtensionsProps {
className?: string;
}
export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) {
return null; // Stub - does nothing in web builds
}
```
```typescript
// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation)
import { Box } from '@mantine/core';
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
interface RightRailFooterExtensionsProps {
className?: string;
}
export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
return (
<Box className={className}>
<BackendHealthIndicator />
</Box>
);
}
```
```typescript
// core/components/shared/RightRail.tsx (usage - works in ALL builds)
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
export function RightRail() {
return (
<div>
{/* In web builds: renders nothing (stub returns null) */}
{/* In desktop builds: renders BackendHealthIndicator */}
<RightRailFooterExtensions className="right-rail-footer" />
</div>
);
}
```
**Build resolution:**
- **Core build**: `@app/*``core/*` → Gets stub (returns null)
- **Desktop build**: `@app/*``desktop/*` → Gets real implementation (shadows core)
**Benefits:**
- No runtime checks or feature flags
- Type-safe across all builds
- Clean, readable code
- Build-time optimization (dead code elimination)
#### Multi-Tool Workflow Architecture #### Multi-Tool Workflow Architecture
Frontend designed for **stateful document processing**: Frontend designed for **stateful document processing**:
- Users upload PDFs once, then chain tools (split → merge → compress → view) - Users upload PDFs once, then chain tools (split → merge → compress → view)
@@ -122,7 +37,7 @@ Frontend designed for **stateful document processing**:
- No file reloading between tools - performance critical for large PDFs (up to 100GB+) - No file reloading between tools - performance critical for large PDFs (up to 100GB+)
#### FileContext - Central State Management #### FileContext - Central State Management
**Location**: `frontend/src/core/contexts/FileContext.tsx` **Location**: `src/contexts/FileContext.tsx`
- **Active files**: Currently loaded PDFs and their variants - **Active files**: Currently loaded PDFs and their variants
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName) - **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management - **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
@@ -147,7 +62,7 @@ Without cleanup: browser crashes with memory leaks.
**Architecture**: Modular hook-based system with clear separation of concerns: **Architecture**: Modular hook-based system with clear separation of concerns:
- **useToolOperation** (`frontend/src/core/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook - **useToolOperation** (`frontend/src/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
- Coordinates all tool operations with consistent interface - Coordinates all tool operations with consistent interface
- Integrates with FileContext for operation tracking - Integrates with FileContext for operation tracking
- Handles validation, error handling, and UI state management - Handles validation, error handling, and UI state management
@@ -213,8 +128,8 @@ return useToolOperation({
## Architecture Overview ## Architecture Overview
### Project Structure ### Project Structure
- **Backend**: Spring Boot application - **Backend**: Spring Boot application with Thymeleaf templating
- **Frontend**: React-based SPA in `/frontend` directory - **Frontend**: React-based SPA in `/frontend` directory (Thymeleaf templates fully replaced)
- **File Storage**: IndexedDB for client-side file persistence and thumbnails - **File Storage**: IndexedDB for client-side file persistence and thumbnails
- **Internationalization**: JSON-based translations (converted from backend .properties) - **Internationalization**: JSON-based translations (converted from backend .properties)
- **PDF Processing**: PDFBox for core PDF operations, LibreOffice for conversions, PDF.js for client-side rendering - **PDF Processing**: PDFBox for core PDF operations, LibreOffice for conversions, PDF.js for client-side rendering
@@ -225,6 +140,8 @@ return useToolOperation({
- **API Controllers** (`src/main/java/.../controller/api/`): REST endpoints for PDF operations - **API Controllers** (`src/main/java/.../controller/api/`): REST endpoints for PDF operations
- Organized by function: converters, security, misc, pipeline - Organized by function: converters, security, misc, pipeline
- Follow pattern: `@RestController` + `@RequestMapping("/api/v1/...")` - Follow pattern: `@RestController` + `@RequestMapping("/api/v1/...")`
- **Web Controllers** (`src/main/java/.../controller/web/`): Serve Thymeleaf templates
- Pattern: `@Controller` + return template names
### Key Components ### Key Components
- **SPDFApplication.java**: Main application class with desktop UI and browser launching logic - **SPDFApplication.java**: Main application class with desktop UI and browser launching logic
@@ -232,34 +149,8 @@ return useToolOperation({
- **Pipeline System**: Automated PDF processing workflows via `PipelineController` - **Pipeline System**: Automated PDF processing workflows via `PipelineController`
- **Security Layer**: Authentication, authorization, and user management (when enabled) - **Security Layer**: Authentication, authorization, and user management (when enabled)
### Frontend Directory Structure
The frontend is organized with a clear separation of concerns:
- **`frontend/src/core/`**: Main application code (shared, production-ready components)
- **`core/components/`**: React components organized by feature
- `core/components/tools/`: Individual PDF tool implementations
- `core/components/viewer/`: PDF viewer components
- `core/components/pageEditor/`: Page manipulation UI
- `core/components/tooltips/`: Help tooltips for tools
- `core/components/shared/`: Reusable UI components
- **`core/contexts/`**: React Context providers
- `FileContext.tsx`: Central file state management
- `file/`: File reducer and selectors
- `toolWorkflow/`: Tool workflow state
- **`core/hooks/`**: Custom React hooks
- `hooks/tools/`: Tool-specific operation hooks (one directory per tool)
- `hooks/tools/shared/`: Shared hook utilities (useToolOperation, etc.)
- **`core/constants/`**: Application constants and configuration
- **`core/data/`**: Static data (tool taxonomy, etc.)
- **`core/services/`**: Business logic services (PDF processing, storage, etc.)
- **`frontend/src/desktop/`**: Desktop-specific (Tauri) code
- **`frontend/src/proprietary/`**: Proprietary/licensed features
- **`frontend/src-tauri/`**: Tauri (Rust) native desktop application code
- **`frontend/public/`**: Static assets served directly
- `public/locales/`: Translation JSON files
### Component Architecture ### Component Architecture
- **React Components**: Located in `frontend/src/components/` and `frontend/src/tools/`
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern) - **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
- **Internationalization**: - **Internationalization**:
- Backend: `messages_*.properties` files - Backend: `messages_*.properties` files
@@ -310,11 +201,11 @@ The frontend is organized with a clear separation of concerns:
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21 - **Java Version**: Minimum JDK 17, supports and recommends JDK 21
- **Lombok**: Used extensively - ensure IDE plugin is installed - **Lombok**: Used extensively - ensure IDE plugin is installed
- **Desktop Mode**: Set `STIRLING_PDF_DESKTOP_UI=true` for desktop application mode
- **File Persistence**: - **File Persistence**:
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only - **Backend**: Designed to be stateless - files are processed in memory/temp locations only
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails) - **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation - **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
- **Import Paths**: ALWAYS use `@app/*` for imports - never use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling - **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code - **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`) - **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
+162 -3
View File
@@ -2,7 +2,7 @@
## 1. Introduction ## 1. Introduction
Stirling-PDF is a robust, locally hosted, web-based PDF manipulation tool. **Stirling 2.0** represents a complete frontend rewrite with a modern React SPA (Single Page Application). Stirling-PDF is a robust, locally hosted, web-based PDF manipulation tool. **Stirling 2.0** represents a complete frontend rewrite, replacing the legacy Thymeleaf-based UI with a modern React SPA (Single Page Application).
This guide focuses on developing for Stirling 2.0, including both the React frontend and Spring Boot backend development workflows. This guide focuses on developing for Stirling 2.0, including both the React frontend and Spring Boot backend development workflows.
@@ -38,6 +38,9 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
- PDF file association support - PDF file association support
- Self-contained JRE bundling with JLink - Self-contained JRE bundling with JLink
**Legacy (reference only during development):**
- Thymeleaf templates (being completely replaced in 2.0)
## 3. Development Environment Setup ## 3. Development Environment Setup
### Prerequisites ### Prerequisites
@@ -97,6 +100,9 @@ Stirling 2.0 uses client-side file storage:
- **PDF.js**: Handles client-side PDF rendering and processing - **PDF.js**: Handles client-side PDF rendering and processing
- **URL Parameters**: Support for deep linking and tool state persistence - **URL Parameters**: Support for deep linking and tool state persistence
### Legacy Code Reference
The existing Thymeleaf templates remain in the codebase during development as reference material but will be completely removed for the 2.0 release.
### Tauri Desktop App Development ### Tauri Desktop App Development
Stirling-PDF can be packaged as a cross-platform desktop application using Tauri with PDF file association support and bundled JRE. Stirling-PDF can be packaged as a cross-platform desktop application using Tauri with PDF file association support and bundled JRE.
See [the frontend README](frontend/README.md#tauri) for build instructions. See [the frontend README](frontend/README.md#tauri) for build instructions.
@@ -148,6 +154,7 @@ Stirling-PDF/
│ │ │ ├── css/ │ │ │ ├── css/
│ │ │ ├── js/ │ │ │ ├── js/
│ │ │ └── pdfjs/ │ │ │ └── pdfjs/
│ │ └── templates/ # Legacy Thymeleaf templates (reference only)
│ └── test/ │ └── test/
├── testing/ # Cucumber and integration tests ├── testing/ # Cucumber and integration tests
│ └── cucumber/ # Cucumber test files │ └── cucumber/ # Cucumber test files
@@ -302,6 +309,7 @@ For quick iterations and development of Java backend, JavaScript, and UI compone
- RESTful API endpoints - RESTful API endpoints
- JavaScript functionality - JavaScript functionality
- User interface components and styling - User interface components and styling
- Thymeleaf templates
To run Stirling-PDF locally: To run Stirling-PDF locally:
@@ -393,7 +401,7 @@ Remember to test your changes thoroughly to ensure they don't break any existing
### React Component Development (Stirling 2.0) ### React Component Development (Stirling 2.0)
For Stirling 2.0, new features are built as React components: For Stirling 2.0, new features are built as React components instead of Thymeleaf templates:
#### Creating a New Tool Component #### Creating a New Tool Component
@@ -440,6 +448,61 @@ For Stirling 2.0, new features are built as React components:
3. **Register in Tool Picker:** 3. **Register in Tool Picker:**
Update the tool picker component to include the new tool with proper routing and URL parameter support. Update the tool picker component to include the new tool with proper routing and URL parameter support.
### Legacy Reference: Overview of Thymeleaf
Thymeleaf is a server-side Java HTML template engine. It is used in Stirling-PDF to render dynamic web pages. Thymeleaf integrates heavily with Spring Boot.
### Thymeleaf overview
In Stirling-PDF, Thymeleaf is used to create HTML templates that are rendered on the server side. These templates are located in the `stirling-pdf/src/main/resources/templates` directory. Thymeleaf templates use a combination of HTML and special Thymeleaf attributes to dynamically generate content.
Some examples of this are:
```html
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
```
or
```html
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
```
Where it uses the `th:block`, `th:` indicating it's a special Thymeleaf element to be used server-side in generating the HTML, and block being the actual element type.
In this case, we are inserting the `navbar` entry within the `fragments/navbar.html` fragment into the `th:block` element.
They can be more complex, such as:
```html
<th:block th:insert="~{fragments/common :: head(title=#{pageExtracter.title}, header=#{pageExtracter.header})}"></th:block>
```
Which is the same as above but passes the parameters title and header into the fragment `common.html` to be used in its HTML generation.
Thymeleaf can also be used to loop through objects or pass things from the Java side into the HTML side.
```java
@GetMapping
public String newFeaturePage(Model model) {
model.addAttribute("exampleData", exampleData);
return "new-feature";
}
```
In the above example, if exampleData is a list of plain java objects of class Person and within it, you had id, name, age, etc. You can reference it like so
```html
<tbody>
<!-- Use th:each to iterate over the list -->
<tr th:each="person : ${exampleData}">
<td th:text="${person.id}"></td>
<td th:text="${person.name}"></td>
<td th:text="${person.age}"></td>
<td th:text="${person.email}"></td>
</tr>
</tbody>
```
This would generate n entries of tr for each person in exampleData
### Adding a New Feature to the Backend (API) ### Adding a New Feature to the Backend (API)
1. **Create a New Controller:** 1. **Create a New Controller:**
@@ -464,7 +527,7 @@ For Stirling 2.0, new features are built as React components:
@GetMapping @GetMapping
@Operation(summary = "New Feature", description = "This is a new feature endpoint.") @Operation(summary = "New Feature", description = "This is a new feature endpoint.")
public String newFeature() { public String newFeature() {
return "NewFeatureResponse"; return "NewFeatureResponse"; // This refers to the NewFeatureResponse.html template presenting the user with the generated html from that file when they navigate to /api/v1/new-feature
} }
} }
``` ```
@@ -519,6 +582,91 @@ For Stirling 2.0, new features are built as React components:
} }
``` ```
### Adding a New Feature to the Frontend (UI)
1. **Create a New Thymeleaf Template:**
- Create a new HTML file in the `stirling-pdf/src/main/resources/templates` directory.
- Use Thymeleaf attributes to dynamically generate content.
- Use `extract-page.html` as a base example for the HTML template, which is useful to ensure importing of the general layout, navbar, and footer.
```html
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title=#{newFeature.title}, header=#{newFeature.header})}"></th:block>
</head>
<body>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon organize">upload</span>
<span class="tool-header-text" th:text="#{newFeature.header}"></span>
</div>
<form th:action="@{'/api/v1/new-feature'}" method="post" enctype="multipart/form-data">
<div th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='application/pdf')}"></div>
<input type="hidden" id="customMode" name="customMode" value="">
<div class="mb-3">
<label for="featureInput" th:text="#{newFeature.prompt}"></label>
<input type="text" class="form-control" id="featureInput" name="featureInput" th:placeholder="#{newFeature.placeholder}" required>
</div>
<button type="submit" id="submitBtn" class="btn btn-primary" th:text="#{newFeature.submit}"></button>
</form>
</div>
</div>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>
```
2. **Create a New Controller for the UI:**
- Create a new Java class in the `stirling-pdf/src/main/java/stirling/software/SPDF/controller/ui` directory.
- Annotate the class with `@Controller` and `@RequestMapping` to define the UI endpoint.
```java
package stirling.software.SPDF.controller.ui;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import stirling.software.SPDF.service.NewFeatureService;
@Controller
@RequestMapping("/new-feature")
public class NewFeatureUIController {
@Autowired
private NewFeatureService newFeatureService;
@GetMapping
public String newFeaturePage(Model model) {
model.addAttribute("newFeatureData", newFeatureService.getNewFeatureData());
return "new-feature";
}
}
```
3. **Update the Navigation Bar:**
- Add a link to the new feature page in the navigation bar.
- Update the `stirling-pdf/src/main/resources/templates/fragments/navbar.html` file.
```html
<li class="nav-item">
<a class="nav-link" th:href="@{'/new-feature'}">New Feature</a>
</li>
```
## Adding New Translations to Existing Language Files in Stirling-PDF ## Adding New Translations to Existing Language Files in Stirling-PDF
When adding a new feature or modifying existing ones in Stirling-PDF, you'll need to add new translation entries to the existing language files. Here's a step-by-step guide: When adding a new feature or modifying existing ones in Stirling-PDF, you'll need to add new translation entries to the existing language files. Here's a step-by-step guide:
@@ -548,4 +696,15 @@ pdfSplitter.input.pages=Enter page numbers to split
Add these entries to the default GB language file and any others you wish, translating the values as appropriate for each language. Add these entries to the default GB language file and any others you wish, translating the values as appropriate for each language.
### 3. Use Translations in Thymeleaf Templates
In your Thymeleaf templates, use the `#{key}` syntax to reference the new translations:
```html
<h1 th:text="#{pdfSplitter.title}">PDF Splitter</h1>
<p th:text="#{pdfSplitter.description}">Split your PDF into multiple documents</p>
<input type="text" th:placeholder="#{pdfSplitter.input.pages}">
<button th:text="#{pdfSplitter.button.split}">Split PDF</button>
```
Remember, never hard-code text in your templates or Java code. Always use translation keys to ensure proper localization. Remember, never hard-code text in your templates or Java code. Always use translation keys to ensure proper localization.
-16
View File
@@ -96,22 +96,6 @@
"moduleName": ".*", "moduleName": ".*",
"moduleLicense": "MPL 2.0" "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": ".*", "moduleName": ".*",
"moduleLicense": "UnboundID SCIM2 SDK Free Use License" "moduleLicense": "UnboundID SCIM2 SDK Free Use License"
+5 -8
View File
@@ -29,23 +29,20 @@ spotless {
dependencies { dependencies {
api 'org.springframework.boot:spring-boot-starter-web' api 'org.springframework.boot:spring-boot-starter-web'
api 'org.springframework.boot:spring-boot-starter-aop' api 'org.springframework.boot:spring-boot-starter-aop'
// api 'org.springframework.boot:spring-boot-starter-thymeleaf' // Deprecated - UI moved to React frontend
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1' api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1'
api 'com.fathzer:javaluator:3.0.6' api 'com.fathzer:javaluator:3.0.6'
api 'com.posthog.java:posthog:1.2.0' api 'com.posthog.java:posthog:1.2.0'
api 'org.apache.commons:commons-lang3:3.20.0' api 'org.apache.commons:commons-lang3:3.19.0'
api 'com.drewnoakes:metadata-extractor:2.19.0' // Image metadata extractor api 'com.drewnoakes:metadata-extractor:2.19.0' // Image metadata extractor
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8' api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
api "org.apache.pdfbox:pdfbox:$pdfboxVersion" api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
api "org.apache.pdfbox:pdfbox-io:$pdfboxVersion"
api "org.apache.pdfbox:xmpbox:$pdfboxVersion" api "org.apache.pdfbox:xmpbox:$pdfboxVersion"
api "org.apache.pdfbox:preflight:$pdfboxVersion" api "org.apache.pdfbox:preflight:$pdfboxVersion"
api 'com.github.junrar:junrar:7.5.7' // RAR archive support for CBR files api 'com.github.junrar:junrar:7.5.5' // RAR archive support for CBR files
api 'jakarta.servlet:jakarta.servlet-api:6.1.0' api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
api 'org.snakeyaml:snakeyaml-engine:3.0.1' api 'org.snakeyaml:snakeyaml-engine:2.10'
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.15" api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13"
// 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' api 'jakarta.mail:jakarta.mail-api:2.1.5'
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5' runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
} }
@@ -156,19 +156,19 @@ public class EndpointConfiguration {
return false; 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); Set<String> endpoints = endpointGroups.get(group);
if (endpoints == null || endpoints.isEmpty()) { if (endpoints == null || endpoints.isEmpty()) {
log.debug("isGroupEnabled('{}') -> false (no endpoints)", group); log.debug("isGroupEnabled('{}') -> false (no endpoints)", group);
return false; 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 functional groups, check each endpoint individually
for (String endpoint : endpoints) { for (String endpoint : endpoints) {
if (!isEndpointEnabledDirectly(endpoint)) { if (!isEndpointEnabledDirectly(endpoint)) {
@@ -303,23 +303,22 @@ public class EndpointConfiguration {
// Adding endpoints to "PageOps" group // Adding endpoints to "PageOps" group
addEndpointToGroup("PageOps", "remove-pages"); addEndpointToGroup("PageOps", "remove-pages");
addEndpointToGroup("PageOps", "merge-pdfs"); addEndpointToGroup("PageOps", "merge-pdfs");
addEndpointToGroup("PageOps", "split-pages"); addEndpointToGroup("PageOps", "split-pdfs");
addEndpointToGroup("PageOps", "rearrange-pages"); addEndpointToGroup("PageOps", "pdf-organizer");
addEndpointToGroup("PageOps", "rotate-pdf"); addEndpointToGroup("PageOps", "rotate-pdf");
addEndpointToGroup("PageOps", "multi-page-layout"); addEndpointToGroup("PageOps", "multi-page-layout");
addEndpointToGroup("PageOps", "booklet-imposition"); addEndpointToGroup("PageOps", "booklet-imposition");
addEndpointToGroup("PageOps", "scale-pages"); addEndpointToGroup("PageOps", "scale-pages");
addEndpointToGroup("PageOps", "crop"); addEndpointToGroup("PageOps", "crop");
addEndpointToGroup("PageOps", "extract-page");
addEndpointToGroup("PageOps", "pdf-to-single-page"); addEndpointToGroup("PageOps", "pdf-to-single-page");
addEndpointToGroup("PageOps", "auto-split-pdf"); addEndpointToGroup("PageOps", "auto-split-pdf");
addEndpointToGroup("PageOps", "split-by-size-or-count"); addEndpointToGroup("PageOps", "split-by-size-or-count");
addEndpointToGroup("PageOps", "overlay-pdf"); addEndpointToGroup("PageOps", "overlay-pdf");
addEndpointToGroup("PageOps", "split-pdf-by-sections"); addEndpointToGroup("PageOps", "split-pdf-by-sections");
addEndpointToGroup("PageOps", "split-pdf-by-chapters"); addEndpointToGroup("PageOps", "split-pdf-by-chapters");
addEndpointToGroup("PageOps", "add-page-numbers");
addEndpointToGroup("PageOps", "extract-pages");
// Adding endpoints to "Convert" group (Frontend has 15 convert endpoints) // Adding endpoints to "Convert" group
addEndpointToGroup("Convert", "pdf-to-img"); addEndpointToGroup("Convert", "pdf-to-img");
addEndpointToGroup("Convert", "img-to-pdf"); addEndpointToGroup("Convert", "img-to-pdf");
addEndpointToGroup("Convert", "pdf-to-pdfa"); addEndpointToGroup("Convert", "pdf-to-pdfa");
@@ -335,16 +334,10 @@ public class EndpointConfiguration {
addEndpointToGroup("Convert", "pdf-to-csv"); addEndpointToGroup("Convert", "pdf-to-csv");
addEndpointToGroup("Convert", "pdf-to-markdown"); addEndpointToGroup("Convert", "pdf-to-markdown");
addEndpointToGroup("Convert", "eml-to-pdf"); addEndpointToGroup("Convert", "eml-to-pdf");
addEndpointToGroup("Convert", "pdf-to-epub");
// Backend-only endpoints (not in frontend tool registry)
addEndpointToGroup("Convert", "pdf-to-vector");
addEndpointToGroup("Convert", "vector-to-pdf");
addEndpointToGroup("Convert", "pdf-to-video");
addEndpointToGroup("Convert", "cbz-to-pdf"); addEndpointToGroup("Convert", "cbz-to-pdf");
addEndpointToGroup("Convert", "pdf-to-cbz"); addEndpointToGroup("Convert", "pdf-to-cbz");
addEndpointToGroup("Convert", "pdf-to-json"); addEndpointToGroup("Convert", "pdf-to-json");
addEndpointToGroup("Convert", "json-to-pdf"); addEndpointToGroup("Convert", "json-to-pdf");
addEndpointToGroup("Convert", "pdf-to-rtf");
// Adding endpoints to "Security" group // Adding endpoints to "Security" group
addEndpointToGroup("Security", "add-password"); addEndpointToGroup("Security", "add-password");
@@ -355,60 +348,50 @@ public class EndpointConfiguration {
addEndpointToGroup("Security", "remove-cert-sign"); addEndpointToGroup("Security", "remove-cert-sign");
addEndpointToGroup("Security", "sanitize-pdf"); addEndpointToGroup("Security", "sanitize-pdf");
addEndpointToGroup("Security", "auto-redact"); addEndpointToGroup("Security", "auto-redact");
addEndpointToGroup("Security", "validate-signature");
addEndpointToGroup("Security", "add-stamp");
addEndpointToGroup("Security", "unlock-pdf-forms");
// Backend-only endpoints (not in frontend tool registry endpoints)
addEndpointToGroup("Security", "redact"); addEndpointToGroup("Security", "redact");
addEndpointToGroup("Security", "verify-pdf"); addEndpointToGroup("Security", "validate-signature");
addEndpointToGroup("Security", "stamp");
addEndpointToGroup("Security", "sign"); addEndpointToGroup("Security", "sign");
// Adding endpoints to "Other" group // Adding endpoints to "Other" group
addEndpointToGroup("Other", "ocr-pdf"); addEndpointToGroup("Other", "ocr-pdf");
addEndpointToGroup("Other", "add-image");
addEndpointToGroup("Other", "extract-images"); addEndpointToGroup("Other", "extract-images");
addEndpointToGroup("Other", "update-metadata"); addEndpointToGroup("Other", "change-metadata");
addEndpointToGroup("Other", "flatten"); addEndpointToGroup("Other", "flatten");
addEndpointToGroup("Other", "unlock-pdf-forms");
addEndpointToGroup("Other", REMOVE_BLANKS); addEndpointToGroup("Other", REMOVE_BLANKS);
addEndpointToGroup("Other", "remove-annotations"); addEndpointToGroup("Other", "remove-annotations");
addEndpointToGroup("Other", "compare");
addEndpointToGroup("Other", "add-page-numbers");
addEndpointToGroup("Other", "get-info-on-pdf"); addEndpointToGroup("Other", "get-info-on-pdf");
addEndpointToGroup("Other", "remove-image-pdf"); addEndpointToGroup("Other", "remove-image-pdf");
addEndpointToGroup("Other", "add-attachments"); addEndpointToGroup("Other", "add-attachments");
addEndpointToGroup("Other", "replace-invert-pdf");
addEndpointToGroup("Other", "edit-table-of-contents");
addEndpointToGroup("Other", "text-editor-pdf");
// Backend-only endpoints (not in frontend tool registry endpoints)
addEndpointToGroup("Other", "add-image");
addEndpointToGroup("Other", "compare");
addEndpointToGroup("Other", "view-pdf"); addEndpointToGroup("Other", "view-pdf");
addEndpointToGroup("Other", "replace-and-invert-color-pdf");
addEndpointToGroup("Other", "multi-tool"); addEndpointToGroup("Other", "multi-tool");
// Adding form-related endpoints to "Other" group
addEndpointToGroup("Other", "fields"); addEndpointToGroup("Other", "fields");
addEndpointToGroup("Other", "modify-fields"); addEndpointToGroup("Other", "modify-fields");
addEndpointToGroup("Other", "delete-fields"); addEndpointToGroup("Other", "delete-fields");
addEndpointToGroup("Other", "fill"); addEndpointToGroup("Other", "fill");
// Adding endpoints to "Advance" group // Adding endpoints to "Advance" group
addEndpointToGroup("Advance", "adjust-contrast");
addEndpointToGroup("Advance", "compress-pdf"); addEndpointToGroup("Advance", "compress-pdf");
addEndpointToGroup("Advance", "extract-image-scans"); addEndpointToGroup("Advance", "extract-image-scans");
addEndpointToGroup("Advance", "repair"); addEndpointToGroup("Advance", "repair");
addEndpointToGroup("Advance", "auto-rename"); addEndpointToGroup("Advance", "auto-rename");
addEndpointToGroup("Advance", "pipeline");
addEndpointToGroup("Advance", "scanner-effect"); addEndpointToGroup("Advance", "scanner-effect");
addEndpointToGroup("Advance", "auto-split-pdf");
addEndpointToGroup("Advance", "show-javascript");
addEndpointToGroup("Advance", "split-by-size-or-count");
addEndpointToGroup("Advance", "overlay-pdf"); addEndpointToGroup("Advance", "overlay-pdf");
// Backend-only endpoints addEndpointToGroup("Advance", "split-pdf-by-sections");
addEndpointToGroup("Advance", "adjust-contrast"); addEndpointToGroup("Advance", "edit-table-of-contents");
addEndpointToGroup("Advance", "split-pdf-by-chapters");
// Adding endpoints to "Automation" group
addEndpointToGroup("Automation", "handleData");
addEndpointToGroup("Automation", "automate"); // Alias for handleData (user-friendly name)
addEndpointToGroup("Automation", "pipeline");
// Adding endpoints to "DeveloperTools" group
addEndpointToGroup("DeveloperTools", "show-javascript");
// Adding endpoints to "DeveloperDocs" group (fake endpoints for link-only tools)
addEndpointToGroup("DeveloperDocs", "dev-api-docs");
addEndpointToGroup("DeveloperDocs", "dev-folder-scanning-docs");
addEndpointToGroup("DeveloperDocs", "dev-sso-guide-docs");
addEndpointToGroup("DeveloperDocs", "dev-airgapped-docs");
// CLI // CLI
addEndpointToGroup("CLI", "compress-pdf"); addEndpointToGroup("CLI", "compress-pdf");
@@ -449,8 +432,8 @@ public class EndpointConfiguration {
// Java // Java
addEndpointToGroup("Java", "merge-pdfs"); addEndpointToGroup("Java", "merge-pdfs");
addEndpointToGroup("Java", "remove-pages"); addEndpointToGroup("Java", "remove-pages");
addEndpointToGroup("Java", "split-pages"); addEndpointToGroup("Java", "split-pdfs");
addEndpointToGroup("Java", "rearrange-pages"); addEndpointToGroup("Java", "pdf-organizer");
addEndpointToGroup("Java", "rotate-pdf"); addEndpointToGroup("Java", "rotate-pdf");
addEndpointToGroup("Java", "pdf-to-img"); addEndpointToGroup("Java", "pdf-to-img");
addEndpointToGroup("Java", "img-to-pdf"); addEndpointToGroup("Java", "img-to-pdf");
@@ -458,10 +441,9 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "remove-password"); addEndpointToGroup("Java", "remove-password");
addEndpointToGroup("Java", "change-permissions"); addEndpointToGroup("Java", "change-permissions");
addEndpointToGroup("Java", "add-watermark"); addEndpointToGroup("Java", "add-watermark");
addEndpointToGroup("Java", "add-stamp");
addEndpointToGroup("Java", "add-image"); addEndpointToGroup("Java", "add-image");
addEndpointToGroup("Java", "extract-images"); addEndpointToGroup("Java", "extract-images");
addEndpointToGroup("Java", "update-metadata"); addEndpointToGroup("Java", "change-metadata");
addEndpointToGroup("Java", "cert-sign"); addEndpointToGroup("Java", "cert-sign");
addEndpointToGroup("Java", "remove-cert-sign"); addEndpointToGroup("Java", "remove-cert-sign");
addEndpointToGroup("Java", "multi-page-layout"); addEndpointToGroup("Java", "multi-page-layout");
@@ -473,6 +455,7 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "sanitize-pdf"); addEndpointToGroup("Java", "sanitize-pdf");
addEndpointToGroup("Java", "crop"); addEndpointToGroup("Java", "crop");
addEndpointToGroup("Java", "get-info-on-pdf"); addEndpointToGroup("Java", "get-info-on-pdf");
addEndpointToGroup("Java", "extract-page");
addEndpointToGroup("Java", "pdf-to-single-page"); addEndpointToGroup("Java", "pdf-to-single-page");
addEndpointToGroup("Java", "markdown-to-pdf"); addEndpointToGroup("Java", "markdown-to-pdf");
addEndpointToGroup("Java", "show-javascript"); addEndpointToGroup("Java", "show-javascript");
@@ -482,9 +465,7 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "split-by-size-or-count"); addEndpointToGroup("Java", "split-by-size-or-count");
addEndpointToGroup("Java", "overlay-pdf"); addEndpointToGroup("Java", "overlay-pdf");
addEndpointToGroup("Java", "split-pdf-by-sections"); addEndpointToGroup("Java", "split-pdf-by-sections");
addEndpointToGroup("Java", "split-pdf-by-chapters");
addEndpointToGroup("Java", REMOVE_BLANKS); addEndpointToGroup("Java", REMOVE_BLANKS);
addEndpointToGroup("Java", "remove-annotations");
addEndpointToGroup("Java", "pdf-to-text"); addEndpointToGroup("Java", "pdf-to-text");
addEndpointToGroup("Java", "remove-image-pdf"); addEndpointToGroup("Java", "remove-image-pdf");
addEndpointToGroup("Java", "pdf-to-markdown"); addEndpointToGroup("Java", "pdf-to-markdown");
@@ -494,24 +475,13 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "pdf-to-cbz"); addEndpointToGroup("Java", "pdf-to-cbz");
addEndpointToGroup("Java", "pdf-to-json"); addEndpointToGroup("Java", "pdf-to-json");
addEndpointToGroup("Java", "json-to-pdf"); addEndpointToGroup("Java", "json-to-pdf");
addEndpointToGroup("Java", "pdf-to-video");
addEndpointToGroup("Java", "verify-pdf");
addEndpointToGroup("Java", "flatten");
addEndpointToGroup("Java", "unlock-pdf-forms");
addEndpointToGroup("Java", "validate-signature");
addEndpointToGroup("Java", "text-editor-pdf");
addEndpointToGroup("Java", "edit-table-of-contents");
addEndpointToGroup("Java", "pdf-to-epub");
addEndpointToGroup("Java", "eml-to-pdf");
addEndpointToGroup("Java", "handleData");
addEndpointToGroup("rar", "pdf-to-cbr"); addEndpointToGroup("rar", "pdf-to-cbr");
// Javascript // Javascript
addEndpointToGroup("Javascript", "rearrange-pages"); addEndpointToGroup("Javascript", "pdf-organizer");
addEndpointToGroup("Javascript", "sign"); addEndpointToGroup("Javascript", "sign");
addEndpointToGroup("Javascript", "compare"); addEndpointToGroup("Javascript", "compare");
addEndpointToGroup("Javascript", "adjust-contrast"); addEndpointToGroup("Javascript", "adjust-contrast");
addEndpointToGroup("Javascript", "text-editor-pdf");
/* qpdf */ /* qpdf */
addEndpointToGroup("qpdf", "repair"); addEndpointToGroup("qpdf", "repair");
@@ -520,14 +490,6 @@ public class EndpointConfiguration {
/* Ghostscript */ /* Ghostscript */
addEndpointToGroup("Ghostscript", "repair"); addEndpointToGroup("Ghostscript", "repair");
addEndpointToGroup("Ghostscript", "compress-pdf"); addEndpointToGroup("Ghostscript", "compress-pdf");
addEndpointToGroup("Ghostscript", "crop");
addEndpointToGroup("Ghostscript", "replace-invert-pdf");
addEndpointToGroup("Ghostscript", "scanner-effect");
addEndpointToGroup("Ghostscript", "pdf-to-vector");
addEndpointToGroup("Ghostscript", "vector-to-pdf");
/* ImageMagick */
addEndpointToGroup("ImageMagick", "compress-pdf");
/* tesseract */ /* tesseract */
addEndpointToGroup("tesseract", "ocr-pdf"); addEndpointToGroup("tesseract", "ocr-pdf");
@@ -541,8 +503,6 @@ public class EndpointConfiguration {
addEndpointAlternative("compress-pdf", "qpdf"); addEndpointAlternative("compress-pdf", "qpdf");
addEndpointAlternative("compress-pdf", "Ghostscript"); addEndpointAlternative("compress-pdf", "Ghostscript");
addEndpointAlternative("compress-pdf", "Java"); addEndpointAlternative("compress-pdf", "Java");
addEndpointAlternative("crop", "Ghostscript");
addEndpointAlternative("crop", "Java");
addEndpointAlternative("ocr-pdf", "tesseract"); addEndpointAlternative("ocr-pdf", "tesseract");
addEndpointAlternative("ocr-pdf", "OCRmyPDF"); addEndpointAlternative("ocr-pdf", "OCRmyPDF");
@@ -565,15 +525,9 @@ public class EndpointConfiguration {
addEndpointToGroup("Weasyprint", "markdown-to-pdf"); addEndpointToGroup("Weasyprint", "markdown-to-pdf");
addEndpointToGroup("Weasyprint", "eml-to-pdf"); addEndpointToGroup("Weasyprint", "eml-to-pdf");
// veraPDF dependent endpoints
addEndpointToGroup("veraPDF", "verify-pdf");
// Pdftohtml dependent endpoints // Pdftohtml dependent endpoints
addEndpointToGroup("Pdftohtml", "pdf-to-html"); addEndpointToGroup("Pdftohtml", "pdf-to-html");
addEndpointToGroup("Pdftohtml", "pdf-to-markdown"); addEndpointToGroup("Pdftohtml", "pdf-to-markdown");
// Calibre dependent endpoints
addEndpointToGroup("Calibre", "pdf-to-epub");
} }
private void processEnvironmentConfigs() { private void processEnvironmentConfigs() {
@@ -597,7 +551,7 @@ public class EndpointConfiguration {
disableGroup("enterprise"); disableGroup("enterprise");
} }
if (!applicationProperties.getSystem().isEnableUrlToPDF()) { if (!applicationProperties.getSystem().getEnableUrlToPDF()) {
disableEndpoint("url-to-pdf"); disableEndpoint("url-to-pdf");
} }
} }
@@ -606,12 +560,6 @@ public class EndpointConfiguration {
return endpointGroups.getOrDefault(group, new HashSet<>()); return endpointGroups.getOrDefault(group, new HashSet<>());
} }
public Set<String> getAllEndpoints() {
return endpointGroups.values().stream()
.flatMap(Set::stream)
.collect(java.util.stream.Collectors.toSet());
}
private boolean isToolGroup(String group) { private boolean isToolGroup(String group) {
return "qpdf".equals(group) return "qpdf".equals(group)
|| "OCRmyPDF".equals(group) || "OCRmyPDF".equals(group)
@@ -626,11 +574,7 @@ public class EndpointConfiguration {
|| "Javascript".equals(group) || "Javascript".equals(group)
|| "Weasyprint".equals(group) || "Weasyprint".equals(group)
|| "Pdftohtml".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) { private boolean isEndpointEnabledDirectly(String endpoint) {
@@ -37,7 +37,7 @@ public class AutoJobAspect {
@Around("@annotation(autoJobPostMapping)") @Around("@annotation(autoJobPostMapping)")
public Object wrapWithJobExecution( public Object wrapWithJobExecution(
ProceedingJoinPoint joinPoint, AutoJobPostMapping autoJobPostMapping) throws Exception { ProceedingJoinPoint joinPoint, AutoJobPostMapping autoJobPostMapping) {
// This aspect will run before any audit aspects due to @Order(0) // This aspect will run before any audit aspects due to @Order(0)
// Extract parameters from the request and annotation // Extract parameters from the request and annotation
boolean async = Boolean.parseBoolean(request.getParameter("async")); boolean async = Boolean.parseBoolean(request.getParameter("async"));
@@ -81,12 +81,6 @@ public class AutoJobAspect {
"AutoJobAspect caught exception during job execution: {}", "AutoJobAspect caught exception during job execution: {}",
ex.getMessage(), ex.getMessage(),
ex); ex);
// Rethrow RuntimeException as-is to preserve exception type
if (ex instanceof RuntimeException) {
throw (RuntimeException) ex;
}
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
// BaseAppException
throw new RuntimeException(ex); throw new RuntimeException(ex);
} }
}, },
@@ -115,8 +109,7 @@ public class AutoJobAspect {
int maxRetries, int maxRetries,
boolean trackProgress, boolean trackProgress,
boolean queueable, boolean queueable,
int resourceWeight) int resourceWeight) {
throws Exception {
// Keep jobId reference for progress tracking in TaskManager // Keep jobId reference for progress tracking in TaskManager
AtomicReference<String> jobIdRef = new AtomicReference<>(); AtomicReference<String> jobIdRef = new AtomicReference<>();
@@ -214,12 +207,6 @@ public class AutoJobAspect {
// If we get here, all retries failed // If we get here, all retries failed
if (lastException != null) { if (lastException != null) {
// Rethrow RuntimeException as-is to preserve exception type
if (lastException instanceof RuntimeException) {
throw (RuntimeException) lastException;
}
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
// BaseAppException
throw new RuntimeException( throw new RuntimeException(
"Job failed after " "Job failed after "
+ maxRetries + maxRetries
@@ -37,6 +37,10 @@ public class AppConfig {
private final ApplicationProperties applicationProperties; private final ApplicationProperties applicationProperties;
@Getter
@Value("${baseUrl:http://localhost}")
private String baseUrl;
@Getter @Getter
@Value("${server.servlet.context-path:/}") @Value("${server.servlet.context-path:/}")
private String contextPath; private String contextPath;
@@ -45,17 +49,6 @@ public class AppConfig {
@Value("${server.port:8080}") @Value("${server.port:8080}")
private String serverPort; private String serverPort;
/**
* Get the backend URL from system configuration. Falls back to http://localhost if not
* configured.
*
* @return The backend base URL for SAML/OAuth/API callbacks
*/
public String getBackendUrl() {
String backendUrl = applicationProperties.getSystem().getBackendUrl();
return (backendUrl != null && !backendUrl.isBlank()) ? backendUrl : "http://localhost";
}
@Value("${v2}") @Value("${v2}")
public boolean v2Enabled; public boolean v2Enabled;
@@ -64,9 +57,19 @@ public class AppConfig {
return v2Enabled; return v2Enabled;
} }
/* Commented out Thymeleaf template engine bean - to be removed when frontend migration is complete
@Bean
@ConditionalOnProperty(name = "system.customHTMLFiles", havingValue = "true")
public SpringTemplateEngine templateEngine(ResourceLoader resourceLoader) {
SpringTemplateEngine templateEngine = new SpringTemplateEngine();
templateEngine.addTemplateResolver(new FileFallbackTemplateResolver(resourceLoader));
return templateEngine;
}
*/
@Bean(name = "loginEnabled") @Bean(name = "loginEnabled")
public boolean loginEnabled() { public boolean loginEnabled() {
return applicationProperties.getSecurity().isEnableLogin(); return applicationProperties.getSecurity().getEnableLogin();
} }
@Bean(name = "appName") @Bean(name = "appName")
@@ -110,7 +113,9 @@ public class AppConfig {
@Bean(name = "enableAlphaFunctionality") @Bean(name = "enableAlphaFunctionality")
public boolean enableAlphaFunctionality() { public boolean enableAlphaFunctionality() {
return applicationProperties.getSystem().isEnableAlphaFunctionality(); return applicationProperties.getSystem().getEnableAlphaFunctionality() != null
? applicationProperties.getSystem().getEnableAlphaFunctionality()
: false;
} }
@Bean(name = "rateLimit") @Bean(name = "rateLimit")
@@ -253,14 +258,9 @@ public class AppConfig {
return "NORMAL"; return "NORMAL";
} }
@Bean(name = "scarfEnabled") @Bean(name = "disablePixel")
public boolean scarfEnabled() { public boolean disablePixel() {
return applicationProperties.getSystem().isScarfEnabled(); return Boolean.parseBoolean(env.getProperty("DISABLE_PIXEL", "false"));
}
@Bean(name = "posthogEnabled")
public boolean posthogEnabled() {
return applicationProperties.getSystem().isPosthogEnabled();
} }
@Bean(name = "machineType") @Bean(name = "machineType")
@@ -0,0 +1,49 @@
package stirling.software.common.configuration;
/* Commented out entire FileFallbackTemplateResolver class - Thymeleaf dependency removed
* This class will be removed when frontend migration to React is complete
@Slf4j
public class FileFallbackTemplateResolver extends AbstractConfigurableTemplateResolver {
private final ResourceLoader resourceLoader;
public FileFallbackTemplateResolver(ResourceLoader resourceLoader) {
super();
this.resourceLoader = resourceLoader;
setSuffix(".html");
}
// Note this does not work in local IDE, Prod jar only.
@Override
protected ITemplateResource computeTemplateResource(
IEngineConfiguration configuration,
String ownerTemplate,
String template,
String resourceName,
String characterEncoding,
Map<String, Object> templateResolutionAttributes) {
Resource resource =
resourceLoader.getResource(
"file:" + InstallationPathConfig.getTemplatesPath() + resourceName);
try {
if (resource.exists() && resource.isReadable()) {
return new FileTemplateResource(resource.getFile().getPath(), characterEncoding);
}
} catch (IOException e) {
// Log the exception to help with debugging issues loading external templates
log.warn("Unable to read template '{}' from file system", resourceName, e);
}
InputStream inputStream =
Thread.currentThread()
.getContextClassLoader()
.getResourceAsStream("templates/" + resourceName);
if (inputStream != null) {
return new InputStreamTemplateResource(inputStream, "UTF-8");
}
return null;
}
}
*/
@@ -1,6 +1,7 @@
package stirling.software.common.configuration; package stirling.software.common.configuration;
import java.io.File; import java.io.File;
import java.nio.file.Paths;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -14,7 +15,6 @@ public class InstallationPathConfig {
private static final String CUSTOM_FILES_PATH; private static final String CUSTOM_FILES_PATH;
private static final String CLIENT_WEBUI_PATH; private static final String CLIENT_WEBUI_PATH;
private static final String PIPELINE_PATH; private static final String PIPELINE_PATH;
private static final String PLUGINS_PATH;
// Config paths // Config paths
private static final String SETTINGS_PATH; private static final String SETTINGS_PATH;
@@ -41,7 +41,6 @@ public class InstallationPathConfig {
CUSTOM_FILES_PATH = BASE_PATH + "customFiles" + File.separator; CUSTOM_FILES_PATH = BASE_PATH + "customFiles" + File.separator;
CLIENT_WEBUI_PATH = BASE_PATH + "clientWebUI" + File.separator; CLIENT_WEBUI_PATH = BASE_PATH + "clientWebUI" + File.separator;
PIPELINE_PATH = BASE_PATH + "pipeline" + File.separator; PIPELINE_PATH = BASE_PATH + "pipeline" + File.separator;
PLUGINS_PATH = CUSTOM_FILES_PATH + "plugins" + File.separator;
// Initialize config paths // Initialize config paths
SETTINGS_PATH = CONFIG_PATH + "settings.yml"; SETTINGS_PATH = CONFIG_PATH + "settings.yml";
@@ -61,6 +60,28 @@ public class InstallationPathConfig {
} }
private static String initializeBasePath() { private static String initializeBasePath() {
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_DESKTOP_UI", "false"))) {
String os = System.getProperty("os.name").toLowerCase();
if (os.contains("win")) {
return Paths.get(
System.getenv("APPDATA"), // parent path
"Stirling-PDF")
+ File.separator;
} else if (os.contains("mac")) {
return Paths.get(
System.getProperty("user.home"),
"Library",
"Application Support",
"Stirling-PDF")
+ File.separator;
} else {
return Paths.get(
System.getProperty("user.home"), // parent path
".config",
"Stirling-PDF")
+ File.separator;
}
}
return "." + File.separator; return "." + File.separator;
} }
@@ -112,10 +133,6 @@ public class InstallationPathConfig {
return SIGNATURES_PATH; return SIGNATURES_PATH;
} }
public static String getPluginsPath() {
return PLUGINS_PATH;
}
public static String getPrivateKeyPath() { public static String getPrivateKeyPath() {
return BACKUP_PRIVATE_KEY_PATH; return BACKUP_PRIVATE_KEY_PATH;
} }
@@ -1,14 +1,7 @@
package stirling.software.common.configuration; package stirling.software.common.configuration;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -17,12 +10,8 @@ import lombok.Getter;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.CustomPaths;
import stirling.software.common.model.ApplicationProperties.CustomPaths.Operations; import stirling.software.common.model.ApplicationProperties.CustomPaths.Operations;
import stirling.software.common.model.ApplicationProperties.CustomPaths.Pipeline; import stirling.software.common.model.ApplicationProperties.CustomPaths.Pipeline;
import stirling.software.common.model.ApplicationProperties.System;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.UnoServerPool;
@Slf4j @Slf4j
@Configuration @Configuration
@@ -30,22 +19,11 @@ import stirling.software.common.util.UnoServerPool;
public class RuntimePathConfig { public class RuntimePathConfig {
private final ApplicationProperties properties; private final ApplicationProperties properties;
private final String basePath; private final String basePath;
// Operation paths
private final String weasyPrintPath; private final String weasyPrintPath;
private final String unoConvertPath; private final String unoConvertPath;
private final String calibrePath;
private final String ocrMyPdfPath;
private final String sOfficePath;
// Tesseract data path
private final String tessDataPath;
private final List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> unoServerEndpoints;
// Pipeline paths // Pipeline paths
private final String pipelineWatchedFoldersPath; private final String pipelineWatchedFoldersPath;
private final List<String> pipelineWatchedFoldersPaths;
private final String pipelineFinishedFoldersPath; private final String pipelineFinishedFoldersPath;
private final String pipelineDefaultWebUiConfigs; private final String pipelineDefaultWebUiConfigs;
private final String pipelinePath; private final String pipelinePath;
@@ -54,27 +32,17 @@ public class RuntimePathConfig {
this.properties = properties; this.properties = properties;
this.basePath = InstallationPathConfig.getPath(); this.basePath = InstallationPathConfig.getPath();
System system = properties.getSystem(); this.pipelinePath = Path.of(basePath, "pipeline").toString();
CustomPaths customPaths = system.getCustomPaths();
Pipeline pipeline = customPaths.getPipeline();
this.pipelinePath =
resolvePath(
Path.of(basePath, "pipeline").toString(),
pipeline != null ? pipeline.getPipelineDir() : null);
String defaultWatchedFolders = Path.of(this.pipelinePath, "watchedFolders").toString(); String defaultWatchedFolders = Path.of(this.pipelinePath, "watchedFolders").toString();
String defaultFinishedFolders = Path.of(this.pipelinePath, "finishedFolders").toString(); String defaultFinishedFolders = Path.of(this.pipelinePath, "finishedFolders").toString();
String defaultWebUIConfigs = Path.of(this.pipelinePath, "defaultWebUIConfigs").toString(); String defaultWebUIConfigs = Path.of(this.pipelinePath, "defaultWebUIConfigs").toString();
List<String> watchedFoldersDirs = Pipeline pipeline = properties.getSystem().getCustomPaths().getPipeline();
sanitizePathList(pipeline != null ? pipeline.getWatchedFoldersDirs() : null);
this.pipelineWatchedFoldersPaths = this.pipelineWatchedFoldersPath =
resolveWatchedFolderPaths( resolvePath(
defaultWatchedFolders, defaultWatchedFolders,
watchedFoldersDirs,
pipeline != null ? pipeline.getWatchedFoldersDir() : null); pipeline != null ? pipeline.getWatchedFoldersDir() : null);
this.pipelineWatchedFoldersPath = this.pipelineWatchedFoldersPaths.get(0);
this.pipelineFinishedFoldersPath = this.pipelineFinishedFoldersPath =
resolvePath( resolvePath(
defaultFinishedFolders, defaultFinishedFolders,
@@ -84,19 +52,13 @@ public class RuntimePathConfig {
defaultWebUIConfigs, defaultWebUIConfigs,
pipeline != null ? pipeline.getWebUIConfigsDir() : null); pipeline != null ? pipeline.getWebUIConfigsDir() : null);
// Validate path conflicts after all paths are resolved
validatePipelinePaths();
boolean isDocker = isRunningInDocker(); boolean isDocker = isRunningInDocker();
// Initialize Operation paths // Initialize Operation paths
String defaultWeasyPrintPath = isDocker ? "/opt/venv/bin/weasyprint" : "weasyprint"; String defaultWeasyPrintPath = isDocker ? "/opt/venv/bin/weasyprint" : "weasyprint";
String defaultUnoConvertPath = isDocker ? "/opt/venv/bin/unoconvert" : "unoconvert"; String defaultUnoConvertPath = isDocker ? "/opt/venv/bin/unoconvert" : "unoconvert";
String defaultCalibrePath = isDocker ? "/opt/calibre/ebook-convert" : "ebook-convert";
String defaultOcrMyPdfPath = isDocker ? "/usr/bin/ocrmypdf" : "ocrmypdf";
String defaultSOfficePath = isDocker ? "/usr/bin/soffice" : "soffice";
Operations operations = customPaths.getOperations(); Operations operations = properties.getSystem().getCustomPaths().getOperations();
this.weasyPrintPath = this.weasyPrintPath =
resolvePath( resolvePath(
defaultWeasyPrintPath, defaultWeasyPrintPath,
@@ -105,245 +67,13 @@ public class RuntimePathConfig {
resolvePath( resolvePath(
defaultUnoConvertPath, defaultUnoConvertPath,
operations != null ? operations.getUnoconvert() : null); operations != null ? operations.getUnoconvert() : null);
this.calibrePath =
resolvePath(
defaultCalibrePath, operations != null ? operations.getCalibre() : null);
this.ocrMyPdfPath =
resolvePath(
defaultOcrMyPdfPath, operations != null ? operations.getOcrmypdf() : null);
this.sOfficePath =
resolvePath(
defaultSOfficePath, operations != null ? operations.getSoffice() : null);
// Initialize Tesseract data path
// Priority: config setting > TESSDATA_PREFIX env var > default path
String tessPath = system.getTessdataDir();
String tessdataPrefix = java.lang.System.getenv("TESSDATA_PREFIX");
String defaultPath = "/usr/share/tesseract-ocr/5/tessdata";
if (tessPath != null && !tessPath.isEmpty()) {
this.tessDataPath = tessPath;
} else if (tessdataPrefix != null && !tessdataPrefix.isEmpty()) {
this.tessDataPath = tessdataPrefix;
} else {
this.tessDataPath = defaultPath;
}
log.info("Using Tesseract data path: {}", this.tessDataPath);
ApplicationProperties.ProcessExecutor processExecutor = properties.getProcessExecutor();
int libreOfficeLimit = 1;
if (processExecutor != null && processExecutor.getSessionLimit() != null) {
libreOfficeLimit = processExecutor.getSessionLimit().getLibreOfficeSessionLimit();
}
this.unoServerEndpoints = buildUnoServerEndpoints(processExecutor, libreOfficeLimit);
ProcessExecutor.setUnoServerPool(new UnoServerPool(this.unoServerEndpoints));
} }
private String resolvePath(String defaultPath, String customPath) { private String resolvePath(String defaultPath, String customPath) {
return StringUtils.isNotBlank(customPath) ? customPath : defaultPath; return StringUtils.isNotBlank(customPath) ? customPath : defaultPath;
} }
private List<String> resolveWatchedFolderPaths(
String defaultPath, List<String> watchedFoldersDirs, String legacyWatchedFolder) {
List<String> rawPaths = new ArrayList<>();
// Collect paths from new config
if (watchedFoldersDirs != null && !watchedFoldersDirs.isEmpty()) {
rawPaths.addAll(watchedFoldersDirs);
}
// Fall back to legacy config
else if (StringUtils.isNotBlank(legacyWatchedFolder)) {
rawPaths.add(legacyWatchedFolder);
}
// Fall back to default
else {
rawPaths.add(defaultPath);
}
// Validate, normalize, and deduplicate paths
List<String> validatedPaths = validateAndNormalizePaths(rawPaths);
// Ensure we have at least one valid path (critical for system to function)
if (validatedPaths.isEmpty()) {
log.warn(
"No valid watched folder paths configured, falling back to default: {}",
defaultPath);
validatedPaths.add(defaultPath);
}
// Detect overlapping paths (warning only, not blocking)
detectOverlappingPaths(validatedPaths);
return validatedPaths;
}
private List<String> sanitizePathList(List<String> paths) {
if (paths == null || paths.isEmpty()) {
return Collections.emptyList();
}
List<String> sanitized = new ArrayList<>();
for (String path : paths) {
if (StringUtils.isNotBlank(path)) {
sanitized.add(path.trim());
}
}
return sanitized;
}
private List<String> validateAndNormalizePaths(List<String> paths) {
Set<String> normalizedPaths = new LinkedHashSet<>(); // Preserves order, prevents duplicates
for (String pathStr : paths) {
if (StringUtils.isBlank(pathStr)) {
continue;
}
try {
// Normalize to absolute path
Path path = Paths.get(pathStr.trim()).toAbsolutePath().normalize();
String normalizedPath = path.toString();
// Check for duplicates
if (normalizedPaths.contains(normalizedPath)) {
log.debug("Skipping duplicate watched folder path: {}", pathStr);
continue;
}
normalizedPaths.add(normalizedPath);
log.info("Registered watched folder path: {}", normalizedPath);
} catch (InvalidPathException e) {
log.error(
"Invalid watched folder path '{}' - skipping: {}", pathStr, e.getMessage());
}
}
return new ArrayList<>(normalizedPaths);
}
private void detectOverlappingPaths(List<String> paths) {
for (int i = 0; i < paths.size(); i++) {
Path path1 = Paths.get(paths.get(i));
for (int j = i + 1; j < paths.size(); j++) {
Path path2 = Paths.get(paths.get(j));
// Check if one path is a parent of the other
if (path1.startsWith(path2)) {
log.warn(
"Watched folder path '{}' is nested inside '{}' - this may cause duplicate processing",
path1,
path2);
} else if (path2.startsWith(path1)) {
log.warn(
"Watched folder path '{}' is nested inside '{}' - this may cause duplicate processing",
path2,
path1);
}
}
}
}
private void validatePipelinePaths() {
try {
Path finishedPath = Paths.get(pipelineFinishedFoldersPath).toAbsolutePath().normalize();
for (String watchedPathStr : pipelineWatchedFoldersPaths) {
Path watchedPath = Paths.get(watchedPathStr).toAbsolutePath().normalize();
// Check if watched folder is same as finished folder
if (watchedPath.equals(finishedPath)) {
log.error(
"CRITICAL: Watched folder '{}' is the same as finished folder '{}' - this will cause processing loops!",
watchedPath,
finishedPath);
}
// Check if watched folder contains finished folder
else if (finishedPath.startsWith(watchedPath)) {
log.warn(
"Finished folder '{}' is nested inside watched folder '{}' - this may cause issues",
finishedPath,
watchedPath);
}
// Check if finished folder contains watched folder
else if (watchedPath.startsWith(finishedPath)) {
log.error(
"CRITICAL: Watched folder '{}' is nested inside finished folder '{}' - this will cause processing loops!",
watchedPath,
finishedPath);
}
}
} catch (Exception e) {
log.error("Error validating pipeline paths: {}", e.getMessage());
}
}
private boolean isRunningInDocker() { private boolean isRunningInDocker() {
return Files.exists(Path.of("/.dockerenv")); return Files.exists(Path.of("/.dockerenv"));
} }
private List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> buildUnoServerEndpoints(
ApplicationProperties.ProcessExecutor processExecutor, int sessionLimit) {
if (processExecutor == null) {
log.warn("ProcessExecutor config missing; defaulting to a single UNO endpoint.");
return Collections.singletonList(
new ApplicationProperties.ProcessExecutor.UnoServerEndpoint());
}
if (!processExecutor.isAutoUnoServer()) {
List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> configured =
sanitizeUnoServerEndpoints(processExecutor.getUnoServerEndpoints());
if (!configured.isEmpty()) {
// Warn if manual endpoint count doesn't match sessionLimit
if (configured.size() != sessionLimit) {
log.warn(
"Manual UNO endpoint count ({}) differs from libreOfficeSessionLimit ({}). "
+ "Concurrency will be limited by endpoint count, not sessionLimit.",
configured.size(),
sessionLimit);
}
return configured;
}
log.warn(
"autoUnoServer disabled but no unoServerEndpoints configured; defaulting to 127.0.0.1:2003.");
return Collections.singletonList(
new ApplicationProperties.ProcessExecutor.UnoServerEndpoint());
}
int count = sessionLimit > 0 ? sessionLimit : 1;
return buildAutoUnoServerEndpoints(count);
}
private List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint>
buildAutoUnoServerEndpoints(int count) {
List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> endpoints = new ArrayList<>();
int basePort = 2003;
for (int i = 0; i < count; i++) {
ApplicationProperties.ProcessExecutor.UnoServerEndpoint endpoint =
new ApplicationProperties.ProcessExecutor.UnoServerEndpoint();
endpoint.setHost("127.0.0.1");
endpoint.setPort(basePort + (i * 2));
endpoints.add(endpoint);
}
return endpoints;
}
private List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint>
sanitizeUnoServerEndpoints(
List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> endpoints) {
if (endpoints == null || endpoints.isEmpty()) {
return Collections.emptyList();
}
List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> sanitized = new ArrayList<>();
for (ApplicationProperties.ProcessExecutor.UnoServerEndpoint endpoint : endpoints) {
if (endpoint == null) {
continue;
}
String host = endpoint.getHost();
int port = endpoint.getPort();
if (host == null || host.isBlank() || port <= 0) {
continue;
}
sanitized.add(endpoint);
}
return sanitized;
}
} }
@@ -1,49 +0,0 @@
package stirling.software.common.constants;
/**
* Centralized constants for JWT token management.
*
* <p>These defaults are used when configuration values are not explicitly set.
*/
public final class JwtConstants {
private JwtConstants() {
throw new UnsupportedOperationException("Utility class");
}
/** Default JWT access token lifetime in minutes (24 hours). */
public static final int DEFAULT_TOKEN_EXPIRY_MINUTES = 1440;
/** Default desktop client token lifetime in minutes (30 days). */
public static final int DEFAULT_DESKTOP_TOKEN_EXPIRY_MINUTES = 43200;
/**
* Default refresh grace period in minutes.
*
* <p>Allows refresh of expired tokens within this window after expiration.
*/
public static final int DEFAULT_REFRESH_GRACE_MINUTES = 15;
/**
* Default allowed clock skew in seconds.
*
* <p>Tolerates small time drift between client and server clocks during validation.
*/
public static final int DEFAULT_CLOCK_SKEW_SECONDS = 60;
/** Milliseconds per minute. */
public static final long MILLIS_PER_MINUTE = 60_000L;
/** Seconds per minute. */
public static final long SECONDS_PER_MINUTE = 60L;
/** JWT issuer identifier. */
public static final String ISSUER = "https://stirling.com";
/**
* Maximum refresh attempts allowed within the grace period window.
*
* <p>Prevents abuse of expired tokens by limiting refresh attempts.
*/
public static final int MAX_REFRESH_ATTEMPTS_IN_GRACE = 3;
}
@@ -12,7 +12,6 @@ import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Locale;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
@@ -29,8 +28,6 @@ import org.springframework.stereotype.Component;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.annotation.PostConstruct;
import lombok.Data; import lombok.Data;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
@@ -39,7 +36,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.YamlPropertySourceFactory; import stirling.software.common.configuration.YamlPropertySourceFactory;
import stirling.software.common.constants.JwtConstants;
import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.common.model.oauth2.GitHubProvider; import stirling.software.common.model.oauth2.GitHubProvider;
import stirling.software.common.model.oauth2.GoogleProvider; import stirling.software.common.model.oauth2.GoogleProvider;
@@ -64,7 +60,6 @@ public class ApplicationProperties {
private AutomaticallyGenerated automaticallyGenerated = new AutomaticallyGenerated(); private AutomaticallyGenerated automaticallyGenerated = new AutomaticallyGenerated();
private Mail mail = new Mail(); private Mail mail = new Mail();
private Telegram telegram = new Telegram();
private Premium premium = new Premium(); private Premium premium = new Premium();
@@ -73,7 +68,6 @@ public class ApplicationProperties {
private AutoPipeline autoPipeline = new AutoPipeline(); private AutoPipeline autoPipeline = new AutoPipeline();
private ProcessExecutor processExecutor = new ProcessExecutor(); private ProcessExecutor processExecutor = new ProcessExecutor();
private PdfEditor pdfEditor = new PdfEditor();
@Bean @Bean
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment) public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
@@ -101,97 +95,11 @@ public class ApplicationProperties {
return propertySource; return propertySource;
} }
/**
* Initialize fileUploadLimit from environment variables if not set in settings.yml. Supports
* SYSTEMFILEUPLOADLIMIT (format: "100MB") and SYSTEM_MAXFILESIZE (format: "100" in MB).
*/
@PostConstruct
public void initializeFileUploadLimitFromEnv() {
// Only override if fileUploadLimit is not already set in settings.yml
if (system.getFileUploadLimit() == null || system.getFileUploadLimit().isEmpty()) {
String fileUploadLimit = null;
// Check SYSTEMFILEUPLOADLIMIT first (format: "100MB", "1GB", etc.)
String systemFileUploadLimit = java.lang.System.getenv("SYSTEMFILEUPLOADLIMIT");
if (systemFileUploadLimit != null && !systemFileUploadLimit.trim().isEmpty()) {
fileUploadLimit = systemFileUploadLimit.trim();
log.info("Setting fileUploadLimit from SYSTEMFILEUPLOADLIMIT: {}", fileUploadLimit);
} else {
// Check SYSTEM_MAXFILESIZE (format: number in MB, e.g., "100")
String systemMaxFileSize = java.lang.System.getenv("SYSTEM_MAXFILESIZE");
if (systemMaxFileSize != null && !systemMaxFileSize.trim().isEmpty()) {
try {
// Validate it's a number
long sizeInMB = Long.parseLong(systemMaxFileSize.trim());
if (sizeInMB > 0 && sizeInMB <= 999) {
fileUploadLimit = sizeInMB + "MB";
log.info(
"Setting fileUploadLimit from SYSTEM_MAXFILESIZE: {}MB",
sizeInMB);
} else {
log.warn(
"SYSTEM_MAXFILESIZE value {} is out of valid range (1-999), ignoring",
sizeInMB);
}
} catch (NumberFormatException e) {
log.warn(
"SYSTEM_MAXFILESIZE value '{}' is not a valid number, ignoring",
systemMaxFileSize);
}
}
}
if (fileUploadLimit != null) {
system.setFileUploadLimit(fileUploadLimit);
}
}
}
@Data @Data
public static class AutoPipeline { public static class AutoPipeline {
private String outputFolder; private String outputFolder;
} }
@Data
public static class PdfEditor {
private Cache cache = new Cache();
private FontNormalization fontNormalization = new FontNormalization();
private CffConverter cffConverter = new CffConverter();
private Type3 type3 = new Type3();
private String fallbackFont = "classpath:/static/fonts/NotoSans-Regular.ttf";
@Data
public static class Cache {
private long maxBytes = -1;
private int maxPercent = 20;
}
@Data
public static class FontNormalization {
private boolean enabled = false;
}
@Data
public static class CffConverter {
private boolean enabled = true;
private String method = "python";
private String pythonCommand = "/opt/venv/bin/python3";
private String pythonScript = "/scripts/convert_cff_to_ttf.py";
private String fontforgeCommand = "fontforge";
}
@Data
public static class Type3 {
private Library library = new Library();
@Data
public static class Library {
private boolean enabled = true;
private String index = "classpath:/type3/library/index.json";
}
}
}
@Data @Data
public static class Legal { public static class Legal {
private String termsAndConditions; private String termsAndConditions;
@@ -203,7 +111,8 @@ public class ApplicationProperties {
@Data @Data
public static class Security { public static class Security {
private boolean enableLogin; private Boolean enableLogin;
private Boolean csrfDisabled;
private InitialLogin initialLogin = new InitialLogin(); private InitialLogin initialLogin = new InitialLogin();
private OAUTH2 oauth2 = new OAUTH2(); private OAUTH2 oauth2 = new OAUTH2();
private SAML2 saml2 = new SAML2(); private SAML2 saml2 = new SAML2();
@@ -213,7 +122,6 @@ public class ApplicationProperties {
private String customGlobalAPIKey; private String customGlobalAPIKey;
private Jwt jwt = new Jwt(); private Jwt jwt = new Jwt();
private Validation validation = new Validation(); private Validation validation = new Validation();
private String xFrameOptions = "DENY";
public Boolean isAltLogin() { public Boolean isAltLogin() {
return saml2.getEnabled() || oauth2.getEnabled(); return saml2.getEnabled() || oauth2.getEnabled();
@@ -379,7 +287,7 @@ public class ApplicationProperties {
private KeycloakProvider keycloak = new KeycloakProvider(); private KeycloakProvider keycloak = new KeycloakProvider();
public Provider get(String registrationId) throws UnsupportedProviderException { public Provider get(String registrationId) throws UnsupportedProviderException {
return switch (registrationId.toLowerCase(Locale.ROOT)) { return switch (registrationId.toLowerCase()) {
case "google" -> getGoogle(); case "google" -> getGoogle();
case "github" -> getGithub(); case "github" -> getGithub();
case "keycloak" -> getKeycloak(); case "keycloak" -> getKeycloak();
@@ -387,114 +295,19 @@ public class ApplicationProperties {
throw new UnsupportedProviderException( throw new UnsupportedProviderException(
"Logout from the provider " "Logout from the provider "
+ registrationId + registrationId
+ " is not supported. Report it at" + " is not supported. "
+ " https://github.com/Stirling-Tools/Stirling-PDF/issues"); + "Report it at https://github.com/Stirling-Tools/Stirling-PDF/issues");
}; };
} }
} }
} }
/**
* JWT token configuration.
*
* <p><b>BREAKING CHANGE (v2.0):</b> Default token expiry increased from 12 hours (720
* minutes) to 24 hours (1440 minutes). If you require the previous behavior, explicitly set
* {@code tokenExpiryMinutes: 720} in your configuration.
*/
@Data @Data
public static class Jwt { public static class Jwt {
private boolean enableKeystore = true; private boolean enableKeystore = true;
private boolean enableKeyRotation = false; private boolean enableKeyRotation = false;
private boolean enableKeyCleanup = true; private boolean enableKeyCleanup = true;
private int keyRetentionDays = 7;
/**
* JWT access token lifetime in minutes for web clients.
*
* <p>Default: {@value JwtConstants#DEFAULT_TOKEN_EXPIRY_MINUTES} minutes (24 hours).
*
* <p><b>BREAKING CHANGE:</b> Previously hardcoded to 720 minutes (12 hours). Now
* defaults to 1440 minutes (24 hours).
*/
private int tokenExpiryMinutes = JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
/**
* JWT access token lifetime in minutes for desktop clients (Tauri app).
*
* <p>Desktop clients are automatically detected via User-Agent header and receive
* longer-lived tokens because they run on personal devices with OS-level encrypted
* storage (macOS Keychain, Windows Credential Manager, Linux Secret Service).
*
* <p>This provides better UX (login once per month) while maintaining security through
* device encryption and secure storage, matching the behavior of popular desktop apps
* like Slack, Discord, VS Code, etc.
*
* <p>Default: 43200 minutes (30 days).
*/
private int desktopTokenExpiryMinutes = 43200;
/**
* Allowed clock skew in seconds for JWT validation.
*
* <p>Tolerates small time drift between client and server clocks. Tokens that are
* slightly expired or slightly in the future (within this window) will still be
* accepted.
*
* <p>Default: {@value JwtConstants#DEFAULT_CLOCK_SKEW_SECONDS} seconds.
*/
private int allowedClockSkewSeconds = JwtConstants.DEFAULT_CLOCK_SKEW_SECONDS;
/**
* Grace period in minutes for refreshing expired tokens.
*
* <p>Allows token refresh using an expired access token if the token expired within
* this many minutes. This provides better UX by allowing users to refresh slightly
* expired tokens without re-authentication.
*
* <p>Rate limiting is applied to prevent abuse of expired tokens within the grace
* window (max {@value JwtConstants#MAX_REFRESH_ATTEMPTS_IN_GRACE} attempts).
*
* <p>Default: {@value JwtConstants#DEFAULT_REFRESH_GRACE_MINUTES} minutes.
*/
private int refreshGraceMinutes = JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES;
/**
* Calculate number of days to retain old JWT signing keys.
*
* <p>Automatically calculated based on the longest token lifetime plus a proportional
* safety buffer. Keys must be retained for at least as long as the tokens they signed
* remain valid, otherwise token verification will fail.
*
* <p>Formula: ceil((maxTokenExpiry + 10% buffer + refreshGrace + clockSkew) / 1440)
*
* <p>The buffer includes:
*
* <ul>
* <li>10% of token lifetime (scales with token duration)
* <li>Token refresh grace period ({@link #refreshGraceMinutes})
* <li>Clock skew tolerance ({@link #allowedClockSkewSeconds} converted to minutes)
* </ul>
*
* @return calculated key retention period in days
*/
public int getKeyRetentionDays() {
final int MINUTES_PER_DAY = 1440;
final double BUFFER_PERCENTAGE = 0.10; // 10% buffer
int maxTokenExpiryMinutes = Math.max(tokenExpiryMinutes, desktopTokenExpiryMinutes);
// Add 10% buffer (scales with token lifetime)
int bufferMinutes = (int) Math.ceil(maxTokenExpiryMinutes * BUFFER_PERCENTAGE);
// Add refresh grace period
bufferMinutes += refreshGraceMinutes;
// Add clock skew (convert seconds to minutes, round up)
bufferMinutes += (int) Math.ceil(allowedClockSkewSeconds / 60.0);
// Total retention in minutes, convert to days (round up)
int totalMinutes = maxTokenExpiryMinutes + bufferMinutes;
return (int) Math.ceil(totalMinutes / (double) MINUTES_PER_DAY);
}
} }
@Data @Data
@@ -536,43 +349,29 @@ public class ApplicationProperties {
@Data @Data
public static class System { public static class System {
private String defaultLocale; private String defaultLocale;
private boolean googlevisibility; private Boolean googlevisibility;
private boolean showUpdate; private boolean showUpdate;
private boolean showUpdateOnlyAdmin; private Boolean showUpdateOnlyAdmin;
private boolean showSettingsWhenNoLogin = true;
private boolean customHTMLFiles; private boolean customHTMLFiles;
private String tessdataDir; private String tessdataDir;
private boolean enableAlphaFunctionality; private Boolean enableAlphaFunctionality;
private Boolean enableAnalytics; private Boolean enableAnalytics;
private Boolean enablePosthog; private Boolean enablePosthog;
private Boolean enableScarf; private Boolean enableScarf;
private Boolean enableDesktopInstallSlide;
private Datasource datasource; private Datasource datasource;
private boolean disableSanitize; private Boolean disableSanitize;
private int maxDPI; private int maxDPI;
private boolean enableUrlToPDF; private Boolean enableUrlToPDF;
private Html html = new Html(); private Html html = new Html();
private CustomPaths customPaths = new CustomPaths(); private CustomPaths customPaths = new CustomPaths();
private String fileUploadLimit; private String fileUploadLimit;
private TempFileManagement tempFileManagement = new TempFileManagement(); private TempFileManagement tempFileManagement = new TempFileManagement();
private DatabaseBackup databaseBackup = new DatabaseBackup(); private DatabaseBackup databaseBackup = new DatabaseBackup();
private List<String> corsAllowedOrigins = new ArrayList<>(); private List<String> corsAllowedOrigins = new ArrayList<>();
private String backendUrl; // Backend base URL for SAML/OAuth/API callbacks (e.g. private String
// 'http://localhost:8080', 'https://api.example.com'). Required for frontendUrl; // Base URL for frontend (used for invite links, etc.). If not set,
// SSO.
private String frontendUrl; // Frontend URL for invite email links (e.g.
// 'https://app.example.com'). If not set, falls back to backendUrl. // falls back to backend URL.
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() { public boolean isAnalyticsEnabled() {
return this.getEnableAnalytics() != null && this.getEnableAnalytics(); return this.getEnableAnalytics() != null && this.getEnableAnalytics();
@@ -603,9 +402,7 @@ public class ApplicationProperties {
@Data @Data
public static class Pipeline { public static class Pipeline {
private String pipelineDir;
private String watchedFoldersDir; private String watchedFoldersDir;
private List<String> watchedFoldersDirs = new ArrayList<>();
private String finishedFoldersDir; private String finishedFoldersDir;
private String webUIConfigsDir; private String webUIConfigsDir;
} }
@@ -614,9 +411,6 @@ public class ApplicationProperties {
public static class Operations { public static class Operations {
private String weasyprint; private String weasyprint;
private String unoconvert; private String unoconvert;
private String calibre;
private String ocrmypdf;
private String soffice;
} }
} }
@@ -699,10 +493,10 @@ public class ApplicationProperties {
@Override @Override
public String toString() { public String toString() {
return """ return """
Driver { Driver {
driverName='%s' driverName='%s'
} }
""" """
.formatted(driverName); .formatted(driverName);
} }
} }
@@ -712,8 +506,6 @@ public class ApplicationProperties {
private String appNameNavbar; private String appNameNavbar;
private List<String> languages; private List<String> languages;
private String logoStyle = "classic"; // Options: "classic" (default) or "modern" private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
private boolean defaultHideUnavailableTools = false;
private boolean defaultHideUnavailableConversions = false;
public String getAppNameNavbar() { public String getAppNameNavbar() {
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null; return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
@@ -736,7 +528,7 @@ public class ApplicationProperties {
@Data @Data
public static class Metrics { public static class Metrics {
private boolean enabled; private Boolean enabled;
} }
@Data @Data
@@ -744,7 +536,6 @@ public class ApplicationProperties {
@ToString.Exclude private String key; @ToString.Exclude private String key;
private String UUID; private String UUID;
private String appVersion; private String appVersion;
private Boolean isNewServer;
} }
// TODO: Remove post migration // TODO: Remove post migration
@@ -757,7 +548,6 @@ public class ApplicationProperties {
private boolean ssoAutoLogin; private boolean ssoAutoLogin;
private CustomMetadata customMetadata = new CustomMetadata(); private CustomMetadata customMetadata = new CustomMetadata();
@Deprecated
@Data @Data
public static class CustomMetadata { public static class CustomMetadata {
private boolean autoUpdateMetadata; private boolean autoUpdateMetadata;
@@ -765,23 +555,16 @@ public class ApplicationProperties {
private String creator; private String creator;
private String producer; private String producer;
@Deprecated
public String getCreator() { public String getCreator() {
return creator == null || creator.trim().isEmpty() ? "Stirling-PDF" : creator; return creator == null || creator.trim().isEmpty() ? "Stirling-PDF" : creator;
} }
@Deprecated
public String getProducer() { public String getProducer() {
return producer == null || producer.trim().isEmpty() ? "Stirling-PDF" : producer; return producer == null || producer.trim().isEmpty() ? "Stirling-PDF" : producer;
} }
} }
} }
/**
* Mail server configuration properties.
*
* @since 0.46.1
*/
@Data @Data
public static class Mail { public static class Mail {
private boolean enabled; private boolean enabled;
@@ -792,112 +575,6 @@ public class ApplicationProperties {
private String username; private String username;
@ToString.Exclude private String password; @ToString.Exclude private String password;
private String from; private String from;
// STARTTLS upgrades a plain SMTP connection to TLS after connecting (RFC 3207)
private Boolean startTlsEnable = true;
private Boolean startTlsRequired;
// SSL/TLS wrapper for implicit TLS (typically port 465)
private Boolean sslEnable;
// Hostnames or patterns (e.g., "smtp.example.com" or "*") to trust for TLS certificates;
// defaults to "*" (trust all) when not set
private String sslTrust;
// Enables hostname verification for TLS connections
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 @Data
@@ -937,25 +614,6 @@ public class ApplicationProperties {
public static class EnterpriseFeatures { public static class EnterpriseFeatures {
private PersistentMetrics persistentMetrics = new PersistentMetrics(); private PersistentMetrics persistentMetrics = new PersistentMetrics();
private Audit audit = new Audit(); private Audit audit = new Audit();
private DatabaseNotifications databaseNotifications = new DatabaseNotifications();
@Data
public static class DatabaseNotifications {
private Backup backups = new Backup();
private Imports imports = new Imports();
@Data
public static class Backup {
private boolean successful = false;
private boolean failed = false;
}
@Data
public static class Imports {
private boolean successful = false;
private boolean failed = false;
}
}
@Data @Data
public static class Audit { public static class Audit {
@@ -976,16 +634,6 @@ public class ApplicationProperties {
public static class ProcessExecutor { public static class ProcessExecutor {
private SessionLimit sessionLimit = new SessionLimit(); private SessionLimit sessionLimit = new SessionLimit();
private TimeoutMinutes timeoutMinutes = new TimeoutMinutes(); private TimeoutMinutes timeoutMinutes = new TimeoutMinutes();
private boolean autoUnoServer = true;
private List<UnoServerEndpoint> unoServerEndpoints = new ArrayList<>();
@Data
public static class UnoServerEndpoint {
private String host = "127.0.0.1";
private int port = 2003;
private String hostLocation = "auto"; // auto|local|remote
private String protocol = "http"; // http|https
}
@Data @Data
public static class SessionLimit { public static class SessionLimit {
@@ -995,12 +643,10 @@ public class ApplicationProperties {
private int weasyPrintSessionLimit; private int weasyPrintSessionLimit;
private int installAppSessionLimit; private int installAppSessionLimit;
private int calibreSessionLimit; private int calibreSessionLimit;
private int imageMagickSessionLimit;
private int qpdfSessionLimit; private int qpdfSessionLimit;
private int tesseractSessionLimit; private int tesseractSessionLimit;
private int ghostscriptSessionLimit; private int ghostscriptSessionLimit;
private int ocrMyPdfSessionLimit; private int ocrMyPdfSessionLimit;
private int ffmpegSessionLimit;
public int getQpdfSessionLimit() { public int getQpdfSessionLimit() {
return qpdfSessionLimit > 0 ? qpdfSessionLimit : 2; return qpdfSessionLimit > 0 ? qpdfSessionLimit : 2;
@@ -1034,10 +680,6 @@ public class ApplicationProperties {
return calibreSessionLimit > 0 ? calibreSessionLimit : 1; return calibreSessionLimit > 0 ? calibreSessionLimit : 1;
} }
public int getImageMagickSessionLimit() {
return imageMagickSessionLimit > 0 ? imageMagickSessionLimit : 4;
}
public int getGhostscriptSessionLimit() { public int getGhostscriptSessionLimit() {
return ghostscriptSessionLimit > 0 ? ghostscriptSessionLimit : 8; return ghostscriptSessionLimit > 0 ? ghostscriptSessionLimit : 8;
} }
@@ -1045,10 +687,6 @@ public class ApplicationProperties {
public int getOcrMyPdfSessionLimit() { public int getOcrMyPdfSessionLimit() {
return ocrMyPdfSessionLimit > 0 ? ocrMyPdfSessionLimit : 2; return ocrMyPdfSessionLimit > 0 ? ocrMyPdfSessionLimit : 2;
} }
public int getFfmpegSessionLimit() {
return ffmpegSessionLimit > 0 ? ffmpegSessionLimit : 2;
}
} }
@Data @Data
@@ -1071,13 +709,10 @@ public class ApplicationProperties {
@JsonProperty("calibretimeoutMinutes") @JsonProperty("calibretimeoutMinutes")
private long calibreTimeoutMinutes; private long calibreTimeoutMinutes;
private long imageMagickTimeoutMinutes;
private long tesseractTimeoutMinutes; private long tesseractTimeoutMinutes;
private long qpdfTimeoutMinutes; private long qpdfTimeoutMinutes;
private long ghostscriptTimeoutMinutes; private long ghostscriptTimeoutMinutes;
private long ocrMyPdfTimeoutMinutes; private long ocrMyPdfTimeoutMinutes;
private long ffmpegTimeoutMinutes;
public long getTesseractTimeoutMinutes() { public long getTesseractTimeoutMinutes() {
return tesseractTimeoutMinutes > 0 ? tesseractTimeoutMinutes : 30; return tesseractTimeoutMinutes > 0 ? tesseractTimeoutMinutes : 30;
@@ -1111,10 +746,6 @@ public class ApplicationProperties {
return calibreTimeoutMinutes > 0 ? calibreTimeoutMinutes : 30; return calibreTimeoutMinutes > 0 ? calibreTimeoutMinutes : 30;
} }
public long getImageMagickTimeoutMinutes() {
return imageMagickTimeoutMinutes > 0 ? imageMagickTimeoutMinutes : 30;
}
public long getGhostscriptTimeoutMinutes() { public long getGhostscriptTimeoutMinutes() {
return ghostscriptTimeoutMinutes > 0 ? ghostscriptTimeoutMinutes : 30; return ghostscriptTimeoutMinutes > 0 ? ghostscriptTimeoutMinutes : 30;
} }
@@ -1122,10 +753,6 @@ public class ApplicationProperties {
public long getOcrMyPdfTimeoutMinutes() { public long getOcrMyPdfTimeoutMinutes() {
return ocrMyPdfTimeoutMinutes > 0 ? ocrMyPdfTimeoutMinutes : 30; return ocrMyPdfTimeoutMinutes > 0 ? ocrMyPdfTimeoutMinutes : 30;
} }
public long getFfmpegTimeoutMinutes() {
return ffmpegTimeoutMinutes > 0 ? ffmpegTimeoutMinutes : 30;
}
} }
} }
} }
@@ -30,11 +30,11 @@ public class FileInfo {
// Formats the file size into a human-readable string. // Formats the file size into a human-readable string.
public String getFormattedFileSize() { public String getFormattedFileSize() {
if (fileSize >= 1024 * 1024 * 1024) { if (fileSize >= 1024 * 1024 * 1024) {
return String.format(Locale.ROOT, "%.2f GB", fileSize / (1024.0 * 1024 * 1024)); return String.format(Locale.US, "%.2f GB", fileSize / (1024.0 * 1024 * 1024));
} else if (fileSize >= 1024 * 1024) { } else if (fileSize >= 1024 * 1024) {
return String.format(Locale.ROOT, "%.2f MB", fileSize / (1024.0 * 1024)); return String.format(Locale.US, "%.2f MB", fileSize / (1024.0 * 1024));
} else if (fileSize >= 1024) { } else if (fileSize >= 1024) {
return String.format(Locale.ROOT, "%.2f KB", fileSize / 1024.0); return String.format(Locale.US, "%.2f KB", fileSize / 1024.0);
} else { } else {
return String.format("%d Bytes", fileSize); return String.format("%d Bytes", fileSize);
} }
@@ -1,98 +0,0 @@
package stirling.software.common.model;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** Form field information with coordinates for interactive form viewer. */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(description = "Form field with coordinates and metadata")
public class FormFieldWithCoordinates {
@Schema(description = "Fully qualified field name", example = "form1.firstName")
private String name;
@Schema(description = "Display label for the field", example = "First Name")
private String label;
@Schema(description = "Field type: text, checkbox, radio, combobox, listbox, button, signature")
private String type;
@Schema(description = "Current field value")
private String value;
@Schema(
description =
"Available options (export values) for choice fields"
+ " (dropdown, radio, listbox)")
private List<String> options;
@Schema(
description =
"Human-readable display labels for choice field options,"
+ " parallel to the 'options' list. Null when identical to options.")
private List<String> displayOptions;
@Schema(description = "Whether the field is required")
private boolean required;
@Schema(description = "Whether the field is read-only")
private boolean readOnly;
@Schema(description = "Whether this is a multi-select list box")
private boolean multiSelect;
@Schema(description = "Whether this is a multi-line text field")
private boolean multiline;
@Schema(description = "Tooltip/alternate name for the field")
private String tooltip;
@Schema(description = "Widget coordinates on each page (fields can have multiple widgets)")
private List<WidgetCoordinates> widgets;
/**
* Coordinates for a single widget annotation (visual representation of the field). A field can
* have multiple widgets if it appears on multiple pages.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(description = "Widget coordinates in PDF space")
public static class WidgetCoordinates {
@Schema(description = "Page index (0-based)", example = "0")
private int pageIndex;
@Schema(description = "X coordinate in PDF points (lower-left origin)")
private float x;
@Schema(description = "Y coordinate in PDF points (lower-left origin)")
private float y;
@Schema(description = "Width in PDF points")
private float width;
@Schema(description = "Height in PDF points")
private float height;
@Schema(description = "Export value for this widget (radio/checkbox buttons only)")
private String exportValue;
@Schema(description = "Font size in PDF points")
private Float fontSize;
}
}
@@ -0,0 +1,40 @@
package stirling.software.common.model;
/* Commented out entire InputStreamTemplateResource class - Thymeleaf dependency removed
* This class will be removed when frontend migration to React is complete
@RequiredArgsConstructor
@Getter
public class InputStreamTemplateResource implements ITemplateResource {
private final InputStream inputStream;
private final String characterEncoding;
@Override
public Reader reader() throws IOException {
return new InputStreamReader(inputStream, characterEncoding);
}
@Override
public ITemplateResource relative(String relativeLocation) {
// Implement logic for relative resources, if needed
throw new UnsupportedOperationException("Relative resources not supported");
}
@Override
public String getDescription() {
return "InputStream resource [Stream]";
}
@Override
public String getBaseName() {
return "streamResource";
}
@Override
public boolean exists() {
return inputStream != null;
}
}
*/
@@ -1,34 +0,0 @@
package stirling.software.common.plugins;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Value;
/** Immutable descriptor that represents a loaded plugin. */
@Value
@Builder
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public class PluginDescriptor {
String id;
String icon;
String name;
String description;
String version;
String author;
String frontendLabel;
String frontendPath;
String iconPath;
String minHostVersion;
String jarCreatedAt;
@Builder.Default boolean hasFrontend = false;
@Builder.Default List<String> backendEndpoints = Collections.emptyList();
@Builder.Default Map<String, String> metadata = Collections.emptyMap();
}
@@ -1,65 +0,0 @@
package stirling.software.common.plugins;
import java.util.List;
import java.util.Map;
import lombok.Builder;
import lombok.Value;
@Value
@Builder
/**
* API-facing representation of a plugin descriptor with a fully resolved frontend URL.
*
* <p>This DTO is returned to clients so they can render plugin metadata and open plugin UIs.
*/
public class PluginDescriptorResponse {
String id;
String icon;
String name;
String description;
String version;
String author;
String frontendUrl;
String frontendLabel;
String iconPath;
String minHostVersion;
String jarCreatedAt;
boolean hasFrontend;
List<String> backendEndpoints;
Map<String, String> metadata;
/**
* Creates a response object from an internal {@link PluginDescriptor}.
*
* @param descriptor loaded plugin descriptor
* @param baseUrl optional API base URL used to build an absolute frontend URL
* @return normalized response payload for API clients
*/
public static PluginDescriptorResponse from(PluginDescriptor descriptor, String baseUrl) {
String frontendPath = descriptor.getFrontendPath();
String normalizedBase = baseUrl != null ? baseUrl.replaceAll("/+$", "") : "";
String normalizedPath = frontendPath != null ? frontendPath.replaceAll("^/+", "/") : "";
String frontendUrl =
(normalizedBase.isEmpty() || normalizedPath.isEmpty())
? (normalizedPath.isEmpty() ? null : normalizedPath)
: normalizedBase + normalizedPath;
return PluginDescriptorResponse.builder()
.id(descriptor.getId())
.icon(descriptor.getIcon())
.name(descriptor.getName())
.description(descriptor.getDescription())
.version(descriptor.getVersion())
.author(descriptor.getAuthor())
.frontendUrl(frontendUrl)
.frontendLabel(descriptor.getFrontendLabel())
.iconPath(descriptor.getIconPath())
.hasFrontend(descriptor.isHasFrontend())
.backendEndpoints(descriptor.getBackendEndpoints())
.metadata(descriptor.getMetadata())
.minHostVersion(descriptor.getMinHostVersion())
.jarCreatedAt(descriptor.getJarCreatedAt())
.build();
}
}
@@ -1,261 +0,0 @@
package stirling.software.common.plugins;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
/**
* Utility responsible for discovering plugin jars, parsing their metadata, and integrating them
* into the Stirling-PDF runtime.
*/
@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class PluginLoader {
private static final String JAR_EXTENSION = ".jar";
private static final String JAR_MIME_TYPE = "application/java-archive";
private static final String METADATA_RESOURCE = "META-INF/stirling-plugin.json";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/**
* Discovers plugin jar files in the configured plugins directory.
*
* @return sorted list of valid plugin jar paths
*/
public static List<Path> listPluginJars() {
Path pluginDir = ensurePluginDirectory();
if (!Files.isDirectory(pluginDir)) {
return Collections.emptyList();
}
try (Stream<Path> stream = Files.list(pluginDir)) {
return stream.filter(Files::isRegularFile)
.filter(PluginLoader::looksLikeJarFile)
.filter(PluginLoader::isReadableJarArchive)
.sorted(
Comparator.comparing(
path -> path.getFileName().toString().toLowerCase()))
.collect(Collectors.toList());
} catch (IOException e) {
log.warn("Failed to list plugin directory {}: {}", pluginDir, e.getMessage());
return Collections.emptyList();
}
}
/**
* Converts discovered plugin jar paths into URL entries suitable for class/resource loading.
*
* @return immutable-style list of valid jar URLs
*/
public static List<URL> pluginJarUrls() {
List<Path> jars = listPluginJars();
if (jars.isEmpty()) {
return Collections.emptyList();
}
List<URL> urls = new ArrayList<>(jars.size());
for (Path jar : jars) {
try {
urls.add(jar.toUri().toURL());
} catch (MalformedURLException e) {
log.warn("Skipping plugin jar with invalid URL {}: {}", jar, e.getMessage());
}
}
return urls;
}
/**
* Creates a class loader that can load classes/resources from installed plugins.
*
* @param parent parent class loader
* @return plugin-aware class loader or parent when no plugin jars exist
*/
public static ClassLoader buildPluginClassLoader(ClassLoader parent) {
List<URL> urls = pluginJarUrls();
if (urls.isEmpty()) {
return parent;
}
log.info(
"Scanning {} plugin jars in {}",
urls.size(),
InstallationPathConfig.getPluginsPath());
return new URLClassLoader(urls.toArray(URL[]::new), parent);
}
/**
* Loads descriptors for all discovered plugin jars.
*
* @return immutable list of successfully parsed descriptors
*/
public static List<PluginDescriptor> loadDescriptors() {
List<Path> jars = listPluginJars();
if (jars.isEmpty()) {
return Collections.emptyList();
}
List<PluginDescriptor> descriptors = new ArrayList<>();
for (Path jar : jars) {
PluginDescriptor descriptor = readDescriptorFromJar(jar);
if (descriptor != null) {
descriptors.add(descriptor);
}
}
return Collections.unmodifiableList(descriptors);
}
/**
* Loads metadata for one plugin jar and maps it to a descriptor.
*
* @param jarPath plugin jar path
* @return descriptor when valid metadata exists, otherwise {@code null}
*/
public static PluginDescriptor loadDescriptor(Path jarPath) {
return readDescriptorFromJar(jarPath);
}
private static Path ensurePluginDirectory() {
Path pluginDir = Path.of(InstallationPathConfig.getPluginsPath());
try {
return Files.createDirectories(pluginDir);
} catch (IOException e) {
log.error("Unable to create plugin directory {}", pluginDir, e);
return pluginDir;
}
}
private static PluginDescriptor readDescriptorFromJar(Path jarPath) {
if (!Files.isRegularFile(jarPath)) {
log.warn("Plugin jar {} is not a regular file, skipping", jarPath);
return null;
}
try (JarFile jarFile = new JarFile(jarPath.toFile())) {
JarEntry entry = jarFile.getJarEntry(METADATA_RESOURCE);
if (entry == null) {
log.info("Plugin jar {} does not include {}, skipping", jarPath, METADATA_RESOURCE);
return null;
}
PluginMetadata metadata;
try (InputStream inputStream = jarFile.getInputStream(entry)) {
metadata = OBJECT_MAPPER.readValue(inputStream, PluginMetadata.class);
}
String createdAt = resolveJarTimestamp(jarPath);
if (metadata.getId() == null || metadata.getId().isBlank()) {
log.warn("Plugin metadata in {} is missing required id, ignoring", jarPath);
return null;
}
String pluginId = metadata.getId();
log.info(
"Loaded metadata for plugin '{}': name='{}' version='{}'",
pluginId,
metadata.getName(),
metadata.getVersion());
return buildDescriptor(metadata, createdAt);
} catch (IOException e) {
log.warn("Failed to inspect plugin jar {}: {}", jarPath, e.getMessage());
return null;
}
}
private static PluginDescriptor buildDescriptor(PluginMetadata metadata, String jarCreatedAt) {
PluginMetadata.PluginFrontend frontend = metadata.getFrontend();
String id = metadata.getId();
String icon = metadata.getIcon();
String frontendPath =
(frontend != null
&& frontend.getEntrypoint() != null
&& !frontend.getEntrypoint().isBlank())
? ensureLeadingSlash(frontend.getEntrypoint())
: "/plugins/" + id + "/index.html";
return PluginDescriptor.builder()
.id(id)
.icon(defaultIfEmpty(icon, null))
.name(defaultIfEmpty(metadata.getName(), id))
.description(defaultIfEmpty(metadata.getDescription(), ""))
.version(defaultIfEmpty(metadata.getVersion(), "0.0.0"))
.author(metadata.getAuthor())
.frontendLabel(frontend != null ? frontend.getLabel() : null)
.frontendPath(frontendPath)
.iconPath(frontend != null ? frontend.getIconPath() : null)
.hasFrontend(frontend != null)
.backendEndpoints(
metadata.getBackendEndpoints() == null
? Collections.emptyList()
: metadata.getBackendEndpoints())
.metadata(
metadata.getMetadata() == null
? Collections.emptyMap()
: metadata.getMetadata())
.minHostVersion(defaultIfEmpty(metadata.getMinHostVersion(), null))
.jarCreatedAt(jarCreatedAt)
.build();
}
private static String defaultIfEmpty(String value, String fallback) {
return (value == null || value.isBlank()) ? fallback : value;
}
private static String ensureLeadingSlash(String path) {
if (path == null || path.isBlank()) {
return "/";
}
return path.startsWith("/") ? path : "/" + path;
}
private static boolean looksLikeJarFile(Path path) {
return path.getFileName().toString().toLowerCase().endsWith(JAR_EXTENSION);
}
private static boolean isReadableJarArchive(Path path) {
try {
String mimeType = Files.probeContentType(path);
if (mimeType != null && !JAR_MIME_TYPE.equals(mimeType)) {
log.debug("Ignoring non-jar mime type {} for {}", mimeType, path);
return false;
}
} catch (IOException e) {
log.debug("Unable to probe mime type for {}: {}", path, e.getMessage());
}
try (JarFile ignored = new JarFile(path.toFile())) {
return true;
} catch (IOException e) {
log.warn("Skipping invalid jar archive {}: {}", path, e.getMessage());
return false;
}
}
private static String resolveJarTimestamp(Path jarPath) throws IOException {
BasicFileAttributes attrs = Files.readAttributes(jarPath, BasicFileAttributes.class);
FileTime creationTime = attrs.creationTime();
FileTime lastModifiedTime = attrs.lastModifiedTime();
FileTime preferredTime =
creationTime == null || creationTime.toMillis() <= 0
? lastModifiedTime
: creationTime;
return preferredTime.toInstant().toString();
}
}
@@ -1,36 +0,0 @@
package stirling.software.common.plugins;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
/** Model for deserializing {@code META-INF/stirling-plugin.json} from a plugin jar. */
public class PluginMetadata {
private String id;
private String icon;
private String name;
private String description;
private String version;
private String author;
private String minHostVersion;
private PluginFrontend frontend;
private List<String> backendEndpoints;
private Map<String, String> metadata;
/** Frontend-specific metadata block declared inside plugin metadata JSON. */
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
public static class PluginFrontend {
private String entrypoint;
private String label;
private String iconPath;
}
}
@@ -1,28 +0,0 @@
package stirling.software.common.plugins;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
/** Registers MVC resource handlers that expose static assets from plugin jars. */
public class PluginResourceConfig implements WebMvcConfigurer {
/** Adds {@code /plugins/**} static resource mappings for every discovered plugin jar. */
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
List<String> locations =
PluginLoader.pluginJarUrls().stream()
.map(url -> "jar:" + url + "!/META-INF/resources/plugins/")
.collect(Collectors.toList());
if (!locations.isEmpty()) {
registry.addResourceHandler("/plugins/**")
.addResourceLocations(locations.toArray(String[]::new))
.setCachePeriod(0);
}
}
}
@@ -7,7 +7,6 @@ import java.io.InputStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import java.util.Locale;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import org.apache.pdfbox.Loader; import org.apache.pdfbox.Loader;
@@ -250,7 +249,7 @@ public class CustomPDFDocumentFactory {
log.debug( log.debug(
"Memory status - Free: {}MB ({}%), Used: {}MB, Max: {}MB", "Memory status - Free: {}MB ({}%), Used: {}MB, Max: {}MB",
actualFreeMemory / (1024 * 1024), actualFreeMemory / (1024 * 1024),
String.format(Locale.ROOT, "%.2f", freeMemoryPercent), String.format("%.2f", freeMemoryPercent),
usedMemory / (1024 * 1024), usedMemory / (1024 * 1024),
maxMemory / (1024 * 1024)); maxMemory / (1024 * 1024));
@@ -259,7 +258,7 @@ public class CustomPDFDocumentFactory {
|| actualFreeMemory < MIN_FREE_MEMORY_BYTES) { || actualFreeMemory < MIN_FREE_MEMORY_BYTES) {
log.debug( log.debug(
"Low memory detected ({}%), forcing file-based cache", "Low memory detected ({}%), forcing file-based cache",
String.format(Locale.ROOT, "%.2f", freeMemoryPercent)); String.format("%.2f", freeMemoryPercent));
return createScratchFileCacheFunction(MemoryUsageSetting.setupTempFileOnly()); return createScratchFileCacheFunction(MemoryUsageSetting.setupTempFileOnly());
} else if (contentSize < SMALL_FILE_THRESHOLD) { } else if (contentSize < SMALL_FILE_THRESHOLD) {
log.debug("Using memory-only cache for small document ({}KB)", contentSize / 1024); log.debug("Using memory-only cache for small document ({}KB)", contentSize / 1024);
@@ -478,6 +477,11 @@ public class CustomPDFDocumentFactory {
return file; return file;
} }
/** Create a uniquely named temporary directory */
private Path createTempDirectory(String prefix) throws IOException {
return Files.createTempDirectory(prefix + tempCounter.incrementAndGet() + "-");
}
/** Create new document bytes based on an existing document */ /** Create new document bytes based on an existing document */
public byte[] createNewBytesBasedOnOldDocument(byte[] oldDocument) throws IOException { public byte[] createNewBytesBasedOnOldDocument(byte[] oldDocument) throws IOException {
try (PDDocument document = load(oldDocument)) { try (PDDocument document = load(oldDocument)) {
@@ -253,22 +253,6 @@ public class JobExecutorService {
log.error("Synchronous job timed out after {} ms", timeoutToUse); log.error("Synchronous job timed out after {} ms", timeoutToUse);
return ResponseEntity.internalServerError() return ResponseEntity.internalServerError()
.body(Map.of("error", "Job timed out after " + timeoutToUse + " ms")); .body(Map.of("error", "Job timed out after " + timeoutToUse + " ms"));
} catch (RuntimeException e) {
// Check if this is a wrapped typed exception that should be handled by
// GlobalExceptionHandler
Throwable cause = e.getCause();
if (cause instanceof stirling.software.common.util.ExceptionUtils.BaseAppException
|| cause
instanceof
stirling.software.common.util.ExceptionUtils
.BaseValidationException) {
// Rethrow so GlobalExceptionHandler can handle with proper HTTP status codes
throw e;
}
// Handle other RuntimeExceptions as generic errors
log.error("Error executing synchronous job: {}", e.getMessage(), e);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Job failed: " + e.getMessage()));
} catch (Exception e) { } catch (Exception e) {
log.error("Error executing synchronous job: {}", e.getMessage(), e); log.error("Error executing synchronous job: {}", e.getMessage(), e);
// Construct a JSON error response // Construct a JSON error response
@@ -1,29 +0,0 @@
package stirling.software.common.service;
/**
* Interface for checking license status dynamically. Implementation provided by proprietary module
* when available.
*/
public interface LicenseServiceInterface {
/**
* Get the license type as a string.
*
* @return "NORMAL", "SERVER", or "ENTERPRISE"
*/
String getLicenseTypeName();
/**
* Check if running Pro or higher (SERVER or ENTERPRISE license).
*
* @return true if SERVER or ENTERPRISE license is active
*/
boolean isRunningProOrHigher();
/**
* Check if running Enterprise edition.
*
* @return true if ENTERPRISE license is active
*/
boolean isRunningEE();
}
@@ -1,12 +0,0 @@
package stirling.software.common.service;
import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
public interface LineArtConversionService {
PDImageXObject convertImageToLineArt(
PDDocument doc, PDImageXObject originalImage, double threshold, int edgeLevel)
throws IOException;
}
@@ -1,443 +0,0 @@
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 java.util.regex.Pattern;
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 static final Pattern FILENAME_SANITIZE_PATTERN = Pattern.compile("[^a-zA-Z0-9._-]");
private static final Pattern SESSION_ID_VALIDATION_PATTERN = Pattern.compile("[a-zA-Z0-9-]+");
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("[.][^.]+$");
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 =
FILE_EXTENSION_PATTERN.matcher(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 (!SESSION_ID_VALIDATION_PATTERN.matcher(sessionId).matches()) {
throw new IllegalArgumentException("Invalid session ID format");
}
}
private String sanitizeFilename(String filename) {
// Remove path traversal attempts and dangerous characters
String sanitized = FILENAME_SANITIZE_PATTERN.matcher(filename).replaceAll("_");
// 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;
}
}
}
@@ -253,8 +253,11 @@ public class PostHogService {
addIfNotEmpty( addIfNotEmpty(
properties, properties,
"security_enableLogin", "security_enableLogin",
applicationProperties.getSecurity().isEnableLogin()); applicationProperties.getSecurity().getEnableLogin());
addIfNotEmpty(properties, "security_csrfDisabled", true); addIfNotEmpty(
properties,
"security_csrfDisabled",
applicationProperties.getSecurity().getCsrfDisabled());
addIfNotEmpty( addIfNotEmpty(
properties, properties,
"security_loginAttemptCount", "security_loginAttemptCount",
@@ -299,13 +302,13 @@ public class PostHogService {
addIfNotEmpty( addIfNotEmpty(
properties, properties,
"system_googlevisibility", "system_googlevisibility",
applicationProperties.getSystem().isGooglevisibility()); applicationProperties.getSystem().getGooglevisibility());
addIfNotEmpty( addIfNotEmpty(
properties, "system_showUpdate", applicationProperties.getSystem().isShowUpdate()); properties, "system_showUpdate", applicationProperties.getSystem().isShowUpdate());
addIfNotEmpty( addIfNotEmpty(
properties, properties,
"system_showUpdateOnlyAdmin", "system_showUpdateOnlyAdmin",
applicationProperties.getSystem().isShowUpdateOnlyAdmin()); applicationProperties.getSystem().getShowUpdateOnlyAdmin());
addIfNotEmpty( addIfNotEmpty(
properties, properties,
"system_customHTMLFiles", "system_customHTMLFiles",
@@ -317,7 +320,7 @@ public class PostHogService {
addIfNotEmpty( addIfNotEmpty(
properties, properties,
"system_enableAlphaFunctionality", "system_enableAlphaFunctionality",
applicationProperties.getSystem().isEnableAlphaFunctionality()); applicationProperties.getSystem().getEnableAlphaFunctionality());
addIfNotEmpty( addIfNotEmpty(
properties, properties,
"system_enableAnalytics", "system_enableAnalytics",
@@ -337,7 +340,7 @@ public class PostHogService {
// Capture Metrics properties // Capture Metrics properties
addIfNotEmpty( addIfNotEmpty(
properties, "metrics_enabled", applicationProperties.getMetrics().isEnabled()); properties, "metrics_enabled", applicationProperties.getMetrics().getEnabled());
// Capture EnterpriseEdition properties // Capture EnterpriseEdition properties
addIfNotEmpty( addIfNotEmpty(
@@ -5,7 +5,6 @@ import java.lang.management.MemoryMXBean;
import java.lang.management.OperatingSystemMXBean; import java.lang.management.OperatingSystemMXBean;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Locale;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@@ -174,8 +173,8 @@ public class ResourceMonitor {
log.info("System resource status changed from {} to {}", oldStatus, newStatus); log.info("System resource status changed from {} to {}", oldStatus, newStatus);
log.info( log.info(
"Current metrics - CPU: {}%, Memory: {}%, Free Memory: {} MB", "Current metrics - CPU: {}%, Memory: {}%, Free Memory: {} MB",
String.format(Locale.ROOT, "%.1f", cpuUsage * 100), String.format("%.1f", cpuUsage * 100),
String.format(Locale.ROOT, "%.1f", memoryUsage * 100), String.format("%.1f", memoryUsage * 100),
freeMemory / (1024 * 1024)); freeMemory / (1024 * 1024));
} }
} catch (Exception e) { } catch (Exception e) {
@@ -5,7 +5,6 @@ import java.net.Inet6Address;
import java.net.InetAddress; import java.net.InetAddress;
import java.net.URI; import java.net.URI;
import java.net.UnknownHostException; import java.net.UnknownHostException;
import java.util.Locale;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -84,7 +83,7 @@ public class SsrfProtectionService {
return false; return false;
} }
return config.getAllowedDomains().contains(host.toLowerCase(Locale.ROOT)); return config.getAllowedDomains().contains(host.toLowerCase());
} catch (Exception e) { } catch (Exception e) {
log.debug("Failed to parse URL for MAX security check: {}", url, e); log.debug("Failed to parse URL for MAX security check: {}", url, e);
@@ -102,7 +101,7 @@ public class SsrfProtectionService {
return false; return false;
} }
String hostLower = host.toLowerCase(Locale.ROOT); String hostLower = host.toLowerCase();
// Check explicit blocked domains // Check explicit blocked domains
if (config.getBlockedDomains().contains(hostLower)) { if (config.getBlockedDomains().contains(hostLower)) {
@@ -112,7 +111,7 @@ public class SsrfProtectionService {
// Check internal TLD patterns // Check internal TLD patterns
for (String tld : config.getInternalTlds()) { for (String tld : config.getInternalTlds()) {
if (hostLower.endsWith(tld.toLowerCase(Locale.ROOT))) { if (hostLower.endsWith(tld.toLowerCase())) {
log.debug("URL blocked by internal TLD pattern '{}': {}", tld, url); log.debug("URL blocked by internal TLD pattern '{}': {}", tld, url);
return false; return false;
} }
@@ -124,11 +123,9 @@ public class SsrfProtectionService {
config.getAllowedDomains().stream() config.getAllowedDomains().stream()
.anyMatch( .anyMatch(
domain -> domain ->
hostLower.equals(domain.toLowerCase(Locale.ROOT)) hostLower.equals(domain.toLowerCase())
|| hostLower.endsWith( || hostLower.endsWith(
"." "." + domain.toLowerCase()));
+ domain.toLowerCase(
Locale.ROOT)));
if (!isAllowed) { if (!isAllowed) {
log.debug("URL not in allowed domains list: {}", url); log.debug("URL not in allowed domains list: {}", url);
@@ -7,7 +7,6 @@ import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@@ -102,16 +101,14 @@ public class TaskManager {
if (!extractedFiles.isEmpty()) { if (!extractedFiles.isEmpty()) {
jobResult.completeWithFiles(extractedFiles); jobResult.completeWithFiles(extractedFiles);
log.debug( log.debug(
"Set multiple file results for job ID: {} with {} files extracted from" "Set multiple file results for job ID: {} with {} files extracted from ZIP",
+ " ZIP",
jobId, jobId,
extractedFiles.size()); extractedFiles.size());
return; return;
} }
} catch (Exception e) { } catch (Exception e) {
log.warn( log.warn(
"Failed to extract ZIP file for job {}: {}. Falling back to single file" "Failed to extract ZIP file for job {}: {}. Falling back to single file result.",
+ " result.",
jobId, jobId,
e.getMessage()); e.getMessage());
} }
@@ -345,12 +342,12 @@ public class TaskManager {
/** Check if a file is a ZIP file based on content type and filename */ /** Check if a file is a ZIP file based on content type and filename */
private boolean isZipFile(String contentType, String fileName) { private boolean isZipFile(String contentType, String fileName) {
if (contentType != null if (contentType != null
&& ("application/zip".equals(contentType) && (contentType.equals("application/zip")
|| "application/x-zip-compressed".equals(contentType))) { || contentType.equals("application/x-zip-compressed"))) {
return true; return true;
} }
if (fileName != null && fileName.toLowerCase(Locale.ROOT).endsWith(".zip")) { if (fileName != null && fileName.toLowerCase().endsWith(".zip")) {
return true; return true;
} }
@@ -417,7 +414,7 @@ public class TaskManager {
return MediaType.APPLICATION_OCTET_STREAM_VALUE; return MediaType.APPLICATION_OCTET_STREAM_VALUE;
} }
String lowerName = fileName.toLowerCase(Locale.ROOT); String lowerName = fileName.toLowerCase();
if (lowerName.endsWith(".pdf")) { if (lowerName.endsWith(".pdf")) {
return MediaType.APPLICATION_PDF_VALUE; return MediaType.APPLICATION_PDF_VALUE;
} else if (lowerName.endsWith(".txt")) { } else if (lowerName.endsWith(".txt")) {
@@ -6,7 +6,6 @@ import java.io.InputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Locale;
import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.FilenameUtils;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
@@ -59,24 +58,31 @@ public class CbrUtils {
log.warn( log.warn(
"Failed to open CBR/RAR archive due to corrupt header: {}", "Failed to open CBR/RAR archive due to corrupt header: {}",
e.getMessage()); e.getMessage());
throw ExceptionUtils.createCbrInvalidFormatException(null); throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid or corrupted CBR/RAR archive. "
+ "The file may be corrupted, use an unsupported RAR format (RAR5+), "
+ "or may not be a valid RAR archive. "
+ "Please ensure the file is a valid RAR archive.");
} catch (RarException e) { } catch (RarException e) {
log.warn("Failed to open CBR/RAR archive: {}", e.getMessage()); log.warn("Failed to open CBR/RAR archive: {}", e.getMessage());
String errorMessage;
String exMessage = e.getMessage() != null ? e.getMessage() : ""; String exMessage = e.getMessage() != null ? e.getMessage() : "";
if (exMessage.contains("encrypted")) { if (exMessage.contains("encrypted")) {
throw ExceptionUtils.createCbrEncryptedException(); errorMessage = "Encrypted CBR/RAR archives are not supported.";
} else if (exMessage.isEmpty()) { } else if (exMessage.isEmpty()) {
throw ExceptionUtils.createCbrInvalidFormatException( errorMessage =
"Invalid CBR/RAR archive. The file may be encrypted, corrupted, or" "Invalid CBR/RAR archive. "
+ " use an unsupported format."); + "The file may be encrypted, corrupted, or use an unsupported format.";
} else { } else {
throw ExceptionUtils.createCbrInvalidFormatException( errorMessage =
"Invalid CBR/RAR archive: " "Invalid CBR/RAR archive: "
+ exMessage + exMessage
+ ". The file may be encrypted, corrupted, or use an" + ". The file may be encrypted, corrupted, or use an unsupported format.";
+ " unsupported format.");
} }
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat", errorMessage);
} catch (IOException e) { } catch (IOException e) {
log.warn("IO error reading CBR/RAR archive: {}", e.getMessage()); log.warn("IO error reading CBR/RAR archive: {}", e.getMessage());
throw ExceptionUtils.createFileProcessingException("CBR extraction", e); throw ExceptionUtils.createFileProcessingException("CBR extraction", e);
@@ -115,8 +121,7 @@ public class CbrUtils {
if (imageEntries.isEmpty()) { if (imageEntries.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException( throw ExceptionUtils.createIllegalArgumentException(
"error.fileProcessing", "error.fileProcessing",
"No valid images found in the CBR file. The archive may be empty or" "No valid images found in the CBR file. The archive may be empty or contain no supported image formats.");
+ " contain no supported image formats.");
} }
for (ImageEntryData imageEntry : imageEntries) { for (ImageEntryData imageEntry : imageEntries) {
@@ -129,12 +134,7 @@ public class CbrUtils {
new PDRectangle(pdImage.getWidth(), pdImage.getHeight())); new PDRectangle(pdImage.getWidth(), pdImage.getHeight()));
document.addPage(page); document.addPage(page);
try (PDPageContentStream contentStream = try (PDPageContentStream contentStream =
new PDPageContentStream( new PDPageContentStream(document, page)) {
document,
page,
PDPageContentStream.AppendMode.OVERWRITE,
true,
true)) {
contentStream.drawImage(pdImage, 0, 0); contentStream.drawImage(pdImage, 0, 0);
} }
} catch (IOException e) { } catch (IOException e) {
@@ -146,8 +146,7 @@ public class CbrUtils {
if (document.getNumberOfPages() == 0) { if (document.getNumberOfPages() == 0) {
throw ExceptionUtils.createIllegalArgumentException( throw ExceptionUtils.createIllegalArgumentException(
"error.fileProcessing", "error.fileProcessing",
"No images could be processed from the CBR file. All images may be" "No images could be processed from the CBR file. All images may be corrupted or in unsupported formats.");
+ " corrupted or in unsupported formats.");
} }
ByteArrayOutputStream baos = new ByteArrayOutputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -160,6 +159,7 @@ public class CbrUtils {
return GeneralUtils.optimizePdfWithGhostscript(pdfBytes); return GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
} catch (IOException e) { } catch (IOException e) {
log.warn("Ghostscript optimization failed, returning unoptimized PDF", e); log.warn("Ghostscript optimization failed, returning unoptimized PDF", e);
return pdfBytes;
} }
} }
@@ -170,17 +170,17 @@ public class CbrUtils {
private void validateCbrFile(MultipartFile file) { private void validateCbrFile(MultipartFile file) {
if (file == null || file.isEmpty()) { if (file == null || file.isEmpty()) {
throw ExceptionUtils.createFileNullOrEmptyException(); throw new IllegalArgumentException("File cannot be null or empty");
} }
String filename = file.getOriginalFilename(); String filename = file.getOriginalFilename();
if (filename == null) { if (filename == null) {
throw ExceptionUtils.createFileNoNameException(); throw new IllegalArgumentException("File must have a name");
} }
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT); String extension = FilenameUtils.getExtension(filename).toLowerCase();
if (!"cbr".equals(extension) && !"rar".equals(extension)) { if (!"cbr".equals(extension) && !"rar".equals(extension)) {
throw ExceptionUtils.createNotCbrFileException(); throw new IllegalArgumentException("File must be a CBR or RAR archive");
} }
} }
@@ -190,7 +190,7 @@ public class CbrUtils {
return false; return false;
} }
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT); String extension = FilenameUtils.getExtension(filename).toLowerCase();
return "cbr".equals(extension) || "rar".equals(extension); return "cbr".equals(extension) || "rar".equals(extension);
} }
@@ -8,7 +8,6 @@ import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.Enumeration; import java.util.Enumeration;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipFile; import java.util.zip.ZipFile;
import java.util.zip.ZipInputStream; import java.util.zip.ZipInputStream;
@@ -56,10 +55,10 @@ public class CbzUtils {
new java.io.FileInputStream(tempFile.getFile())); new java.io.FileInputStream(tempFile.getFile()));
ZipInputStream zis = new ZipInputStream(bis)) { ZipInputStream zis = new ZipInputStream(bis)) {
if (zis.getNextEntry() == null) { if (zis.getNextEntry() == null) {
throw ExceptionUtils.createCbzEmptyException(); throw new IllegalArgumentException("Archive is empty or invalid ZIP");
} }
} catch (IOException e) { } catch (IOException e) {
throw ExceptionUtils.createCbzInvalidFormatException(e); throw new IllegalArgumentException("Invalid CBZ/ZIP archive", e);
} }
try (PDDocument document = pdfDocumentFactory.createNewDocument(); try (PDDocument document = pdfDocumentFactory.createNewDocument();
@@ -84,7 +83,7 @@ public class CbzUtils {
Comparator.comparing(ImageEntryData::name, new NaturalOrderComparator())); Comparator.comparing(ImageEntryData::name, new NaturalOrderComparator()));
if (imageEntries.isEmpty()) { if (imageEntries.isEmpty()) {
throw ExceptionUtils.createCbzNoImagesException(); throw new IllegalArgumentException("No valid images found in the CBZ file");
} }
for (ImageEntryData imageEntry : imageEntries) { for (ImageEntryData imageEntry : imageEntries) {
@@ -97,12 +96,7 @@ public class CbzUtils {
new PDRectangle(pdImage.getWidth(), pdImage.getHeight())); new PDRectangle(pdImage.getWidth(), pdImage.getHeight()));
document.addPage(page); document.addPage(page);
try (PDPageContentStream contentStream = try (PDPageContentStream contentStream =
new PDPageContentStream( new PDPageContentStream(document, page)) {
document,
page,
PDPageContentStream.AppendMode.OVERWRITE,
true,
true)) {
contentStream.drawImage(pdImage, 0, 0); contentStream.drawImage(pdImage, 0, 0);
} }
} catch (IOException e) { } catch (IOException e) {
@@ -112,7 +106,8 @@ public class CbzUtils {
} }
if (document.getNumberOfPages() == 0) { if (document.getNumberOfPages() == 0) {
throw ExceptionUtils.createCbzCorruptedImagesException(); throw new IllegalArgumentException(
"No images could be processed from the CBZ file");
} }
ByteArrayOutputStream baos = new ByteArrayOutputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos); document.save(baos);
@@ -124,6 +119,7 @@ public class CbzUtils {
return GeneralUtils.optimizePdfWithGhostscript(pdfBytes); return GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
} catch (IOException e) { } catch (IOException e) {
log.warn("Ghostscript optimization failed, returning unoptimized PDF", e); log.warn("Ghostscript optimization failed, returning unoptimized PDF", e);
return pdfBytes;
} }
} }
@@ -134,17 +130,17 @@ public class CbzUtils {
private void validateCbzFile(MultipartFile file) { private void validateCbzFile(MultipartFile file) {
if (file == null || file.isEmpty()) { if (file == null || file.isEmpty()) {
throw ExceptionUtils.createFileNullOrEmptyException(); throw new IllegalArgumentException("File cannot be null or empty");
} }
String filename = file.getOriginalFilename(); String filename = file.getOriginalFilename();
if (filename == null) { if (filename == null) {
throw ExceptionUtils.createFileNoNameException(); throw new IllegalArgumentException("File must have a name");
} }
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT); String extension = FilenameUtils.getExtension(filename).toLowerCase();
if (!"cbz".equals(extension) && !"zip".equals(extension)) { if (!"cbz".equals(extension) && !"zip".equals(extension)) {
throw ExceptionUtils.createNotCbzFileException(); throw new IllegalArgumentException("File must be a CBZ or ZIP archive");
} }
} }
@@ -154,7 +150,7 @@ public class CbzUtils {
return false; return false;
} }
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT); String extension = FilenameUtils.getExtension(filename).toLowerCase();
return "cbz".equals(extension) || "zip".equals(extension); return "cbz".equals(extension) || "zip".equals(extension);
} }
@@ -164,7 +160,7 @@ public class CbzUtils {
return false; return false;
} }
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT); String extension = FilenameUtils.getExtension(filename).toLowerCase();
return "cbz".equals(extension) return "cbz".equals(extension)
|| "zip".equals(extension) || "zip".equals(extension)
|| "cbr".equals(extension) || "cbr".equals(extension)
@@ -11,8 +11,6 @@ public class CheckProgramInstall {
private static final List<String> PYTHON_COMMANDS = Arrays.asList("python3", "python"); private static final List<String> PYTHON_COMMANDS = Arrays.asList("python3", "python");
private static boolean pythonAvailableChecked = false; private static boolean pythonAvailableChecked = false;
private static String availablePythonCommand = null; private static String availablePythonCommand = null;
private static boolean ffmpegAvailableChecked = false;
private static boolean ffmpegAvailable = false;
/** /**
* Checks which Python command is available and returns it. * Checks which Python command is available and returns it.
@@ -58,25 +56,4 @@ public class CheckProgramInstall {
public static boolean isPythonAvailable() { public static boolean isPythonAvailable() {
return getAvailablePythonCommand() != null; return getAvailablePythonCommand() != null;
} }
/**
* Checks if FFmpeg is available on the system.
*
* @return true if FFmpeg is installed and accessible, false otherwise.
*/
public static boolean isFfmpegAvailable() {
if (!ffmpegAvailableChecked) {
try {
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.FFMPEG)
.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version"));
ffmpegAvailable = true;
} catch (IOException | InterruptedException e) {
ffmpegAvailable = false;
} finally {
ffmpegAvailableChecked = true;
}
}
return ffmpegAvailable;
}
} }
@@ -58,11 +58,14 @@ public class ChecksumUtils {
* @throws IOException if reading from the stream fails * @throws IOException if reading from the stream fails
*/ */
public static String checksum(InputStream is, String algorithm) throws IOException { public static String checksum(InputStream is, String algorithm) throws IOException {
return switch (algorithm.toUpperCase(Locale.ROOT)) { switch (algorithm.toUpperCase(Locale.ROOT)) {
case "CRC32" -> checksumChecksum(is, new CRC32()); case "CRC32":
case "ADLER32" -> checksumChecksum(is, new Adler32()); return checksumChecksum(is, new CRC32());
default -> toHex(checksumBytes(is, algorithm)); case "ADLER32":
}; return checksumChecksum(is, new Adler32());
default:
return toHex(checksumBytes(is, algorithm));
}
} }
/** /**
@@ -95,13 +98,14 @@ public class ChecksumUtils {
* @throws IOException if reading from the stream fails * @throws IOException if reading from the stream fails
*/ */
public static String checksumBase64(InputStream is, String algorithm) throws IOException { public static String checksumBase64(InputStream is, String algorithm) throws IOException {
return switch (algorithm.toUpperCase(Locale.ROOT)) { switch (algorithm.toUpperCase(Locale.ROOT)) {
case "CRC32" -> case "CRC32":
Base64.getEncoder().encodeToString(checksumChecksumBytes(is, new CRC32())); return Base64.getEncoder().encodeToString(checksumChecksumBytes(is, new CRC32()));
case "ADLER32" -> case "ADLER32":
Base64.getEncoder().encodeToString(checksumChecksumBytes(is, new Adler32())); return Base64.getEncoder().encodeToString(checksumChecksumBytes(is, new Adler32()));
default -> Base64.getEncoder().encodeToString(checksumBytes(is, algorithm)); default:
}; return Base64.getEncoder().encodeToString(checksumBytes(is, algorithm));
}
} }
/** /**
@@ -175,7 +179,7 @@ public class ChecksumUtils {
for (Map.Entry<String, Checksum> entry : checksums.entrySet()) { for (Map.Entry<String, Checksum> entry : checksums.entrySet()) {
// Keep value as long and mask to ensure unsigned hex formatting. // Keep value as long and mask to ensure unsigned hex formatting.
long unsigned32 = entry.getValue().getValue() & UNSIGNED_32_BIT_MASK; long unsigned32 = entry.getValue().getValue() & UNSIGNED_32_BIT_MASK;
results.put(entry.getKey(), String.format(Locale.ROOT, "%08x", unsigned32)); results.put(entry.getKey(), String.format("%08x", unsigned32));
} }
return results; return results;
} }
@@ -254,7 +258,7 @@ public class ChecksumUtils {
} }
// Keep as long and mask to ensure correct unsigned representation. // Keep as long and mask to ensure correct unsigned representation.
long unsigned32 = checksum.getValue() & UNSIGNED_32_BIT_MASK; long unsigned32 = checksum.getValue() & UNSIGNED_32_BIT_MASK;
return String.format(Locale.ROOT, "%08x", unsigned32); return String.format("%08x", unsigned32);
} }
/** /**
@@ -290,7 +294,7 @@ public class ChecksumUtils {
private static String toHex(byte[] hash) { private static String toHex(byte[] hash) {
StringBuilder sb = new StringBuilder(hash.length * 2); StringBuilder sb = new StringBuilder(hash.length * 2);
for (byte b : hash) { for (byte b : hash) {
sb.append(String.format(Locale.ROOT, "%02x", b)); sb.append(String.format("%02x", b));
} }
return sb.toString(); return sb.toString();
} }
@@ -62,7 +62,8 @@ public class CustomHtmlSanitizer {
.and(new HtmlPolicyBuilder().disallowElements("noscript").toFactory()); .and(new HtmlPolicyBuilder().disallowElements("noscript").toFactory());
public String sanitize(String html) { public String sanitize(String html) {
boolean disableSanitize = applicationProperties.getSystem().isDisableSanitize(); boolean disableSanitize =
Boolean.TRUE.equals(applicationProperties.getSystem().getDisableSanitize());
return disableSanitize ? html : POLICY.sanitize(html); return disableSanitize ? html : POLICY.sanitize(html);
} }
} }
@@ -1,417 +1,646 @@
package stirling.software.common.util; package stirling.software.common.util;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.time.ZoneOffset; import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.time.ZoneId;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Properties;
import java.util.Objects; import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.simplejavamail.api.email.AttachmentResource; import org.springframework.http.MediaType;
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.Data;
import lombok.experimental.UtilityClass; import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.converters.EmlToPdfRequest; import stirling.software.common.model.api.converters.EmlToPdfRequest;
@Slf4j
@UtilityClass @UtilityClass
public class EmlParser { public class EmlParser {
// Configuration constants private static volatile Boolean jakartaMailAvailable = null;
private final int DEFAULT_MAX_ATTACHMENT_MB = 10; private static volatile Method mimeUtilityDecodeTextMethod = null;
private final long MAX_SIZE_ESTIMATION_BYTES = 500L * 1024 * 1024; // 500MB private static volatile boolean mimeUtilityChecked = false;
// Message constants private static final Pattern MIME_ENCODED_PATTERN =
private final String NO_CONTENT_MESSAGE = "Email content could not be parsed"; RegexPatternUtils.getInstance().getMimeEncodedWordPattern();
private final String ATTACHMENT_PREFIX = "attachment-";
public EmailContent extractEmailContent( private static final String DISPOSITION_ATTACHMENT = "attachment";
private static final String TEXT_PLAIN = MediaType.TEXT_PLAIN_VALUE;
private static final String TEXT_HTML = MediaType.TEXT_HTML_VALUE;
private static final String MULTIPART_PREFIX = "multipart/";
private static final String HEADER_CONTENT_TYPE = "content-type:";
private static final String HEADER_CONTENT_DISPOSITION = "content-disposition:";
private static final String HEADER_CONTENT_TRANSFER_ENCODING = "content-transfer-encoding:";
private static final String HEADER_CONTENT_ID = "Content-ID";
private static final String HEADER_SUBJECT = "Subject:";
private static final String HEADER_FROM = "From:";
private static final String HEADER_TO = "To:";
private static final String HEADER_CC = "Cc:";
private static final String HEADER_BCC = "Bcc:";
private static final String HEADER_DATE = "Date:";
private static synchronized boolean isJakartaMailAvailable() {
if (jakartaMailAvailable == null) {
try {
Class.forName("jakarta.mail.internet.MimeMessage");
Class.forName("jakarta.mail.Session");
Class.forName("jakarta.mail.internet.MimeUtility");
Class.forName("jakarta.mail.internet.MimePart");
Class.forName("jakarta.mail.internet.MimeMultipart");
Class.forName("jakarta.mail.Multipart");
Class.forName("jakarta.mail.Part");
jakartaMailAvailable = true;
} catch (ClassNotFoundException e) {
jakartaMailAvailable = false;
}
}
return jakartaMailAvailable;
}
public static EmailContent extractEmailContent(
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer)
throws IOException { throws IOException {
EmlProcessingUtils.validateEmlInput(emlBytes); EmlProcessingUtils.validateEmlInput(emlBytes);
Email email = parseEmail(emlBytes); if (isJakartaMailAvailable()) {
return buildEmailContent(email, request, customHtmlSanitizer); return extractEmailContentAdvanced(emlBytes, request, customHtmlSanitizer);
} } else {
return extractEmailContentBasic(emlBytes, customHtmlSanitizer);
private Email parseEmail(byte[] emlBytes) throws IOException {
boolean isMsgFile = EmlProcessingUtils.isMsgFile(emlBytes);
try (ByteArrayInputStream input = new ByteArrayInputStream(emlBytes)) {
Email email;
if (isMsgFile) {
try {
email = EmailConverter.outlookMsgToEmail(input);
} catch (Exception e) {
// OLE2 magic bytes match but parsing failed - might be DOC/XLS/other OLE2 file
throw new IOException(
"The file appears to be an OLE2 file (MSG/DOC/XLS) but could not be "
+ "parsed as an Outlook email. Ensure it is a valid .msg file: "
+ e.getMessage(),
e);
}
} else {
email = EmailConverter.emlToEmail(input);
}
return email;
} catch (IOException e) {
throw e; // Re-throw IOException as-is
} catch (Exception e) {
throw new IOException(
String.format(
"Failed to parse EML file with Simple Java Mail: %s", e.getMessage()),
e);
} }
} }
private EmailContent buildEmailContent( private static EmailContent extractEmailContentBasic(
Email email, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) byte[] emlBytes, CustomHtmlSanitizer customHtmlSanitizer) {
throws IOException { String emlContent = new String(emlBytes, StandardCharsets.UTF_8);
EmailContent content = new EmailContent(); EmailContent content = new EmailContent();
content.setSubject(defaultString(email.getSubject()));
content.setFrom(formatRecipient(email.getFromRecipient()));
content.setTo(formatRecipients(email.getRecipients(), RecipientType.TO));
content.setCc(formatRecipients(email.getRecipients(), RecipientType.CC));
content.setBcc(formatRecipients(email.getRecipients(), RecipientType.BCC));
Date sentDate = email.getSentDate(); content.setSubject(extractBasicHeader(emlContent, HEADER_SUBJECT));
if (sentDate != null) { content.setFrom(extractBasicHeader(emlContent, HEADER_FROM));
// Use UTC for consistent timezone handling across deployments content.setTo(extractBasicHeader(emlContent, HEADER_TO));
content.setDate(ZonedDateTime.ofInstant(sentDate.toInstant(), ZoneOffset.UTC)); content.setCc(extractBasicHeader(emlContent, HEADER_CC));
content.setBcc(extractBasicHeader(emlContent, HEADER_BCC));
String dateStr = extractBasicHeader(emlContent, HEADER_DATE);
if (!dateStr.isEmpty()) {
content.setDateString(dateStr);
} }
String htmlBody = email.getHTMLText(); String htmlBody = extractHtmlBody(emlContent);
if (customHtmlSanitizer != null && htmlBody != null) { if (htmlBody != null) {
htmlBody = customHtmlSanitizer.sanitize(htmlBody); content.setHtmlBody(htmlBody);
} } else {
content.setHtmlBody(htmlBody); String textBody = extractTextBody(emlContent);
content.setTextBody(textBody != null ? textBody : "Email content could not be parsed");
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);
} }
List<EmailAttachment> attachments = new ArrayList<>(); content.getAttachments().addAll(extractAttachmentsBasic(emlContent));
attachments.addAll(mapResources(email.getEmbeddedImages(), request, true));
attachments.addAll(mapResources(email.getAttachments(), request, false));
content.setAttachments(attachments);
content.setAttachmentCount(attachments.size());
return content; return content;
} }
private List<EmailAttachment> mapResources( private static EmailContent extractEmailContentAdvanced(
List<AttachmentResource> resources, EmlToPdfRequest request, boolean embedded) byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) {
throws IOException { try {
Class<?> sessionClass = Class.forName("jakarta.mail.Session");
Class<?> mimeMessageClass = Class.forName("jakarta.mail.internet.MimeMessage");
if (resources == null || resources.isEmpty()) { Method getDefaultInstance =
return List.of(); sessionClass.getMethod("getDefaultInstance", Properties.class);
Object session = getDefaultInstance.invoke(null, new Properties());
Class<?>[] constructorArgs = new Class<?>[] {sessionClass, InputStream.class};
Constructor<?> mimeMessageConstructor =
mimeMessageClass.getConstructor(constructorArgs);
Object message =
mimeMessageConstructor.newInstance(session, new ByteArrayInputStream(emlBytes));
return extractFromMimeMessage(message, request, customHtmlSanitizer);
} catch (ReflectiveOperationException e) {
return extractEmailContentBasic(emlBytes, customHtmlSanitizer);
} }
List<EmailAttachment> mapped = new ArrayList<>(resources.size());
int unnamedCounter = 0; // Start at 0, increment before use
for (AttachmentResource resource : resources) {
if (resource == null) {
continue; // Skip null resources early
}
// Pre-determine if this resource needs a generated filename
boolean needsGeneratedName = !embedded && needsGeneratedFilename(resource);
if (needsGeneratedName) {
unnamedCounter++;
}
EmailAttachment attachment =
toEmailAttachment(resource, request, embedded, unnamedCounter);
if (attachment != null) {
mapped.add(attachment);
}
}
return mapped;
} }
/** Checks if a resource needs a generated filename (has no usable name). */ private static EmailContent extractFromMimeMessage(
private boolean needsGeneratedFilename(AttachmentResource resource) { Object message, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) {
if (resource == null) { EmailContent content = new EmailContent();
return false;
try {
Class<?> messageClass = message.getClass();
Method getSubject = messageClass.getMethod("getSubject");
String subject = (String) getSubject.invoke(message);
content.setSubject(subject != null ? safeMimeDecode(subject) : "No Subject");
Method getFrom = messageClass.getMethod("getFrom");
Object[] fromAddresses = (Object[]) getFrom.invoke(message);
content.setFrom(buildAddressString(fromAddresses));
extractRecipients(message, messageClass, content);
Method getSentDate = messageClass.getMethod("getSentDate");
Date legacyDate = (Date) getSentDate.invoke(message);
if (legacyDate != null) {
content.setDate(
ZonedDateTime.ofInstant(legacyDate.toInstant(), ZoneId.systemDefault()));
}
Method getContent = messageClass.getMethod("getContent");
Object messageContent = getContent.invoke(message);
processMessageContent(message, messageContent, content, request, customHtmlSanitizer);
} catch (ReflectiveOperationException | RuntimeException e) {
content.setSubject("Email Conversion");
content.setFrom("Unknown");
content.setTo("Unknown");
content.setCc("");
content.setBcc("");
content.setTextBody("Email content could not be parsed with advanced processing");
} }
String resourceName = resource.getName();
if (!isBlank(resourceName)) { return content;
return false;
}
DataSource dataSource = resource.getDataSource();
return isBlank(dataSource.getName());
} }
private EmailAttachment toEmailAttachment( private static void extractRecipients(
AttachmentResource resource, EmlToPdfRequest request, boolean embedded, int counter) Object message, Class<?> messageClass, EmailContent content) {
throws IOException { try {
Method getRecipients =
messageClass.getMethod(
"getRecipients", Class.forName("jakarta.mail.Message$RecipientType"));
Class<?> recipientTypeClass = Class.forName("jakarta.mail.Message$RecipientType");
if (resource == null) { Object toType = recipientTypeClass.getField("TO").get(null);
return null; Object[] toRecipients = (Object[]) getRecipients.invoke(message, toType);
} content.setTo(buildAddressString(toRecipients));
EmailAttachment attachment = new EmailAttachment(); Object ccType = recipientTypeClass.getField("CC").get(null);
attachment.setEmbedded(embedded); Object[] ccRecipients = (Object[]) getRecipients.invoke(message, ccType);
content.setCc(buildAddressString(ccRecipients));
String resourceName = defaultString(resource.getName()); Object bccType = recipientTypeClass.getField("BCC").get(null);
String filename = resourceName; Object[] bccRecipients = (Object[]) getRecipients.invoke(message, bccType);
DataSource dataSource = resource.getDataSource(); content.setBcc(buildAddressString(bccRecipients));
String contentType = dataSource.getContentType();
if (!isBlank(dataSource.getName())) { } catch (ReflectiveOperationException e) {
filename = dataSource.getName(); try {
} Method getAllRecipients = messageClass.getMethod("getAllRecipients");
filename = safeMimeDecode(filename); Object[] recipients = (Object[]) getAllRecipients.invoke(message);
content.setTo(buildAddressString(recipients));
// Generate unique filename for unnamed attachments content.setCc("");
if (isBlank(filename)) { content.setBcc("");
String extension = detectExtensionFromMimeType(contentType); } catch (ReflectiveOperationException ex) {
filename = embedded ? resourceName : (ATTACHMENT_PREFIX + counter + extension); content.setTo("");
} content.setCc("");
attachment.setFilename(filename); content.setBcc("");
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( private static String buildAddressString(Object[] addresses) {
boolean embedded, EmlToPdfRequest request, ReadResult readResult) { if (addresses == null || addresses.length == 0) {
// 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 ""; return "";
} }
String lower = mimeType.toLowerCase(Locale.ROOT); StringBuilder builder = new StringBuilder();
for (int i = 0; i < addresses.length; i++) {
// Remove any parameters (e.g., "text/plain; charset=utf-8" -> "text/plain") if (i > 0) builder.append(", ");
int semicolon = lower.indexOf(';'); builder.append(safeMimeDecode(addresses[i].toString()));
if (semicolon > 0) {
lower = lower.substring(0, semicolon).trim();
} }
return builder.toString();
// Match exact MIME types first, then fall back to contains() for variants
return switch (lower) {
case "application/pdf" -> ".pdf";
case "image/png" -> ".png";
case "image/jpeg", "image/jpg" -> ".jpg";
case "image/gif" -> ".gif";
case "image/webp" -> ".webp";
case "image/bmp" -> ".bmp";
case "text/plain" -> ".txt";
case "text/html" -> ".html";
case "text/xml", "application/xml" -> ".xml";
case "application/json" -> ".json";
case "application/zip" -> ".zip";
case "application/octet-stream" -> ".bin";
default -> {
if (lower.contains("wordprocessingml") || lower.contains("msword")) yield ".docx";
if (lower.contains("spreadsheetml") || lower.contains("excel")) yield ".xlsx";
if (lower.contains("presentationml") || lower.contains("powerpoint")) yield ".pptx";
if (lower.contains("opendocument.text")) yield ".odt";
if (lower.contains("opendocument.spreadsheet")) yield ".ods";
yield "";
}
};
} }
private ReadResult readData(DataSource dataSource, boolean embedded, EmlToPdfRequest request) private static void processMessageContent(
throws IOException { Object message,
if (dataSource == null) { Object messageContent,
return null; EmailContent content,
} EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer) {
try {
if (messageContent instanceof String stringContent) {
Method getContentType = message.getClass().getMethod("getContentType");
String contentType = (String) getContentType.invoke(message);
long maxBytes = getMaxAttachmentSizeBytes(request); if (contentType != null && contentType.toLowerCase().contains(TEXT_HTML)) {
content.setHtmlBody(stringContent);
try (InputStream input = dataSource.getInputStream()) { } else {
// Embedded images are usually needed for display regardless of size, content.setTextBody(stringContent);
// but regular attachments should be guarded against OOM
if (!embedded && request != null) {
byte[] buffer = new byte[8192];
ByteArrayOutputStream output = new ByteArrayOutputStream();
int bytesRead;
long totalBytes = 0;
while ((bytesRead = input.read(buffer)) != -1) {
totalBytes += bytesRead;
if (totalBytes > maxBytes) {
// Attachment too large - skip remaining data but estimate total size
long remainingBytes = countRemainingBytes(input, totalBytes);
log.debug(
"Attachment exceeds size limit: {} bytes (max: {} bytes), skipping",
remainingBytes,
maxBytes);
return new ReadResult(null, remainingBytes);
}
output.write(buffer, 0, bytesRead);
} }
byte[] data = output.toByteArray();
return new ReadResult(data, data.length);
} else { } else {
byte[] data = input.readAllBytes(); Class<?> multipartClass = Class.forName("jakarta.mail.Multipart");
return new ReadResult(data, data.length); if (multipartClass.isInstance(messageContent)) {
processMultipart(messageContent, content, request, customHtmlSanitizer, 0);
}
} }
} catch (IOException e) { } catch (ReflectiveOperationException | ClassCastException e) {
if (embedded) { content.setTextBody("Email content could not be parsed with advanced processing");
log.debug(
"Failed to read embedded image, using empty placeholder: {}",
e.getMessage());
return new ReadResult(new byte[0], 0);
}
throw e;
} }
} }
private long countRemainingBytes(InputStream input, long alreadyRead) throws IOException { private static void processMultipart(
long count = alreadyRead; Object multipart,
EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer,
int depth) {
long skipped; final int MAX_MULTIPART_DEPTH = 10;
while (count < MAX_SIZE_ESTIMATION_BYTES if (depth > MAX_MULTIPART_DEPTH) {
&& (skipped = input.skip(MAX_SIZE_ESTIMATION_BYTES - count)) > 0) { content.setHtmlBody("<div class=\"error\">Maximum multipart depth exceeded</div>");
count += skipped; return;
} }
if (count < MAX_SIZE_ESTIMATION_BYTES && input.available() > 0) { try {
byte[] buffer = new byte[8192]; Class<?> multipartClass = multipart.getClass();
int read; Method getCount = multipartClass.getMethod("getCount");
while ((read = input.read(buffer)) != -1 && count < MAX_SIZE_ESTIMATION_BYTES) { int count = (Integer) getCount.invoke(multipart);
count += read;
Method getBodyPart = multipartClass.getMethod("getBodyPart", int.class);
for (int i = 0; i < count; i++) {
Object part = getBodyPart.invoke(multipart, i);
processPart(part, content, request, customHtmlSanitizer, depth + 1);
} }
}
return count; } catch (ReflectiveOperationException | ClassCastException e) {
content.setHtmlBody("<div class=\"error\">Error processing multipart content</div>");
}
} }
private String formatRecipients(List<Recipient> recipients, RecipientType type) { private static void processPart(
if (recipients == null || type == null) { 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() : 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().startsWith(headerName.toLowerCase())) {
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();
int htmlStart = lowerContent.indexOf(HEADER_CONTENT_TYPE + " " + TEXT_HTML);
if (htmlStart == -1) return null;
int bodyStart = emlContent.indexOf("\r\n\r\n", htmlStart);
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n", htmlStart);
if (bodyStart == -1) return null;
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
int bodyEnd = findPartEnd(emlContent, bodyStart);
return emlContent.substring(bodyStart, bodyEnd).trim();
} catch (Exception e) {
return null;
}
}
private static String extractTextBody(String emlContent) {
try {
String lowerContent = emlContent.toLowerCase();
int textStart = lowerContent.indexOf(HEADER_CONTENT_TYPE + " " + TEXT_PLAIN);
if (textStart == -1) {
int bodyStart = emlContent.indexOf("\r\n\r\n");
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n");
if (bodyStart != -1) {
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
int bodyEnd = findPartEnd(emlContent, bodyStart);
return emlContent.substring(bodyStart, bodyEnd).trim();
}
return null;
}
int bodyStart = emlContent.indexOf("\r\n\r\n", textStart);
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n", textStart);
if (bodyStart == -1) return null;
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
int bodyEnd = findPartEnd(emlContent, bodyStart);
return emlContent.substring(bodyStart, bodyEnd).trim();
} catch (RuntimeException e) {
return null;
}
}
private static int findPartEnd(String content, int start) {
String[] lines =
RegexPatternUtils.getInstance()
.getNewlineSplitPattern()
.split(content.substring(start));
StringBuilder result = new StringBuilder();
for (String line : lines) {
if (line.startsWith("--") && line.length() > 10) break;
result.append(line).append("\n");
}
return start + result.length();
}
private static List<EmailAttachment> extractAttachmentsBasic(String emlContent) {
List<EmailAttachment> attachments = new ArrayList<>();
try {
String[] lines =
RegexPatternUtils.getInstance().getNewlineSplitPattern().split(emlContent);
boolean inHeaders = true;
String currentContentType = "";
String currentDisposition = "";
String currentFilename = "";
String currentEncoding = "";
for (String line : lines) {
String lowerLine = line.toLowerCase().trim();
if (line.trim().isEmpty()) {
inHeaders = false;
if (isAttachment(currentDisposition, currentFilename, currentContentType)) {
EmailAttachment attachment = new EmailAttachment();
attachment.setFilename(currentFilename);
attachment.setContentType(currentContentType);
attachment.setTransferEncoding(currentEncoding);
attachments.add(attachment);
}
currentContentType = "";
currentDisposition = "";
currentFilename = "";
currentEncoding = "";
inHeaders = true;
continue;
}
if (!inHeaders) continue;
if (lowerLine.startsWith(HEADER_CONTENT_TYPE)) {
currentContentType = line.substring(HEADER_CONTENT_TYPE.length()).trim();
} else if (lowerLine.startsWith(HEADER_CONTENT_DISPOSITION)) {
currentDisposition = line.substring(HEADER_CONTENT_DISPOSITION.length()).trim();
currentFilename = extractFilenameFromDisposition(currentDisposition);
} else if (lowerLine.startsWith(HEADER_CONTENT_TRANSFER_ENCODING)) {
currentEncoding =
line.substring(HEADER_CONTENT_TRANSFER_ENCODING.length()).trim();
}
}
} catch (RuntimeException e) {
// Continue with empty list
}
return attachments;
}
private static boolean isAttachment(String disposition, String filename, String contentType) {
return (disposition.toLowerCase().contains(DISPOSITION_ATTACHMENT) && !filename.isEmpty())
|| (!filename.isEmpty() && !contentType.toLowerCase().startsWith("text/"))
|| (contentType.toLowerCase().contains("application/") && !filename.isEmpty());
}
private static String extractFilenameFromDisposition(String disposition) {
if (disposition == null || !disposition.contains("filename=")) {
return ""; return "";
} }
return recipients.stream() // Handle filename*= (RFC 2231 encoded filename)
.filter(Objects::nonNull) if (disposition.toLowerCase().contains("filename*=")) {
// Use type.equals() for null-safe comparison (recipient.getType() may be null) int filenameStarStart = disposition.toLowerCase().indexOf("filename*=") + 10;
.filter(recipient -> type.equals(recipient.getType())) int filenameStarEnd = disposition.indexOf(";", filenameStarStart);
.map(EmlParser::formatRecipient) if (filenameStarEnd == -1) filenameStarEnd = disposition.length();
.filter(string -> !isBlank(string)) String extendedFilename =
.collect(Collectors.joining(", ")); 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().indexOf("filename=") + 9;
int filenameEnd = disposition.indexOf(";", filenameStart);
if (filenameEnd == -1) filenameEnd = disposition.length();
String filename = disposition.substring(filenameStart, filenameEnd).trim();
filename =
RegexPatternUtils.getInstance()
.getQuotesRemovalPattern()
.matcher(filename)
.replaceAll("");
return safeMimeDecode(filename);
} }
private String formatRecipient(Recipient recipient) { public static String safeMimeDecode(String headerValue) {
if (recipient == null) { if (headerValue == null || headerValue.trim().isEmpty()) {
return ""; return "";
} }
String name = safeMimeDecode(recipient.getName()); if (!mimeUtilityChecked) {
String address = safeMimeDecode(recipient.getAddress()); synchronized (EmlParser.class) {
if (!mimeUtilityChecked) {
if (!isBlank(name) && !isBlank(address)) { initializeMimeUtilityDecoding();
return name + " <" + address + ">"; }
}
} }
return !isBlank(name) ? name : address;
}
public String safeMimeDecode(String headerValue) { if (mimeUtilityDecodeTextMethod != null) {
if (isBlank(headerValue)) { try {
return ""; return (String) mimeUtilityDecodeTextMethod.invoke(null, headerValue.trim());
} catch (ReflectiveOperationException | RuntimeException e) {
// Fall through to custom implementation
}
} }
return EmlProcessingUtils.decodeMimeHeader(headerValue.trim()); return EmlProcessingUtils.decodeMimeHeader(headerValue.trim());
} }
private String stripCid(String contentId) { private static void initializeMimeUtilityDecoding() {
if (contentId == null) { try {
return null; Class<?> mimeUtilityClass = Class.forName("jakarta.mail.internet.MimeUtility");
} mimeUtilityDecodeTextMethod = mimeUtilityClass.getMethod("decodeText", String.class);
return RegexPatternUtils.getInstance() } catch (ClassNotFoundException | NoSuchMethodException e) {
.getAngleBracketsPattern() mimeUtilityDecodeTextMethod = null;
.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 @Data
public class EmailContent { public static class EmailContent {
private String subject; private String subject;
private String from; private String from;
private String to; private String to;
private String cc; private String cc;
private String bcc; private String bcc;
private ZonedDateTime date; private ZonedDateTime date;
private String dateString; // Maintained for compatibility private String dateString; // For basic parsing fallback
private String htmlBody; private String htmlBody;
private String textBody; private String textBody;
private int attachmentCount; private int attachmentCount;
@@ -439,7 +668,7 @@ public class EmlParser {
} }
@Data @Data
public class EmailAttachment { public static class EmailAttachment {
private String filename; private String filename;
private String contentType; private String contentType;
private byte[] data; private byte[] data;
@@ -1,7 +1,5 @@
package stirling.software.common.util; package stirling.software.common.util;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset; import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Base64; import java.util.Base64;
@@ -10,41 +8,32 @@ import java.util.Map;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import lombok.Synchronized;
import lombok.experimental.UtilityClass; import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.converters.EmlToPdfRequest; import stirling.software.common.model.api.converters.EmlToPdfRequest;
import stirling.software.common.model.api.converters.HTMLToPdfRequest; import stirling.software.common.model.api.converters.HTMLToPdfRequest;
@Slf4j
@UtilityClass @UtilityClass
public class EmlProcessingUtils { public class EmlProcessingUtils {
// Style constants // Style constants
private final int DEFAULT_FONT_SIZE = 12; private static final int DEFAULT_FONT_SIZE = 12;
private final String DEFAULT_FONT_FAMILY = "Helvetica, sans-serif"; private static final String DEFAULT_FONT_FAMILY = "Helvetica, sans-serif";
private final float DEFAULT_LINE_HEIGHT = 1.4f; private static final float DEFAULT_LINE_HEIGHT = 1.4f;
private final String DEFAULT_ZOOM = "1.0"; private static final String DEFAULT_ZOOM = "1.0";
private final String DEFAULT_TEXT_COLOR = "#202124"; private static final String DEFAULT_TEXT_COLOR = "#202124";
private final String DEFAULT_BACKGROUND_COLOR = "#ffffff"; private static final String DEFAULT_BACKGROUND_COLOR = "#ffffff";
private final String DEFAULT_BORDER_COLOR = "#e8eaed"; private static final String DEFAULT_BORDER_COLOR = "#e8eaed";
private final String ATTACHMENT_BACKGROUND_COLOR = "#f9f9f9"; private static final String ATTACHMENT_BACKGROUND_COLOR = "#f9f9f9";
private final String ATTACHMENT_BORDER_COLOR = "#eeeeee"; private static final String ATTACHMENT_BORDER_COLOR = "#eeeeee";
private final String CSS_RESOURCE_PATH = "templates/email-pdf-styles.css"; private static final int EML_CHECK_LENGTH = 8192;
private final int EML_CHECK_LENGTH = 8192; private static final int MIN_HEADER_COUNT_FOR_VALID_EML = 2;
private final int MIN_HEADER_COUNT_FOR_VALID_EML = 2;
// MSG file magic bytes (Compound File Binary Format / OLE2) // MIME type detection
// D0 CF 11 E0 A1 B1 1A E1 private static final Map<String, String> EXTENSION_TO_MIME_TYPE =
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( Map.of(
".png", MediaType.IMAGE_PNG_VALUE, ".png", MediaType.IMAGE_PNG_VALUE,
".jpg", MediaType.IMAGE_JPEG_VALUE, ".jpg", MediaType.IMAGE_JPEG_VALUE,
@@ -56,36 +45,18 @@ public class EmlProcessingUtils {
".ico", "image/x-icon", ".ico", "image/x-icon",
".tiff", "image/tiff", ".tiff", "image/tiff",
".tif", "image/tiff"); ".tif", "image/tiff");
private volatile String cachedCssContent = null;
public void validateEmlInput(byte[] emlBytes) { public static void validateEmlInput(byte[] emlBytes) {
if (emlBytes == null || emlBytes.length == 0) { if (emlBytes == null || emlBytes.length == 0) {
throw ExceptionUtils.createEmlEmptyException(); throw new IllegalArgumentException("EML file is empty or null");
}
if (isMsgFile(emlBytes)) {
return; // Valid MSG file, no further EML validation needed
} }
if (isInvalidEmlFormat(emlBytes)) { if (isInvalidEmlFormat(emlBytes)) {
throw ExceptionUtils.createEmlInvalidFormatException(); throw new IllegalArgumentException("Invalid EML file format");
} }
} }
public boolean isMsgFile(byte[] fileBytes) { private static boolean isInvalidEmlFormat(byte[] emlBytes) {
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 { try {
int checkLength = Math.min(emlBytes.length, EML_CHECK_LENGTH); int checkLength = Math.min(emlBytes.length, EML_CHECK_LENGTH);
String content; String content;
@@ -130,7 +101,7 @@ public class EmlProcessingUtils {
} }
} }
public String generateEnhancedEmailHtml( public static String generateEnhancedEmailHtml(
EmlParser.EmailContent content, EmlParser.EmailContent content,
EmlToPdfRequest request, EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer) { CustomHtmlSanitizer customHtmlSanitizer) {
@@ -138,13 +109,12 @@ public class EmlProcessingUtils {
html.append( html.append(
String.format( String.format(
Locale.ROOT,
""" """
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"> <html lang="en"><head><meta charset="UTF-8">
<title>%s</title> <title>%s</title>
<style> <style>
""", """,
sanitizeText(content.getSubject(), customHtmlSanitizer))); sanitizeText(content.getSubject(), customHtmlSanitizer)));
appendEnhancedStyles(html); appendEnhancedStyles(html);
@@ -157,15 +127,14 @@ public class EmlProcessingUtils {
html.append( html.append(
String.format( String.format(
Locale.ROOT,
""" """
<div class="email-container"> <div class="email-container">
<div class="email-header"> <div class="email-header">
<h1>%s</h1> <h1>%s</h1>
<div class="email-meta"> <div class="email-meta">
<div><strong>From:</strong> %s</div> <div><strong>From:</strong> %s</div>
<div><strong>To:</strong> %s</div> <div><strong>To:</strong> %s</div>
""", """,
sanitizeText(content.getSubject(), customHtmlSanitizer), sanitizeText(content.getSubject(), customHtmlSanitizer),
sanitizeText(content.getFrom(), customHtmlSanitizer), sanitizeText(content.getFrom(), customHtmlSanitizer),
sanitizeText(content.getTo(), customHtmlSanitizer))); sanitizeText(content.getTo(), customHtmlSanitizer)));
@@ -173,36 +142,32 @@ public class EmlProcessingUtils {
if (content.getCc() != null && !content.getCc().trim().isEmpty()) { if (content.getCc() != null && !content.getCc().trim().isEmpty()) {
html.append( html.append(
String.format( String.format(
Locale.ROOT, "<div><strong>CC:</strong> %s</div>\n",
"<div><strong>CC:</strong> %s</div>%n",
sanitizeText(content.getCc(), customHtmlSanitizer))); sanitizeText(content.getCc(), customHtmlSanitizer)));
} }
if (content.getBcc() != null && !content.getBcc().trim().isEmpty()) { if (content.getBcc() != null && !content.getBcc().trim().isEmpty()) {
html.append( html.append(
String.format( String.format(
Locale.ROOT, "<div><strong>BCC:</strong> %s</div>\n",
"<div><strong>BCC:</strong> %s</div>%n",
sanitizeText(content.getBcc(), customHtmlSanitizer))); sanitizeText(content.getBcc(), customHtmlSanitizer)));
} }
if (content.getDate() != null) { if (content.getDate() != null) {
html.append( html.append(
String.format( String.format(
Locale.ROOT, "<div><strong>Date:</strong> %s</div>\n",
"<div><strong>Date:</strong> %s</div>%n",
PdfAttachmentHandler.formatEmailDate(content.getDate()))); PdfAttachmentHandler.formatEmailDate(content.getDate())));
} else if (content.getDateString() != null && !content.getDateString().trim().isEmpty()) { } else if (content.getDateString() != null && !content.getDateString().trim().isEmpty()) {
html.append( html.append(
String.format( String.format(
Locale.ROOT, "<div><strong>Date:</strong> %s</div>\n",
"<div><strong>Date:</strong> %s</div>%n",
sanitizeText(content.getDateString(), customHtmlSanitizer))); sanitizeText(content.getDateString(), customHtmlSanitizer)));
} }
html.append(String.format(Locale.ROOT, "</div></div>%n")); html.append("</div></div>\n");
html.append(String.format(Locale.ROOT, "<div class=\"email-body\">%n")); html.append("<div class=\"email-body\">\n");
if (content.getHtmlBody() != null && !content.getHtmlBody().trim().isEmpty()) { if (content.getHtmlBody() != null && !content.getHtmlBody().trim().isEmpty()) {
String processedHtml = String processedHtml =
processEmailHtmlBody(content.getHtmlBody(), content, customHtmlSanitizer); processEmailHtmlBody(content.getHtmlBody(), content, customHtmlSanitizer);
@@ -210,23 +175,22 @@ public class EmlProcessingUtils {
} else if (content.getTextBody() != null && !content.getTextBody().trim().isEmpty()) { } else if (content.getTextBody() != null && !content.getTextBody().trim().isEmpty()) {
html.append( html.append(
String.format( String.format(
Locale.ROOT,
"<div class=\"text-body\">%s</div>", "<div class=\"text-body\">%s</div>",
convertTextToHtml(content.getTextBody(), customHtmlSanitizer))); convertTextToHtml(content.getTextBody(), customHtmlSanitizer)));
} else { } else {
html.append("<div class=\"no-content\"><p><em>No content available</em></p></div>"); html.append("<div class=\"no-content\"><p><em>No content available</em></p></div>");
} }
html.append(String.format(Locale.ROOT, "</div>%n")); html.append("</div>\n");
if (content.getAttachmentCount() > 0 || !content.getAttachments().isEmpty()) { if (content.getAttachmentCount() > 0 || !content.getAttachments().isEmpty()) {
appendAttachmentsSection(html, content, request); appendAttachmentsSection(html, content, request, customHtmlSanitizer);
} }
html.append(String.format(Locale.ROOT, "</div>%n</body></html>")); html.append("</div>\n</body></html>");
return html.toString(); return html.toString();
} }
public String processEmailHtmlBody( public static String processEmailHtmlBody(
String htmlBody, String htmlBody,
EmlParser.EmailContent emailContent, EmlParser.EmailContent emailContent,
CustomHtmlSanitizer customHtmlSanitizer) { CustomHtmlSanitizer customHtmlSanitizer) {
@@ -253,7 +217,8 @@ public class EmlProcessingUtils {
return processed; return processed;
} }
public String convertTextToHtml(String textBody, CustomHtmlSanitizer customHtmlSanitizer) { public static String convertTextToHtml(
String textBody, CustomHtmlSanitizer customHtmlSanitizer) {
if (textBody == null) return ""; if (textBody == null) return "";
String html = String html =
@@ -269,111 +234,171 @@ public class EmlProcessingUtils {
.getUrlLinkPattern() .getUrlLinkPattern()
.matcher(html) .matcher(html)
.replaceAll( .replaceAll(
"<a href=\"$1\" style=\"color: #1a73e8; text-decoration:" "<a href=\"$1\" style=\"color: #1a73e8; text-decoration: underline;\">$1</a>");
+ " underline;\">$1</a>");
html = html =
RegexPatternUtils.getInstance() RegexPatternUtils.getInstance()
.getEmailLinkPattern() .getEmailLinkPattern()
.matcher(html) .matcher(html)
.replaceAll( .replaceAll(
"<a href=\"mailto:$1\" style=\"color: #1a73e8; text-decoration:" "<a href=\"mailto:$1\" style=\"color: #1a73e8; text-decoration: underline;\">$1</a>");
+ " underline;\">$1</a>");
return html; return html;
} }
private void appendEnhancedStyles(StringBuilder html) { private static void appendEnhancedStyles(StringBuilder html) {
html.append( String css =
String.format( String.format(
Locale.ROOT,
""" """
:root { body {
--font-family: %s; font-family: %s;
--font-size: %dpx; font-size: %dpx;
--line-height: %s; line-height: %s;
--text-color: %s; color: %s;
--bg-color: %s; margin: 0;
--border-color: %s; padding: 16px;
--header-font-size: %dpx; background-color: %s;
--meta-font-size: %dpx; }
--attachment-bg: %s;
--attachment-border: %s; .email-container {
--attachment-header-size: %dpx; width: 100%%;
--attachment-detail-size: %dpx; max-width: 100%%;
--note-font-size: %dpx; margin: 0 auto;
} }
""",
.email-header {
padding-bottom: 10px;
border-bottom: 1px solid %s;
margin-bottom: 10px;
}
.email-header h1 {
margin: 0 0 10px 0;
font-size: %dpx;
font-weight: bold;
}
.email-meta div {
margin-bottom: 2px;
font-size: %dpx;
}
.email-body {
word-wrap: break-word;
}
.attachment-section {
margin-top: 15px;
padding: 10px;
background-color: %s;
border: 1px solid %s;
border-radius: 3px;
}
.attachment-section h3 {
margin: 0 0 8px 0;
font-size: %dpx;
}
.attachment-item {
padding: 5px 0;
}
.attachment-icon {
margin-right: 5px;
}
.attachment-details, .attachment-type {
font-size: %dpx;
color: #555555;
}
.attachment-inclusion-note, .attachment-info-note {
margin-top: 8px;
padding: 6px;
font-size: %dpx;
border-radius: 3px;
}
.attachment-inclusion-note {
background-color: #e6ffed;
border: 1px solid #d4f7dc;
color: #006420;
}
.attachment-info-note {
background-color: #fff9e6;
border: 1px solid #fff0c2;
color: #664d00;
}
.attachment-link-container {
display: flex;
align-items: center;
padding: 8px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
margin: 4px 0;
}
.attachment-link-container:hover {
background-color: #e9ecef;
}
.attachment-note {
font-size: %dpx;
color: #6c757d;
font-style: italic;
margin-left: 8px;
}
.no-content {
padding: 20px;
text-align: center;
color: #666;
font-style: italic;
}
.text-body {
white-space: pre-wrap;
}
img {
max-width: 100%%;
height: auto;
display: block;
}
""",
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
DEFAULT_LINE_HEIGHT, DEFAULT_LINE_HEIGHT,
DEFAULT_TEXT_COLOR, DEFAULT_TEXT_COLOR,
DEFAULT_BACKGROUND_COLOR, DEFAULT_BACKGROUND_COLOR,
DEFAULT_BORDER_COLOR, DEFAULT_BORDER_COLOR,
DEFAULT_FONT_SIZE + 6, DEFAULT_FONT_SIZE + 4,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE - 1,
ATTACHMENT_BACKGROUND_COLOR, ATTACHMENT_BACKGROUND_COLOR,
ATTACHMENT_BORDER_COLOR, ATTACHMENT_BORDER_COLOR,
DEFAULT_FONT_SIZE + 2, DEFAULT_FONT_SIZE + 1,
DEFAULT_FONT_SIZE - 1, DEFAULT_FONT_SIZE - 2,
DEFAULT_FONT_SIZE - 1)); DEFAULT_FONT_SIZE - 2,
DEFAULT_FONT_SIZE - 3);
html.append(loadEmailStyles()); html.append(css);
} }
@Synchronized private static void appendAttachmentsSection(
private String loadEmailStyles() { StringBuilder html,
if (cachedCssContent != null) { EmlParser.EmailContent content,
return cachedCssContent; EmlToPdfRequest request,
} CustomHtmlSanitizer customHtmlSanitizer) {
html.append("<div class=\"attachment-section\">\n");
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 = int displayedAttachmentCount =
content.getAttachmentCount() > 0 content.getAttachmentCount() > 0
? content.getAttachmentCount() ? content.getAttachmentCount()
: content.getAttachments().size(); : content.getAttachments().size();
html.append( html.append("<h3>Attachments (").append(displayedAttachmentCount).append(")</h3>\n");
String.format(
Locale.ROOT, "<h3>Attachments (%d)</h3>%n", displayedAttachmentCount));
if (!content.getAttachments().isEmpty()) { if (!content.getAttachments().isEmpty()) {
for (int i = 0; i < content.getAttachments().size(); i++) { for (int i = 0; i < content.getAttachments().size(); i++) {
@@ -395,14 +420,13 @@ public class EmlProcessingUtils {
String attachmentId = "attachment_" + i; String attachmentId = "attachment_" + i;
html.append( html.append(
String.format( String.format(
Locale.ROOT,
""" """
<div class="attachment-item" id="%s"> <div class="attachment-item" id="%s">
<span class="attachment-icon" data-filename="%s">@</span> <span class="attachment-icon" data-filename="%s">@</span>
<span class="attachment-name">%s</span> <span class="attachment-name">%s</span>
<span class="attachment-details">(%s%s)</span> <span class="attachment-details">(%s%s)</span>
</div> </div>
""", """,
attachmentId, attachmentId,
escapeHtml(embeddedFilename), escapeHtml(embeddedFilename),
escapeHtml(EmlParser.safeMimeDecode(attachment.getFilename())), escapeHtml(EmlParser.safeMimeDecode(attachment.getFilename())),
@@ -426,10 +450,10 @@ public class EmlProcessingUtils {
</div> </div>
"""); """);
} }
html.append(String.format(Locale.ROOT, "</div>%n")); html.append("</div>\n");
} }
public HTMLToPdfRequest createHtmlRequest(EmlToPdfRequest request) { public static HTMLToPdfRequest createHtmlRequest(EmlToPdfRequest request) {
HTMLToPdfRequest htmlRequest = new HTMLToPdfRequest(); HTMLToPdfRequest htmlRequest = new HTMLToPdfRequest();
if (request != null) { if (request != null) {
@@ -440,13 +464,13 @@ public class EmlProcessingUtils {
return htmlRequest; return htmlRequest;
} }
public String detectMimeType(String filename, String existingMimeType) { public static String detectMimeType(String filename, String existingMimeType) {
if (existingMimeType != null && !existingMimeType.isEmpty()) { if (existingMimeType != null && !existingMimeType.isEmpty()) {
return existingMimeType; return existingMimeType;
} }
if (filename != null) { if (filename != null) {
String lowerFilename = filename.toLowerCase(Locale.ROOT); String lowerFilename = filename.toLowerCase();
for (Map.Entry<String, String> entry : EXTENSION_TO_MIME_TYPE.entrySet()) { for (Map.Entry<String, String> entry : EXTENSION_TO_MIME_TYPE.entrySet()) {
if (lowerFilename.endsWith(entry.getKey())) { if (lowerFilename.endsWith(entry.getKey())) {
return entry.getValue(); return entry.getValue();
@@ -457,7 +481,7 @@ public class EmlProcessingUtils {
return MediaType.IMAGE_PNG_VALUE; // Default MIME type return MediaType.IMAGE_PNG_VALUE; // Default MIME type
} }
public String decodeUrlEncoded(String encoded) { public static String decodeUrlEncoded(String encoded) {
try { try {
return java.net.URLDecoder.decode(encoded, StandardCharsets.UTF_8); return java.net.URLDecoder.decode(encoded, StandardCharsets.UTF_8);
} catch (Exception e) { } catch (Exception e) {
@@ -465,7 +489,7 @@ public class EmlProcessingUtils {
} }
} }
public String decodeMimeHeader(String encodedText) { public static String decodeMimeHeader(String encodedText) {
if (encodedText == null || encodedText.trim().isEmpty()) { if (encodedText == null || encodedText.trim().isEmpty()) {
return encodedText; return encodedText;
} }
@@ -492,7 +516,7 @@ public class EmlProcessingUtils {
result.append(processedText, lastEnd, matcher.start()); result.append(processedText, lastEnd, matcher.start());
String charset = matcher.group(1); String charset = matcher.group(1);
String encoding = matcher.group(2).toUpperCase(Locale.ROOT); String encoding = matcher.group(2).toUpperCase();
String encodedValue = matcher.group(3); String encodedValue = matcher.group(3);
try { try {
@@ -531,7 +555,7 @@ public class EmlProcessingUtils {
} }
} }
private String decodeQuotedPrintable(String encodedText, String charset) { private static String decodeQuotedPrintable(String encodedText, String charset) {
StringBuilder result = new StringBuilder(); StringBuilder result = new StringBuilder();
for (int i = 0; i < encodedText.length(); i++) { for (int i = 0; i < encodedText.length(); i++) {
char c = encodedText.charAt(i); char c = encodedText.charAt(i);
@@ -574,7 +598,7 @@ public class EmlProcessingUtils {
} }
} }
public String escapeHtml(String text) { public static String escapeHtml(String text) {
if (text == null) return ""; if (text == null) return "";
return text.replace("&", "&amp;") return text.replace("&", "&amp;")
.replace("<", "&lt;") .replace("<", "&lt;")
@@ -583,7 +607,7 @@ public class EmlProcessingUtils {
.replace("'", "&#39;"); .replace("'", "&#39;");
} }
public String sanitizeText(String text, CustomHtmlSanitizer customHtmlSanitizer) { public static String sanitizeText(String text, CustomHtmlSanitizer customHtmlSanitizer) {
if (customHtmlSanitizer != null) { if (customHtmlSanitizer != null) {
return customHtmlSanitizer.sanitize(text); return customHtmlSanitizer.sanitize(text);
} else { } else {
@@ -591,7 +615,7 @@ public class EmlProcessingUtils {
} }
} }
public String simplifyHtmlContent(String htmlContent) { public static String simplifyHtmlContent(String htmlContent) {
String simplified = String simplified =
RegexPatternUtils.getInstance() RegexPatternUtils.getInstance()
.getScriptTagPattern() .getScriptTagPattern()
File diff suppressed because it is too large Load Diff
@@ -29,7 +29,7 @@ public class FileMonitor {
private final ConcurrentHashMap.KeySetView<Path, Boolean> readyForProcessingFiles; private final ConcurrentHashMap.KeySetView<Path, Boolean> readyForProcessingFiles;
private final WatchService watchService; private final WatchService watchService;
private final Predicate<Path> pathFilter; private final Predicate<Path> pathFilter;
private final List<Path> rootDirs; private final Path rootDir;
private Set<Path> stagingFiles; private Set<Path> stagingFiles;
/** /**
@@ -47,28 +47,8 @@ public class FileMonitor {
this.pathFilter = pathFilter; this.pathFilter = pathFilter;
this.readyForProcessingFiles = ConcurrentHashMap.newKeySet(); this.readyForProcessingFiles = ConcurrentHashMap.newKeySet();
this.watchService = FileSystems.getDefault().newWatchService(); this.watchService = FileSystems.getDefault().newWatchService();
log.info("Monitoring directory: {}", runtimePathConfig.getPipelineWatchedFoldersPath());
List<String> watchedFoldersDirs = runtimePathConfig.getPipelineWatchedFoldersPaths(); this.rootDir = Path.of(runtimePathConfig.getPipelineWatchedFoldersPath());
List<Path> validRootDirs = new ArrayList<>();
for (String pathStr : watchedFoldersDirs) {
try {
Path path = Path.of(pathStr);
validRootDirs.add(path);
log.info("Monitoring directory: {}", path);
} catch (Exception e) {
log.error(
"Failed to initialize monitoring for path '{}': {}",
pathStr,
e.getMessage());
}
}
this.rootDirs = Collections.unmodifiableList(validRootDirs);
if (this.rootDirs.isEmpty()) {
log.error("No valid directories to monitor - FileMonitor will not function");
}
} }
private boolean shouldNotProcess(Path path) { private boolean shouldNotProcess(Path path) {
@@ -105,15 +85,13 @@ public class FileMonitor {
readyForProcessingFiles.clear(); readyForProcessingFiles.clear();
if (path2KeyMapping.isEmpty()) { if (path2KeyMapping.isEmpty()) {
log.warn("Not monitoring any directories; attempting to re-register root paths."); log.warn("not monitoring any directory, even the root directory itself: {}", rootDir);
for (Path rootDir : rootDirs) { if (Files.exists(
if (Files.exists( rootDir)) { // if the root directory exists, re-register the root directory
rootDir)) { // if the root directory exists, re-register the root directory try {
try { recursivelyRegisterEntry(rootDir);
recursivelyRegisterEntry(rootDir); } catch (IOException e) {
} catch (IOException e) { log.error("unable to register monitoring", e);
log.error("unable to register monitoring for {}", rootDir, e);
}
} }
} }
} }
@@ -5,11 +5,13 @@ import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.UncheckedIOException; import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.FileVisitResult;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.stream.Stream; import java.util.stream.Stream;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream; import java.util.zip.ZipInputStream;
@@ -35,11 +37,9 @@ public class FileToPdf {
try (TempFile tempInputFile = try (TempFile tempInputFile =
new TempFile( new TempFile(
tempFileManager, tempFileManager,
fileName.toLowerCase(Locale.ROOT).endsWith(".html") fileName.toLowerCase().endsWith(".html") ? ".html" : ".zip")) {
? ".html"
: ".zip")) {
if (fileName.toLowerCase(Locale.ROOT).endsWith(".html")) { if (fileName.toLowerCase().endsWith(".html")) {
String sanitizedHtml = String sanitizedHtml =
sanitizeHtmlContent( sanitizeHtmlContent(
new String(fileBytes, StandardCharsets.UTF_8), new String(fileBytes, StandardCharsets.UTF_8),
@@ -47,7 +47,7 @@ public class FileToPdf {
Files.write( Files.write(
tempInputFile.getPath(), tempInputFile.getPath(),
sanitizedHtml.getBytes(StandardCharsets.UTF_8)); sanitizedHtml.getBytes(StandardCharsets.UTF_8));
} else if (fileName.toLowerCase(Locale.ROOT).endsWith(".zip")) { } else if (fileName.toLowerCase().endsWith(".zip")) {
Files.write(tempInputFile.getPath(), fileBytes); Files.write(tempInputFile.getPath(), fileBytes);
sanitizeHtmlFilesInZip( sanitizeHtmlFilesInZip(
tempInputFile.getPath(), tempFileManager, customHtmlSanitizer); tempInputFile.getPath(), tempFileManager, customHtmlSanitizer);
@@ -102,8 +102,8 @@ public class FileToPdf {
tempUnzippedDir.getPath().resolve(sanitizeZipFilename(entry.getName())); tempUnzippedDir.getPath().resolve(sanitizeZipFilename(entry.getName()));
if (!entry.isDirectory()) { if (!entry.isDirectory()) {
Files.createDirectories(filePath.getParent()); Files.createDirectories(filePath.getParent());
if (entry.getName().toLowerCase(Locale.ROOT).endsWith(".html") if (entry.getName().toLowerCase().endsWith(".html")
|| entry.getName().toLowerCase(Locale.ROOT).endsWith(".htm")) { || entry.getName().toLowerCase().endsWith(".htm")) {
String content = String content =
new String(zipIn.readAllBytes(), StandardCharsets.UTF_8); new String(zipIn.readAllBytes(), StandardCharsets.UTF_8);
String sanitizedContent = String sanitizedContent =
@@ -145,6 +145,64 @@ public class FileToPdf {
} }
} }
private static void deleteDirectory(Path dir) throws IOException {
Files.walkFileTree(
dir,
new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc)
throws IOException {
Files.delete(dir);
return FileVisitResult.CONTINUE;
}
});
}
private static Path unzipAndGetMainHtml(byte[] fileBytes) throws IOException {
Path tempDirectory = Files.createTempDirectory("unzipped_");
try (ZipInputStream zipIn =
ZipSecurity.createHardenedInputStream(new ByteArrayInputStream(fileBytes))) {
ZipEntry entry = zipIn.getNextEntry();
while (entry != null) {
Path filePath = tempDirectory.resolve(sanitizeZipFilename(entry.getName()));
if (entry.isDirectory()) {
Files.createDirectories(filePath); // Explicitly create the directory structure
} else {
Files.createDirectories(
filePath.getParent()); // Create parent directories if they don't exist
Files.copy(zipIn, filePath);
}
zipIn.closeEntry();
entry = zipIn.getNextEntry();
}
}
// Search for the main HTML file.
try (Stream<Path> walk = Files.walk(tempDirectory)) {
List<Path> htmlFiles = walk.filter(file -> file.toString().endsWith(".html")).toList();
if (htmlFiles.isEmpty()) {
throw new IOException("No HTML files found in the unzipped directory.");
}
// Prioritize 'index.html' if it exists, otherwise use the first .html file
for (Path htmlFile : htmlFiles) {
if ("index.html".equals(htmlFile.getFileName().toString())) {
return htmlFile;
}
}
return htmlFiles.get(0);
}
}
static String sanitizeZipFilename(String entryName) { static String sanitizeZipFilename(String entryName) {
if (entryName == null || entryName.trim().isEmpty()) { if (entryName == null || entryName.trim().isEmpty()) {
return ""; return "";
@@ -1,165 +0,0 @@
package stirling.software.common.util;
import java.io.IOException;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDListBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import lombok.extern.slf4j.Slf4j;
/**
* Simplified form field type support for general PDF operations. This is a subset of the full
* proprietary FormFieldTypeSupport, containing only what's needed for basic form field copying
* during page operations.
*/
@Slf4j
public enum GeneralFormFieldTypeSupport {
TEXT("text", "textField", PDTextField.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
PDTextField textField = new PDTextField(acroForm);
textField.setDefaultAppearance("/Helv 12 Tf 0 g");
return textField;
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDTextField src = (PDTextField) source;
PDTextField dst = (PDTextField) target;
String value = src.getValueAsString();
if (value != null) {
dst.setValue(value);
}
}
},
CHECKBOX("checkbox", "checkBox", PDCheckBox.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDCheckBox(acroForm);
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDCheckBox src = (PDCheckBox) source;
PDCheckBox dst = (PDCheckBox) target;
if (src.isChecked()) {
dst.check();
} else {
dst.unCheck();
}
}
},
RADIO("radio", "radioButton", PDRadioButton.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDRadioButton(acroForm);
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDRadioButton src = (PDRadioButton) source;
PDRadioButton dst = (PDRadioButton) target;
if (src.getExportValues() != null) {
dst.setExportValues(src.getExportValues());
}
if (src.getValue() != null) {
dst.setValue(src.getValue());
}
}
},
COMBOBOX("combobox", "comboBox", PDComboBox.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDComboBox(acroForm);
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDComboBox src = (PDComboBox) source;
PDComboBox dst = (PDComboBox) target;
if (src.getOptions() != null) {
dst.setOptions(src.getOptions());
}
if (src.getValue() != null && !src.getValue().isEmpty()) {
dst.setValue(src.getValue());
}
}
},
LISTBOX("listbox", "listBox", PDListBox.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDListBox(acroForm);
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDListBox src = (PDListBox) source;
PDListBox dst = (PDListBox) target;
if (src.getOptions() != null) {
dst.setOptions(src.getOptions());
}
if (src.getValue() != null && !src.getValue().isEmpty()) {
dst.setValue(src.getValue());
}
}
},
SIGNATURE("signature", "signature", PDSignatureField.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDSignatureField(acroForm);
}
},
BUTTON("button", "pushButton", PDPushButton.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDPushButton(acroForm);
}
};
private final String typeName;
private final String fallbackWidgetName;
private final Class<? extends PDTerminalField> fieldClass;
GeneralFormFieldTypeSupport(
String typeName,
String fallbackWidgetName,
Class<? extends PDTerminalField> fieldClass) {
this.typeName = typeName;
this.fallbackWidgetName = fallbackWidgetName;
this.fieldClass = fieldClass;
}
public static GeneralFormFieldTypeSupport forField(PDField field) {
if (field == null) {
return null;
}
for (GeneralFormFieldTypeSupport handler : values()) {
if (handler.fieldClass.isInstance(field)) {
return handler;
}
}
return null;
}
String typeName() {
return typeName;
}
String fallbackWidgetName() {
return fallbackWidgetName;
}
abstract PDTerminalField createField(PDAcroForm acroForm);
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
// default no-op
}
}
@@ -4,8 +4,6 @@ import java.io.File;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.*; import java.net.*;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.*; import java.nio.file.*;
@@ -35,15 +33,6 @@ import stirling.software.common.configuration.InstallationPathConfig;
@UtilityClass @UtilityClass
public class GeneralUtils { public class GeneralUtils {
/**
* Maximum number of resolved DNS addresses allowed for a host before it is considered unsafe.
*/
private static final int MAX_DNS_ADDRESSES = 20;
// Constants for size conversion
private static final BigDecimal KIB = BigDecimal.valueOf(1024L);
private static final BigDecimal LONG_MAX_DECIMAL = BigDecimal.valueOf(Long.MAX_VALUE);
private final Set<String> DEFAULT_VALID_SCRIPTS = Set.of("png_to_webp.py", "split_photos.py"); private final Set<String> DEFAULT_VALID_SCRIPTS = Set.of("png_to_webp.py", "split_photos.py");
private final Set<String> DEFAULT_VALID_PIPELINE = private final Set<String> DEFAULT_VALID_PIPELINE =
Set.of( Set.of(
@@ -97,18 +86,36 @@ public class GeneralUtils {
while ((bytesRead = inputStream.read(buffer)) != -1) { while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead); outputStream.write(buffer, 0, bytesRead);
} }
} catch (IOException e) {
if (tempFile.exists()) {
try {
Files.delete(tempFile.toPath());
} catch (IOException ignored) {
}
}
throw e;
} }
return tempFile; return tempFile;
} }
/*
* Gets the configured temporary directory, creating it if necessary.
*
* @return Path to the temporary directory
* @throws IOException if directory creation fails
*/
private Path getTempDirectory() throws IOException {
String customTempDir = System.getenv("STIRLING_TEMPFILES_DIRECTORY");
if (customTempDir == null || customTempDir.isEmpty()) {
customTempDir = System.getProperty("stirling.tempfiles.directory");
}
Path tempDir;
if (customTempDir != null && !customTempDir.isEmpty()) {
tempDir = Path.of(customTempDir);
} else {
tempDir = Path.of(System.getProperty("java.io.tmpdir"), "stirling-pdf");
}
if (!Files.exists(tempDir)) {
Files.createDirectories(tempDir);
}
return tempDir;
}
/* /*
* Remove file extension * Remove file extension
* *
@@ -147,7 +154,7 @@ public class GeneralUtils {
return matcher.find() ? matcher.replaceFirst("") : filename; return matcher.find() ? matcher.replaceFirst("") : filename;
} }
/** /*
* Append suffix to base name with null safety. * Append suffix to base name with null safety.
* *
* @param baseName the base filename, null becomes "default" * @param baseName the base filename, null becomes "default"
@@ -158,7 +165,7 @@ public class GeneralUtils {
return (baseName == null ? "default" : baseName) + (suffix != null ? suffix : ""); return (baseName == null ? "default" : baseName) + (suffix != null ? suffix : "");
} }
/** /*
* Generate a PDF filename by removing extension from first file and adding suffix. * Generate a PDF filename by removing extension from first file and adding suffix.
* *
* <p>High-level utility method for common PDF naming scenarios. Handles null safety and uses * <p>High-level utility method for common PDF naming scenarios. Handles null safety and uses
@@ -173,7 +180,7 @@ public class GeneralUtils {
return appendSuffix(baseName, suffix); return appendSuffix(baseName, suffix);
} }
/** /*
* Process a list of filenames by removing extensions and adding suffix. * Process a list of filenames by removing extensions and adding suffix.
* *
* <p>Efficiently processes multiple filenames using streaming operations and bulk operations * <p>Efficiently processes multiple filenames using streaming operations and bulk operations
@@ -194,7 +201,7 @@ public class GeneralUtils {
.forEach(processor); .forEach(processor);
} }
/** /*
* Extract title from filename by removing extension, with fallback handling. * Extract title from filename by removing extension, with fallback handling.
* *
* <p>Returns "Untitled" for null or empty filenames, otherwise removes the extension using the * <p>Returns "Untitled" for null or empty filenames, otherwise removes the extension using the
@@ -262,12 +269,6 @@ public class GeneralUtils {
.getResources(pattern); .getResources(pattern);
} }
/**
* Validates URL syntax and disallows common-infrastructure targets to reduce SSRF risk.
*
* @param urlStr a URL string to validate
* @return {@code true} if the URL is syntactically valid and allowed; {@code false} otherwise
*/
public boolean isValidURL(String urlStr) { public boolean isValidURL(String urlStr) {
try { try {
Urls.create( Urls.create(
@@ -278,7 +279,7 @@ public class GeneralUtils {
} }
} }
/** /*
* Checks if a URL is reachable with proper timeout configuration and error handling. * Checks if a URL is reachable with proper timeout configuration and error handling.
* *
* @param urlStr the URL string to check * @param urlStr the URL string to check
@@ -288,17 +289,15 @@ public class GeneralUtils {
return isURLReachable(urlStr, 5000, 5000); return isURLReachable(urlStr, 5000, 5000);
} }
/** /*
* Checks whether a URL is reachable using configurable timeouts. Only {@code http} and {@code * Checks if a URL is reachable with configurable timeouts.
* https} protocols are permitted, and local/private/multicast ranges are blocked.
* *
* @param urlStr the URL to probe * @param urlStr the URL string to check
* @param connectTimeout connection timeout in milliseconds * @param connectTimeout connection timeout in milliseconds
* @param readTimeout read timeout in milliseconds * @param readTimeout read timeout in milliseconds
* @return {@code true} if a HEAD request returns a 2xx or 3xx status; {@code false} otherwise * @return true if URL is reachable, false otherwise
*/ */
public boolean isURLReachable(String urlStr, int connectTimeout, int readTimeout) { public boolean isURLReachable(String urlStr, int connectTimeout, int readTimeout) {
HttpURLConnection connection = null;
try { try {
// Parse the URL // Parse the URL
URL url = URI.create(urlStr).toURL(); URL url = URI.create(urlStr).toURL();
@@ -309,17 +308,14 @@ public class GeneralUtils {
return false; // Disallow other protocols return false; // Disallow other protocols
} }
// Check if the host is a local address
String host = url.getHost(); String host = url.getHost();
if (host == null || host.isBlank()) { if (isLocalAddress(host)) {
return false; return false; // Exclude local addresses
}
if (isDisallowedNetworkLocation(host)) {
return false; // Exclude local, private or otherwise sensitive addresses
} }
// Check if the URL is reachable // Check if the URL is reachable
connection = (HttpURLConnection) url.openConnection(); HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("HEAD"); connection.setRequestMethod("HEAD");
connection.setConnectTimeout(connectTimeout); connection.setConnectTimeout(connectTimeout);
connection.setReadTimeout(readTimeout); connection.setReadTimeout(readTimeout);
@@ -330,173 +326,29 @@ public class GeneralUtils {
} catch (Exception e) { } catch (Exception e) {
log.debug("URL {} is not reachable: {}", urlStr, e.getMessage()); log.debug("URL {} is not reachable: {}", urlStr, e.getMessage());
return false; // Return false in case of any exception return false; // Return false in case of any exception
} finally {
if (connection != null) {
connection.disconnect();
}
} }
} }
/** private boolean isLocalAddress(String host) {
* Determines whether the specified host resolves to a disallowed network location, such as
* local, private, multicast, or reserved ranges. Excessive DNS results are also blocked.
*
* @param host the hostname to resolve
* @return {@code true} if the host should be considered unsafe
*/
private boolean isDisallowedNetworkLocation(String host) {
// Resolution is delegated to the JVM/OS resolver which already applies system
// configured query limits and timeouts. We only need the resolved addresses here so
// that we can enforce the MAX_DNS_ADDRESSES limit and perform the sensitive range
// checks below.
try { try {
InetAddress[] addresses = InetAddress.getAllByName(host); // Resolve DNS to IP address
if (addresses.length > MAX_DNS_ADDRESSES) { InetAddress address = InetAddress.getByName(host);
log.debug(
"Blocking URL to host {} due to excessive DNS records (>{})", // Check for local addresses
host, return address.isAnyLocalAddress()
MAX_DNS_ADDRESSES); || // Matches 0.0.0.0 or similar
return true; address.isLoopbackAddress()
} || // Matches 127.0.0.1 or ::1
for (InetAddress address : addresses) { address.isSiteLocalAddress()
if (address == null || isSensitiveAddress(address)) { || // Matches private IPv4 ranges: 192.168.x.x, 10.x.x.x, 172.16.x.x to
log.debug("Blocking URL to host {} resolved to {}", host, address); // 172.31.x.x
return true; address.getHostAddress()
} .startsWith("fe80:"); // Matches link-local IPv6 addresses
}
return false;
} catch (Exception e) { } catch (Exception e) {
log.debug("Unable to resolve host {}: {}", host, e.getMessage()); return false; // Return false for invalid or unresolved addresses
return true; // Treat resolution issues as unsafe to avoid SSRF
} }
} }
/**
* Returns whether the given IP address lies within ranges that should not be contacted by the
* server (loopback, link-local, private, multicast, etc.). IPv6 ULA and IPv4-mapped addresses
* are handled.
*
* @param address the resolved address
* @return {@code true} if the address is considered sensitive
*/
private boolean isSensitiveAddress(InetAddress address) {
if (address.isAnyLocalAddress()
|| address.isLoopbackAddress()
|| address.isLinkLocalAddress()
|| address.isSiteLocalAddress()
|| address.isMulticastAddress()) {
return true;
}
byte[] rawAddress = address.getAddress();
if (address instanceof Inet4Address) {
return isPrivateOrReservedIPv4(rawAddress);
}
if (address instanceof Inet6Address inet6Address) {
if (isUniqueLocalIPv6(rawAddress)) {
return true;
}
if (isIPv4MappedAddress(rawAddress) || inet6Address.isIPv4CompatibleAddress()) {
byte[] ipv4 =
Arrays.copyOfRange(rawAddress, rawAddress.length - 4, rawAddress.length);
return isPrivateOrReservedIPv4(ipv4);
}
}
return false;
}
/**
* Checks whether an IPv4 address is private or reserved. Any malformed input defaults to {@code
* true} (conservative) to avoid misuse.
*
* @param address 4-byte IPv4 address
* @return {@code true} if private/reserved
*/
private boolean isPrivateOrReservedIPv4(byte[] address) {
// IPv4 addresses must be exactly 4 bytes. Treat null or unexpected lengths as
// sensitive to avoid processing malformed input.
if (address == null || address.length != 4) {
return true;
}
int first = Byte.toUnsignedInt(address[0]);
int second = Byte.toUnsignedInt(address[1]);
if (first == 0 || first == 127) {
return true; // 0.0.0.0/8 and 127.0.0.0/8
}
if (first == 100 && second >= 64 && second <= 127) {
return true; // 100.64.0.0/10 Carrier-grade NAT
}
if (first == 169 && second == 254) {
return true; // 169.254.0.0/16 Link-local
}
if (first == 172 && second >= 16 && second <= 31) {
return true; // 172.16.0.0/12 Private
}
if (first == 192 && second == 0 && Byte.toUnsignedInt(address[2]) == 0) {
return true; // 192.0.0.0/24 IETF Protocol Assignments
}
if (first == 192 && second == 0 && Byte.toUnsignedInt(address[2]) == 2) {
return true; // 192.0.2.0/24 TEST-NET-1
}
if (first == 192 && second == 168) {
return true; // 192.168.0.0/16 Private
}
if (first == 198 && (second == 18 || second == 19)) {
return true; // 198.18.0.0/15 Benchmark tests
}
if (first == 198 && second == 51 && Byte.toUnsignedInt(address[2]) == 100) {
return true; // 198.51.100.0/24 TEST-NET-2
}
if (first == 203 && second == 0 && Byte.toUnsignedInt(address[2]) == 113) {
return true; // 203.0.113.0/24 TEST-NET-3
}
if (first == 10) {
return true; // 10.0.0.0/8 Private
}
if (first >= 224) {
return true; // 224.0.0.0/4 Multicast and 240.0.0.0/4 Reserved for future use
}
return false;
}
/**
* Checks whether an IPv6 address is a Unique Local Address (ULA, fc00::/7). Any malformed input
* defaults to {@code true} (conservative) to avoid misuse.
*
* @param address 16-byte IPv6 address
* @return {@code true} if ULA
*/
private boolean isUniqueLocalIPv6(byte[] address) {
if (address == null || address.length != 16) {
return true;
}
int first = Byte.toUnsignedInt(address[0]);
return (first & 0xFE) == 0xFC; // fc00::/7 Unique local addresses
}
/**
* Checks whether an IPv6 address is an IPv4-mapped address (::ffff:0:0/96). Any malformed input
* defaults to {@code false} (conservative) to avoid misuse.
*
* @param address 16-byte IPv6 address
* @return {@code true} if IPv4-mapped
*/
private boolean isIPv4MappedAddress(byte[] address) {
if (address == null || address.length != 16) {
return false;
}
for (int i = 0; i < 10; i++) {
if (address[i] != 0) {
return false;
}
}
return address[10] == (byte) 0xFF && address[11] == (byte) 0xFF;
}
/* /*
* Improved multipart file conversion using the shared helper method. * Improved multipart file conversion using the shared helper method.
* *
@@ -513,12 +365,6 @@ public class GeneralUtils {
while ((bytesRead = in.read(buffer)) != -1) { while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead); out.write(buffer, 0, bytesRead);
} }
} catch (IOException e) {
try {
Files.deleteIfExists(tempFile);
} catch (IOException ignored) {
}
throw e;
} }
return tempFile.toFile(); return tempFile.toFile();
} }
@@ -540,31 +386,44 @@ public class GeneralUtils {
throw new IllegalArgumentException("Invalid default unit: " + defaultUnit); throw new IllegalArgumentException("Invalid default unit: " + defaultUnit);
} }
sizeStr = sizeStr.trim().toUpperCase(Locale.ROOT); sizeStr = sizeStr.trim().toUpperCase();
sizeStr = sizeStr.replace(",", ".").replace(" ", ""); sizeStr = sizeStr.replace(",", ".").replace(" ", "");
try { try {
if (sizeStr.endsWith("TB")) { if (sizeStr.endsWith("TB")) {
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 2)), 4); return (long)
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
* 1024L
* 1024L
* 1024L
* 1024L);
} else if (sizeStr.endsWith("GB")) { } else if (sizeStr.endsWith("GB")) {
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 2)), 3); return (long)
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
* 1024L
* 1024L
* 1024L);
} else if (sizeStr.endsWith("MB")) { } else if (sizeStr.endsWith("MB")) {
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 2)), 2); return (long)
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
* 1024L
* 1024L);
} else if (sizeStr.endsWith("KB")) { } else if (sizeStr.endsWith("KB")) {
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 2)), 1); return (long)
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2)) * 1024L);
} else if (!sizeStr.isEmpty() && sizeStr.charAt(sizeStr.length() - 1) == 'B') { } else if (!sizeStr.isEmpty() && sizeStr.charAt(sizeStr.length() - 1) == 'B') {
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 1)), 0); return Long.parseLong(sizeStr.substring(0, sizeStr.length() - 1));
} else { } else {
// Use provided default unit or fall back to MB // Use provided default unit or fall back to MB
String unit = defaultUnit != null ? defaultUnit.toUpperCase(Locale.ROOT) : "MB"; String unit = defaultUnit != null ? defaultUnit.toUpperCase() : "MB";
BigDecimal value = parseSizeValue(sizeStr); double value = Double.parseDouble(sizeStr);
return switch (unit) { return switch (unit) {
case "TB" -> toBytes(value, 4); case "TB" -> (long) (value * 1024L * 1024L * 1024L * 1024L);
case "GB" -> toBytes(value, 3); case "GB" -> (long) (value * 1024L * 1024L * 1024L);
case "MB" -> toBytes(value, 2); case "MB" -> (long) (value * 1024L * 1024L);
case "KB" -> toBytes(value, 1); case "KB" -> (long) (value * 1024L);
case "B" -> toBytes(value, 0); case "B" -> (long) value;
default -> toBytes(value, 2); // Default to MB default -> (long) (value * 1024L * 1024L); // Default to MB
}; };
} }
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
@@ -583,30 +442,6 @@ public class GeneralUtils {
return convertSizeToBytes(sizeStr, "MB"); return convertSizeToBytes(sizeStr, "MB");
} }
private Long toBytes(BigDecimal value, int powerOf1024) {
if (value == null) {
return null;
}
if (value.compareTo(BigDecimal.ZERO) < 0) {
log.warn("Size value cannot be negative: {}", value);
return null;
}
if (powerOf1024 < 0 || powerOf1024 > 4) {
throw new IllegalArgumentException("Invalid power for size conversion: " + powerOf1024);
}
BigDecimal multiplier = powerOf1024 == 0 ? BigDecimal.ONE : KIB.pow(powerOf1024);
BigDecimal bytes = value.multiply(multiplier).setScale(0, RoundingMode.DOWN);
if (bytes.compareTo(LONG_MAX_DECIMAL) > 0) {
log.warn("Size value too large to fit in long: {}", bytes);
return null;
}
return bytes.longValue();
}
private BigDecimal parseSizeValue(String value) {
return new BigDecimal(value);
}
/* Validates if a string represents a valid size unit. */ /* Validates if a string represents a valid size unit. */
private boolean isValidSizeUnit(String unit) { private boolean isValidSizeUnit(String unit) {
// Use a precomputed Set for O(1) lookup, normalize using a locale-safe toUpperCase // Use a precomputed Set for O(1) lookup, normalize using a locale-safe toUpperCase
@@ -622,14 +457,13 @@ public class GeneralUtils {
if (bytes < 1024) { if (bytes < 1024) {
return bytes + " B"; return bytes + " B";
} else if (bytes < 1024L * 1024L) { } else if (bytes < 1024L * 1024L) {
return String.format(Locale.ROOT, "%.2f KB", bytes / 1024.0); return String.format(Locale.US, "%.2f KB", bytes / 1024.0);
} else if (bytes < 1024L * 1024L * 1024L) { } else if (bytes < 1024L * 1024L * 1024L) {
return String.format(Locale.ROOT, "%.2f MB", bytes / (1024.0 * 1024.0)); return String.format(Locale.US, "%.2f MB", bytes / (1024.0 * 1024.0));
} else if (bytes < 1024L * 1024L * 1024L * 1024L) { } else if (bytes < 1024L * 1024L * 1024L * 1024L) {
return String.format(Locale.ROOT, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0)); return String.format(Locale.US, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0));
} else { } else {
return String.format( return String.format(Locale.US, "%.2f TB", bytes / (1024.0 * 1024.0 * 1024.0 * 1024.0));
Locale.ROOT, "%.2f TB", bytes / (1024.0 * 1024.0 * 1024.0 * 1024.0));
} }
} }
@@ -889,7 +723,7 @@ public class GeneralUtils {
byte[] mac = net.getHardwareAddress(); byte[] mac = net.getHardwareAddress();
if (mac != null && mac.length > 0) { if (mac != null && mac.length > 0) {
for (byte b : mac) { for (byte b : mac) {
sb.append(String.format(Locale.ROOT, "%02X", b)); sb.append(String.format("%02X", b));
} }
break; // Use the first valid network interface break; // Use the first valid network interface
} }
@@ -899,7 +733,7 @@ public class GeneralUtils {
byte[] mac = network.getHardwareAddress(); byte[] mac = network.getHardwareAddress();
if (mac != null) { if (mac != null) {
for (byte b : mac) { for (byte b : mac) {
sb.append(String.format(Locale.ROOT, "%02X", b)); sb.append(String.format("%02X", b));
} }
} }
} }
@@ -1106,29 +940,17 @@ public class GeneralUtils {
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT) ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
.runCommandWithOutputHandling(command); .runCommandWithOutputHandling(command);
ExceptionUtils.GhostscriptException detectedError =
ExceptionUtils.detectGhostscriptCriticalError(result.getMessages());
if (detectedError != null) {
log.warn(
"Ghostscript ebook optimization reported a critical error: {}",
detectedError.getMessage());
throw detectedError;
}
if (result.getRc() != 0) { if (result.getRc() != 0) {
log.warn( log.warn(
"Ghostscript ebook optimization failed with return code: {}", "Ghostscript ebook optimization failed with return code: {}",
result.getRc()); result.getRc());
throw ExceptionUtils.createGhostscriptCompressionException(result.getMessages()); throw ExceptionUtils.createGhostscriptCompressionException();
} }
return Files.readAllBytes(tempOutput); return Files.readAllBytes(tempOutput);
} catch (Exception e) { } catch (Exception e) {
log.warn("Ghostscript ebook optimization failed", e); log.warn("Ghostscript ebook optimization failed", e);
if (e instanceof ExceptionUtils.GhostscriptException ghostscriptException) {
throw ghostscriptException;
}
throw ExceptionUtils.createGhostscriptCompressionException(e); throw ExceptionUtils.createGhostscriptCompressionException(e);
} finally { } finally {
if (tempInput != null) { if (tempInput != null) {
@@ -6,7 +6,6 @@ import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.util.Iterator; import java.util.Iterator;
import java.util.Locale;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
import javax.imageio.ImageReader; import javax.imageio.ImageReader;
@@ -63,16 +62,15 @@ public class ImageProcessingUtils {
} else { } else {
int width = image.getWidth(); int width = image.getWidth();
int height = image.getHeight(); 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]; byte[] data = new byte[width * height * 3];
int index = 0; int index = 0;
for (int rgb : pixels) { for (int y = 0; y < height; y++) {
data[index++] = (byte) ((rgb >> 16) & 0xFF); // Red for (int x = 0; x < width; x++) {
data[index++] = (byte) ((rgb >> 8) & 0xFF); // Green int rgb = image.getRGB(x, y);
data[index++] = (byte) (rgb & 0xFF); // Blue data[index++] = (byte) ((rgb >> 16) & 0xFF); // Red
data[index++] = (byte) ((rgb >> 8) & 0xFF); // Green
data[index++] = (byte) (rgb & 0xFF); // Blue
}
} }
return data; return data;
} }
@@ -120,7 +118,7 @@ public class ImageProcessingUtils {
BufferedImage image = null; BufferedImage image = null;
String filename = file.getOriginalFilename(); String filename = file.getOriginalFilename();
if (filename != null && filename.toLowerCase(Locale.ROOT).endsWith(".psd")) { if (filename != null && filename.toLowerCase().endsWith(".psd")) {
// For PSD files, try explicit ImageReader // For PSD files, try explicit ImageReader
Iterator<ImageReader> readers = ImageIO.getImageReadersByFormatName("PSD"); Iterator<ImageReader> readers = ImageIO.getImageReadersByFormatName("PSD");
if (readers.hasNext()) { if (readers.hasNext()) {
@@ -136,8 +134,7 @@ public class ImageProcessingUtils {
throw new IOException( throw new IOException(
"Unable to read image from file: " "Unable to read image from file: "
+ filename + filename
+ ". Supported PSD formats: RGB/CMYK/Gray 8-32 bit, RLE/ZIP" + ". Supported PSD formats: RGB/CMYK/Gray 8-32 bit, RLE/ZIP compression");
+ " compression");
} }
} else { } else {
// For non-PSD files, use standard ImageIO // For non-PSD files, use standard ImageIO
@@ -145,7 +142,7 @@ public class ImageProcessingUtils {
} }
if (image == null) { if (image == null) {
throw ExceptionUtils.createImageReadException(filename); throw new IOException("Unable to read image from file: " + filename);
} }
double orientation = extractImageOrientation(file.getInputStream()); double orientation = extractImageOrientation(file.getInputStream());
@@ -27,22 +27,15 @@ import io.github.pixee.security.Filenames;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
@Slf4j @Slf4j
public class PDFToFile { public class PDFToFile {
private final TempFileManager tempFileManager; private final TempFileManager tempFileManager;
private final RuntimePathConfig runtimePathConfig;
public PDFToFile(TempFileManager tempFileManager) { public PDFToFile(TempFileManager tempFileManager) {
this(tempFileManager, null);
}
public PDFToFile(TempFileManager tempFileManager, RuntimePathConfig runtimePathConfig) {
this.tempFileManager = tempFileManager; this.tempFileManager = tempFileManager;
this.runtimePathConfig = runtimePathConfig;
} }
public ResponseEntity<byte[]> processPdfToMarkdown(MultipartFile inputFile) public ResponseEntity<byte[]> processPdfToMarkdown(MultipartFile inputFile)
@@ -107,65 +100,56 @@ public class PDFToFile {
File[] outputFiles = File[] outputFiles =
Objects.requireNonNull(tempOutputDir.getPath().toFile().listFiles()); Objects.requireNonNull(tempOutputDir.getPath().toFile().listFiles());
List<File> markdownFiles = new ArrayList<>(); List<File> markdownFiles = new ArrayList<>();
List<File> imageFiles = new ArrayList<>();
// Convert HTML files to Markdown and collect image files // Convert HTML files to Markdown
for (File outputFile : outputFiles) { for (File outputFile : outputFiles) {
if (outputFile.getName().endsWith(".html")) { if (outputFile.getName().endsWith(".html")) {
String html = Files.readString(outputFile.toPath()); String html = Files.readString(outputFile.toPath());
String markdown = htmlToMarkdownConverter.convert(html); String markdown = htmlToMarkdownConverter.convert(html);
// Update image references to point to images/ folder
markdown = updateImageReferences(markdown);
String mdFileName = outputFile.getName().replace(".html", ".md"); String mdFileName = outputFile.getName().replace(".html", ".md");
File mdFile = new File(tempOutputDir.getPath().toFile(), mdFileName); File mdFile = new File(tempOutputDir.getPath().toFile(), mdFileName);
Files.writeString(mdFile.toPath(), markdown); Files.writeString(mdFile.toPath(), markdown);
markdownFiles.add(mdFile); markdownFiles.add(mdFile);
} else if (!outputFile.getName().endsWith(".md")) {
// Collect non-HTML, non-MD files as images/assets
imageFiles.add(outputFile);
} }
} }
// Always create a ZIP file // If there's only one markdown file, return it directly
fileName = pdfBaseName + "ToMarkdown.zip"; if (markdownFiles.size() == 1) {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); fileName = pdfBaseName + ".md";
fileBytes = Files.readAllBytes(markdownFiles.get(0).toPath());
} else {
// Multiple files - create a zip
fileName = pdfBaseName + "ToMarkdown.zip";
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
try (ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream)) { try (ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream)) {
// Add markdown files to root of ZIP // Add markdown files
for (File mdFile : markdownFiles) { for (File mdFile : markdownFiles) {
ZipEntry mdEntry = new ZipEntry(mdFile.getName()); ZipEntry mdEntry = new ZipEntry(mdFile.getName());
zipOutputStream.putNextEntry(mdEntry); zipOutputStream.putNextEntry(mdEntry);
Files.copy(mdFile.toPath(), zipOutputStream); Files.copy(mdFile.toPath(), zipOutputStream);
zipOutputStream.closeEntry(); zipOutputStream.closeEntry();
}
// Add images and other assets
for (File file : outputFiles) {
if (!file.getName().endsWith(".html") && !file.getName().endsWith(".md")) {
ZipEntry assetEntry = new ZipEntry(file.getName());
zipOutputStream.putNextEntry(assetEntry);
Files.copy(file.toPath(), zipOutputStream);
zipOutputStream.closeEntry();
}
}
} }
// Add images and other assets to images/ folder fileBytes = byteArrayOutputStream.toByteArray();
for (File imageFile : imageFiles) {
ZipEntry assetEntry = new ZipEntry("images/" + imageFile.getName());
zipOutputStream.putNextEntry(assetEntry);
Files.copy(imageFile.toPath(), zipOutputStream);
zipOutputStream.closeEntry();
}
} }
fileBytes = byteArrayOutputStream.toByteArray();
} }
return WebResponseUtils.bytesToWebResponse( return WebResponseUtils.bytesToWebResponse(
fileBytes, fileName, MediaType.APPLICATION_OCTET_STREAM); fileBytes, fileName, MediaType.APPLICATION_OCTET_STREAM);
} }
/**
* Updates image references in markdown to point to the images/ folder. Matches patterns like
* ![alt](filename.png) and converts to ![alt](images/filename.png)
*/
private String updateImageReferences(String markdown) {
// Match markdown image syntax: ![alt text](image.png)
// Only update if the path doesn't already start with images/
return markdown.replaceAll("(!\\[.*?\\])\\((?!images/)([^/)][^)]*?)\\)", "$1(images/$2)");
}
public ResponseEntity<byte[]> processPdfToHtml(MultipartFile inputFile) public ResponseEntity<byte[]> processPdfToHtml(MultipartFile inputFile)
throws IOException, InterruptedException { throws IOException, InterruptedException {
if (!MediaType.APPLICATION_PDF_VALUE.equals(inputFile.getContentType())) { if (!MediaType.APPLICATION_PDF_VALUE.equals(inputFile.getContentType())) {
@@ -257,65 +241,31 @@ public class PDFToFile {
byte[] fileBytes; byte[] fileBytes;
String fileName; String fileName;
Path libreOfficeProfile = null;
try (TempFile inputFileTemp = new TempFile(tempFileManager, ".pdf"); try (TempFile inputFileTemp = new TempFile(tempFileManager, ".pdf");
TempDirectory outputDirTemp = new TempDirectory(tempFileManager)) { TempDirectory outputDirTemp = new TempDirectory(tempFileManager)) {
Path tempInputFile = inputFileTemp.getPath(); Path tempInputFile = inputFileTemp.getPath();
Path tempOutputDir = outputDirTemp.getPath(); Path tempOutputDir = outputDirTemp.getPath();
Path unoOutputFile =
tempOutputDir.resolve(
pdfBaseName + "." + resolvePrimaryExtension(outputFormat));
// Save the uploaded file to a temporary location // Save the uploaded file to a temporary location
inputFile.transferTo(tempInputFile); inputFile.transferTo(tempInputFile);
// Run the LibreOffice command // Run the LibreOffice command
ProcessExecutorResult returnCode = null; List<String> command =
IOException unoconvertException = null; new ArrayList<>(
Arrays.asList(
if (isUnoConvertEnabled()) { "soffice",
try { "--headless",
List<String> unoCommand = "--nologo",
buildUnoConvertCommand( "--infilter=" + libreOfficeFilter,
tempInputFile, unoOutputFile, outputFormat, libreOfficeFilter); "--convert-to",
returnCode = outputFormat,
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE) "--outdir",
.runCommandWithOutputHandling(unoCommand); tempOutputDir.toString(),
} catch (IOException e) { tempInputFile.toString()));
unoconvertException = e; ProcessExecutorResult returnCode =
log.warn( ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
"Unoconvert command failed ({}). Falling back to soffice command.", .runCommandWithOutputHandling(command);
e.getMessage());
}
}
if (returnCode == null) {
// Run the LibreOffice command as a fallback
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("--infilter=" + libreOfficeFilter);
command.add("--convert-to");
command.add(outputFormat);
command.add("--outdir");
command.add(tempOutputDir.toString());
command.add(tempInputFile.toString());
try {
returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
.runCommandWithOutputHandling(command);
} catch (IOException e) {
if (unoconvertException != null) {
e.addSuppressed(unoconvertException);
}
throw e;
}
}
// Get output files // Get output files
List<File> outputFiles = Arrays.asList(tempOutputDir.toFile().listFiles()); List<File> outputFiles = Arrays.asList(tempOutputDir.toFile().listFiles());
@@ -350,40 +300,8 @@ public class PDFToFile {
fileBytes = byteArrayOutputStream.toByteArray(); fileBytes = byteArrayOutputStream.toByteArray();
} }
} finally {
if (libreOfficeProfile != null) {
FileUtils.deleteQuietly(libreOfficeProfile.toFile());
}
} }
return WebResponseUtils.bytesToWebResponse( return WebResponseUtils.bytesToWebResponse(
fileBytes, fileName, MediaType.APPLICATION_OCTET_STREAM); fileBytes, fileName, MediaType.APPLICATION_OCTET_STREAM);
} }
private boolean isUnoConvertEnabled() {
return runtimePathConfig != null
&& runtimePathConfig.getUnoConvertPath() != null
&& !runtimePathConfig.getUnoConvertPath().isBlank();
}
private List<String> buildUnoConvertCommand(
Path inputFile, Path outputFile, String outputFormat, String libreOfficeFilter) {
List<String> command = new ArrayList<>();
command.add(runtimePathConfig.getUnoConvertPath());
command.add("--convert-to");
command.add(outputFormat);
if (libreOfficeFilter != null && !libreOfficeFilter.isBlank()) {
command.add("--input-filter=" + libreOfficeFilter);
}
command.add(inputFile.toString());
command.add(outputFile.toString());
return command;
}
private String resolvePrimaryExtension(String outputFormat) {
if (outputFormat == null) {
return "";
}
int colonIndex = outputFormat.indexOf(':');
return colonIndex > 0 ? outputFormat.substring(0, colonIndex) : outputFormat;
}
} }
@@ -318,7 +318,7 @@ public class PdfAttachmentHandler {
private static String normalizeFilename(String filename) { private static String normalizeFilename(String filename) {
if (filename == null) return ""; if (filename == null) return "";
String normalized = filename.toLowerCase(Locale.ROOT).trim(); String normalized = filename.toLowerCase().trim();
normalized = normalized =
RegexPatternUtils.getInstance() RegexPatternUtils.getInstance()
.getWhitespacePattern() .getWhitespacePattern()
@@ -560,7 +560,7 @@ public class PdfAttachmentHandler {
@Override @Override
protected void writeString(String string, List<TextPosition> textPositions) protected void writeString(String string, List<TextPosition> textPositions)
throws IOException { throws IOException {
String lowerString = string.toLowerCase(Locale.ROOT); String lowerString = string.toLowerCase();
if (ATTACHMENT_SECTION_PATTERN.matcher(lowerString).find()) { if (ATTACHMENT_SECTION_PATTERN.matcher(lowerString).find()) {
isInAttachmentSection = true; isInAttachmentSection = true;
@@ -9,7 +9,6 @@ import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Locale;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
@@ -35,7 +34,7 @@ public class PdfToCbrUtils {
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) { try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
if (document.getNumberOfPages() == 0) { if (document.getNumberOfPages() == 0) {
throw ExceptionUtils.createPdfNoPages(); throw new IllegalArgumentException("PDF file contains no pages");
} }
return createCbrFromPdf(document, dpi); return createCbrFromPdf(document, dpi);
@@ -44,23 +43,22 @@ public class PdfToCbrUtils {
private static void validatePdfFile(MultipartFile file) { private static void validatePdfFile(MultipartFile file) {
if (file == null || file.isEmpty()) { if (file == null || file.isEmpty()) {
throw ExceptionUtils.createFileNullOrEmptyException(); throw new IllegalArgumentException("File cannot be null or empty");
} }
String filename = file.getOriginalFilename(); String filename = file.getOriginalFilename();
if (filename == null) { if (filename == null) {
throw ExceptionUtils.createFileNoNameException(); throw new IllegalArgumentException("File must have a name");
} }
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT); String extension = FilenameUtils.getExtension(filename).toLowerCase();
if (!"pdf".equals(extension)) { if (!"pdf".equals(extension)) {
throw ExceptionUtils.createPdfFileRequiredException(); throw new IllegalArgumentException("File must be a PDF");
} }
} }
private static byte[] createCbrFromPdf(PDDocument document, int dpi) throws IOException { private static byte[] createCbrFromPdf(PDDocument document, int dpi) throws IOException {
PDFRenderer pdfRenderer = new PDFRenderer(document); PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
Path tempDir = Files.createTempDirectory("stirling-pdf-cbr-"); Path tempDir = Files.createTempDirectory("stirling-pdf-cbr-");
List<Path> generatedImages = new ArrayList<>(); List<Path> generatedImages = new ArrayList<>();
@@ -68,36 +66,27 @@ public class PdfToCbrUtils {
int totalPages = document.getNumberOfPages(); int totalPages = document.getNumberOfPages();
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) { for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
final int currentPage = pageIndex;
try { try {
BufferedImage image = BufferedImage image =
ExceptionUtils.handleOomRendering( pdfRenderer.renderImageWithDPI(pageIndex, dpi, ImageType.RGB);
currentPage + 1,
dpi,
() ->
pdfRenderer.renderImageWithDPI(
currentPage, dpi, ImageType.RGB));
String imageFilename = String imageFilename = String.format("page_%03d.png", pageIndex + 1);
String.format(Locale.ROOT, "page_%03d.png", currentPage + 1);
Path imagePath = tempDir.resolve(imageFilename); Path imagePath = tempDir.resolve(imageFilename);
ImageIO.write(image, "PNG", imagePath.toFile()); ImageIO.write(image, "PNG", imagePath.toFile());
generatedImages.add(imagePath); generatedImages.add(imagePath);
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
// Re-throw OOM exceptions without wrapping
throw e;
} catch (IOException e) { } catch (IOException e) {
// Wrap other IOExceptions with context log.warn("Error processing page {}: {}", pageIndex + 1, e.getMessage());
throw ExceptionUtils.createFileProcessingException( } catch (OutOfMemoryError e) {
"CBR creation for page " + (currentPage + 1), e); throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
} catch (NegativeArraySizeException e) {
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
} }
} }
if (generatedImages.isEmpty()) { if (generatedImages.isEmpty()) {
throw ExceptionUtils.createFileProcessingException( throw new IOException("Failed to render any pages to images for CBR conversion");
"CBR conversion", new IOException("No pages were successfully rendered"));
} }
return createRarArchive(tempDir, generatedImages); return createRarArchive(tempDir, generatedImages);
@@ -126,18 +115,15 @@ public class PdfToCbrUtils {
ProcessExecutorResult result = ProcessExecutorResult result =
executor.runCommandWithOutputHandling(command, tempDir.toFile()); executor.runCommandWithOutputHandling(command, tempDir.toFile());
if (result.getRc() != 0) { if (result.getRc() != 0) {
throw ExceptionUtils.createFileProcessingException( throw new IOException("RAR command failed: " + result.getMessages());
"RAR archive creation",
new IOException("RAR command failed with code " + result.getRc()));
} }
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
throw ExceptionUtils.createProcessingInterruptedException("RAR creation", e); throw new IOException("RAR command interrupted", e);
} }
if (!Files.exists(rarFile)) { if (!Files.exists(rarFile)) {
throw ExceptionUtils.createFileProcessingException( throw new IOException("RAR file was not created");
"RAR archive creation", new IOException("RAR file was not created"));
} }
try (FileInputStream fis = new FileInputStream(rarFile.toFile()); try (FileInputStream fis = new FileInputStream(rarFile.toFile());
@@ -181,7 +167,7 @@ public class PdfToCbrUtils {
return false; return false;
} }
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT); String extension = FilenameUtils.getExtension(filename).toLowerCase();
return "pdf".equals(extension); return "pdf".equals(extension);
} }
} }
@@ -3,7 +3,6 @@ package stirling.software.common.util;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.util.Locale;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream; import java.util.zip.ZipOutputStream;
@@ -30,7 +29,7 @@ public class PdfToCbzUtils {
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) { try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
if (document.getNumberOfPages() == 0) { if (document.getNumberOfPages() == 0) {
throw ExceptionUtils.createPdfNoPages(); throw new IllegalArgumentException("PDF file contains no pages");
} }
return createCbzFromPdf(document, dpi); return createCbzFromPdf(document, dpi);
@@ -39,23 +38,22 @@ public class PdfToCbzUtils {
private static void validatePdfFile(MultipartFile file) { private static void validatePdfFile(MultipartFile file) {
if (file == null || file.isEmpty()) { if (file == null || file.isEmpty()) {
throw ExceptionUtils.createFileNullOrEmptyException(); throw new IllegalArgumentException("File cannot be null or empty");
} }
String filename = file.getOriginalFilename(); String filename = file.getOriginalFilename();
if (filename == null) { if (filename == null) {
throw ExceptionUtils.createFileNoNameException(); throw new IllegalArgumentException("File must have a name");
} }
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT); String extension = FilenameUtils.getExtension(filename).toLowerCase();
if (!"pdf".equals(extension)) { if (!"pdf".equals(extension)) {
throw ExceptionUtils.createPdfFileRequiredException(); throw new IllegalArgumentException("File must be a PDF");
} }
} }
private static byte[] createCbzFromPdf(PDDocument document, int dpi) throws IOException { private static byte[] createCbzFromPdf(PDDocument document, int dpi) throws IOException {
PDFRenderer pdfRenderer = new PDFRenderer(document); PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
try (ByteArrayOutputStream cbzOutputStream = new ByteArrayOutputStream(); try (ByteArrayOutputStream cbzOutputStream = new ByteArrayOutputStream();
ZipOutputStream zipOut = new ZipOutputStream(cbzOutputStream)) { ZipOutputStream zipOut = new ZipOutputStream(cbzOutputStream)) {
@@ -63,31 +61,24 @@ public class PdfToCbzUtils {
int totalPages = document.getNumberOfPages(); int totalPages = document.getNumberOfPages();
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) { for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
final int currentPage = pageIndex;
try { try {
BufferedImage image = BufferedImage image =
ExceptionUtils.handleOomRendering( pdfRenderer.renderImageWithDPI(pageIndex, dpi, ImageType.RGB);
currentPage + 1,
dpi, String imageFilename = String.format("page_%03d.png", pageIndex + 1);
() ->
pdfRenderer.renderImageWithDPI(
currentPage, dpi, ImageType.RGB));
String imageFilename =
String.format(Locale.ROOT, "page_%03d.png", currentPage + 1);
ZipEntry zipEntry = new ZipEntry(imageFilename); ZipEntry zipEntry = new ZipEntry(imageFilename);
zipOut.putNextEntry(zipEntry); zipOut.putNextEntry(zipEntry);
ImageIO.write(image, "PNG", zipOut); ImageIO.write(image, "PNG", zipOut);
zipOut.closeEntry(); zipOut.closeEntry();
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
// Re-throw OOM exceptions without wrapping
throw e;
} catch (IOException e) { } catch (IOException e) {
// Wrap other IOExceptions with context log.warn("Error processing page {}: {}", pageIndex + 1, e.getMessage());
throw ExceptionUtils.createFileProcessingException( } catch (OutOfMemoryError e) {
"CBZ creation for page " + (currentPage + 1), e); throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
} catch (NegativeArraySizeException e) {
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
} }
} }
@@ -102,7 +93,7 @@ public class PdfToCbzUtils {
return false; return false;
} }
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT); String extension = FilenameUtils.getExtension(filename).toLowerCase();
return "pdf".equals(extension); return "pdf".equals(extension);
} }
} }
@@ -8,7 +8,6 @@ import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
@@ -51,18 +50,36 @@ public class PdfUtils {
public PDRectangle textToPageSize(String size) { public PDRectangle textToPageSize(String size) {
return switch (size.toUpperCase(Locale.ROOT)) { switch (size.toUpperCase()) {
case "A0" -> PDRectangle.A0; case "A0" -> {
case "A1" -> PDRectangle.A1; return PDRectangle.A0;
case "A2" -> PDRectangle.A2; }
case "A3" -> PDRectangle.A3; case "A1" -> {
case "A4" -> PDRectangle.A4; return PDRectangle.A1;
case "A5" -> PDRectangle.A5; }
case "A6" -> PDRectangle.A6; case "A2" -> {
case "LETTER" -> PDRectangle.LETTER; return PDRectangle.A2;
case "LEGAL" -> PDRectangle.LEGAL; }
case "A3" -> {
return PDRectangle.A3;
}
case "A4" -> {
return PDRectangle.A4;
}
case "A5" -> {
return PDRectangle.A5;
}
case "A6" -> {
return PDRectangle.A6;
}
case "LETTER" -> {
return PDRectangle.LETTER;
}
case "LEGAL" -> {
return PDRectangle.LEGAL;
}
default -> throw ExceptionUtils.createInvalidPageSizeException(size); default -> throw ExceptionUtils.createInvalidPageSizeException(size);
}; }
} }
public List<RenderedImage> getAllImages(PDResources resources) throws IOException { public List<RenderedImage> getAllImages(PDResources resources) throws IOException {
@@ -119,11 +136,11 @@ public class PdfUtils {
public boolean hasTextOnPage(PDPage page, String phrase) throws IOException { public boolean hasTextOnPage(PDPage page, String phrase) throws IOException {
PDFTextStripper textStripper = new PDFTextStripper(); PDFTextStripper textStripper = new PDFTextStripper();
try (PDDocument tempDoc = new PDDocument()) { PDDocument tempDoc = new PDDocument();
tempDoc.addPage(page); tempDoc.addPage(page);
String pageText = textStripper.getText(tempDoc); String pageText = textStripper.getText(tempDoc);
return pageText.contains(phrase); tempDoc.close();
} return pageText.contains(phrase);
} }
public byte[] convertFromPdf( public byte[] convertFromPdf(
@@ -153,8 +170,7 @@ public class PdfUtils {
maxSafeDpi); maxSafeDpi);
} }
try (PDDocument document = pdfDocumentFactory.load(inputStream); try (PDDocument document = pdfDocumentFactory.load(inputStream)) {
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
PDFRenderer pdfRenderer = new PDFRenderer(document); PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true); pdfRenderer.setSubsamplingAllowed(true);
if (!includeAnnotations) { if (!includeAnnotations) {
@@ -162,9 +178,12 @@ public class PdfUtils {
} }
int pageCount = document.getNumberOfPages(); int pageCount = document.getNumberOfPages();
// Create a ByteArrayOutputStream to save the image(s) to
ByteArrayOutputStream baos = new ByteArrayOutputStream();
if (singleImage) { if (singleImage) {
if ("tiff".equals(imageType.toLowerCase(Locale.ROOT)) if ("tiff".equals(imageType.toLowerCase())
|| "tif".equals(imageType.toLowerCase(Locale.ROOT))) { || "tif".equals(imageType.toLowerCase())) {
// Write the images to the output stream as a TIFF with multiple frames // Write the images to the output stream as a TIFF with multiple frames
ImageWriter writer = ImageIO.getImageWritersByFormatName("tiff").next(); ImageWriter writer = ImageIO.getImageWritersByFormatName("tiff").next();
ImageWriteParam param = writer.getDefaultWriteParam(); ImageWriteParam param = writer.getDefaultWriteParam();
@@ -177,20 +196,9 @@ public class PdfUtils {
writer.prepareWriteSequence(null); writer.prepareWriteSequence(null);
for (int i = 0; i < pageCount; ++i) { for (int i = 0; i < pageCount; ++i) {
final int pageIndex = i;
BufferedImage image; BufferedImage image;
try { try {
// Validate dimensions before rendering image = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
ExceptionUtils.validateRenderingDimensions(
document.getPage(pageIndex), pageIndex + 1, DPI);
image =
ExceptionUtils.handleOomRendering(
pageIndex + 1,
DPI,
() ->
pdfRenderer.renderImageWithDPI(
pageIndex, DPI, colorType));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
if (e.getMessage() != null if (e.getMessage() != null
&& e.getMessage() && e.getMessage()
@@ -204,6 +212,10 @@ public class PdfUtils {
DPI); DPI);
} }
throw e; throw e;
} catch (OutOfMemoryError e) {
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
} catch (NegativeArraySizeException e) {
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
} }
writer.writeToSequence(new IIOImage(image, null, null), param); writer.writeToSequence(new IIOImage(image, null, null), param);
} }
@@ -227,7 +239,6 @@ public class PdfUtils {
HashMap<PdfRenderSettingsKey, PdfImageDimensionValue> pageSizes = HashMap<PdfRenderSettingsKey, PdfImageDimensionValue> pageSizes =
new HashMap<>(); new HashMap<>();
for (int i = 0; i < pageCount; ++i) { for (int i = 0; i < pageCount; ++i) {
final int pageIndex = i;
PDPage page = document.getPage(i); PDPage page = document.getPage(i);
PDRectangle mediaBox = page.getMediaBox(); PDRectangle mediaBox = page.getMediaBox();
int rotation = page.getRotation(); int rotation = page.getRotation();
@@ -238,17 +249,7 @@ public class PdfUtils {
if (dimension == null) { if (dimension == null) {
// Render the image to get the dimensions // Render the image to get the dimensions
try { try {
// Validate dimensions before rendering pdfSizeImage = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
ExceptionUtils.validateRenderingDimensions(
page, pageIndex + 1, DPI);
pdfSizeImage =
ExceptionUtils.handleOomRendering(
pageIndex + 1,
DPI,
() ->
pdfRenderer.renderImageWithDPI(
pageIndex, DPI, colorType));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
if (e.getMessage() != null if (e.getMessage() != null
&& e.getMessage() && e.getMessage()
@@ -263,6 +264,10 @@ public class PdfUtils {
DPI); DPI);
} }
throw e; throw e;
} catch (OutOfMemoryError e) {
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
} catch (NegativeArraySizeException e) {
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
} }
pdfSizeImageIndex = i; pdfSizeImageIndex = i;
dimension = dimension =
@@ -288,22 +293,11 @@ public class PdfUtils {
boolean firstImageAlreadyRendered = pdfSizeImageIndex == 0; boolean firstImageAlreadyRendered = pdfSizeImageIndex == 0;
for (int i = 0; i < pageCount; ++i) { for (int i = 0; i < pageCount; ++i) {
final int pageIndex = i;
if (firstImageAlreadyRendered && i == 0) { if (firstImageAlreadyRendered && i == 0) {
pageImage = pdfSizeImage; pageImage = pdfSizeImage;
} else { } else {
try { try {
// Validate dimensions before rendering pageImage = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
ExceptionUtils.validateRenderingDimensions(
document.getPage(pageIndex), pageIndex + 1, DPI);
pageImage =
ExceptionUtils.handleOomRendering(
pageIndex + 1,
DPI,
() ->
pdfRenderer.renderImageWithDPI(
pageIndex, DPI, colorType));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
if (e.getMessage() != null if (e.getMessage() != null
&& e.getMessage() && e.getMessage()
@@ -317,6 +311,10 @@ public class PdfUtils {
DPI); DPI);
} }
throw e; throw e;
} catch (OutOfMemoryError e) {
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
} catch (NegativeArraySizeException e) {
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
} }
} }
@@ -337,20 +335,9 @@ public class PdfUtils {
// Zip the images and return as byte array // Zip the images and return as byte array
try (ZipOutputStream zos = new ZipOutputStream(baos)) { try (ZipOutputStream zos = new ZipOutputStream(baos)) {
for (int i = 0; i < pageCount; ++i) { for (int i = 0; i < pageCount; ++i) {
final int pageIndex = i;
BufferedImage image; BufferedImage image;
try { try {
// Validate dimensions before rendering image = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
ExceptionUtils.validateRenderingDimensions(
document.getPage(pageIndex), pageIndex + 1, DPI);
image =
ExceptionUtils.handleOomRendering(
pageIndex + 1,
DPI,
() ->
pdfRenderer.renderImageWithDPI(
pageIndex, DPI, colorType));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
if (e.getMessage() != null if (e.getMessage() != null
&& e.getMessage().contains("Maximum size of image exceeded")) { && e.getMessage().contains("Maximum size of image exceeded")) {
@@ -362,6 +349,10 @@ public class PdfUtils {
DPI); DPI);
} }
throw e; throw e;
} catch (OutOfMemoryError e) {
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
} catch (NegativeArraySizeException e) {
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
} }
try (ByteArrayOutputStream baosImage = new ByteArrayOutputStream()) { try (ByteArrayOutputStream baosImage = new ByteArrayOutputStream()) {
ImageIO.write(image, imageType, baosImage); ImageIO.write(image, imageType, baosImage);
@@ -370,10 +361,9 @@ public class PdfUtils {
zos.putNextEntry( zos.putNextEntry(
new ZipEntry( new ZipEntry(
String.format( String.format(
Locale.ROOT,
filename + "_%d.%s", filename + "_%d.%s",
i + 1, i + 1,
imageType.toLowerCase(Locale.ROOT)))); imageType.toLowerCase())));
zos.write(baosImage.toByteArray()); zos.write(baosImage.toByteArray());
} }
} }
@@ -398,61 +388,51 @@ public class PdfUtils {
*/ */
public PDDocument convertPdfToPdfImage(PDDocument document) throws IOException { public PDDocument convertPdfToPdfImage(PDDocument document) throws IOException {
PDDocument imageDocument = new PDDocument(); PDDocument imageDocument = new PDDocument();
try { PDFRenderer pdfRenderer = new PDFRenderer(document);
PDFRenderer pdfRenderer = new PDFRenderer(document); pdfRenderer.setSubsamplingAllowed(true);
pdfRenderer.setSubsamplingAllowed(true); for (int page = 0; page < document.getNumberOfPages(); ++page) {
for (int page = 0; page < document.getNumberOfPages(); ++page) { BufferedImage bim;
final int pageIndex = page;
BufferedImage bim;
// Use global maximum DPI setting, fallback to 300 if not set // Use global maximum DPI setting, fallback to 300 if not set
int renderDpi = 300; // Default fallback int renderDpi = 300; // Default fallback
ApplicationProperties properties = ApplicationProperties properties =
ApplicationContextProvider.getBean(ApplicationProperties.class); ApplicationContextProvider.getBean(ApplicationProperties.class);
if (properties != null && properties.getSystem() != null) { if (properties != null && properties.getSystem() != null) {
renderDpi = properties.getSystem().getMaxDPI(); renderDpi = properties.getSystem().getMaxDPI();
}
final int dpi = renderDpi;
try {
bim =
ExceptionUtils.handleOomRendering(
pageIndex + 1,
dpi,
() ->
pdfRenderer.renderImageWithDPI(
pageIndex, dpi, ImageType.RGB));
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage().contains("Maximum size of image exceeded")) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pageTooBigFor300Dpi",
"PDF page {0} is too large to render at 300 DPI. The resulting image"
+ " would exceed Java's maximum array size. Please use a lower DPI"
+ " value for PDF-to-image conversion.",
pageIndex + 1);
}
throw e;
}
PDPage originalPage = document.getPage(page);
float width = originalPage.getMediaBox().getWidth();
float height = originalPage.getMediaBox().getHeight();
PDPage newPage = new PDPage(new PDRectangle(width, height));
imageDocument.addPage(newPage);
PDImageXObject pdImage = LosslessFactory.createFromImage(imageDocument, bim);
try (PDPageContentStream contentStream =
new PDPageContentStream(
imageDocument, newPage, AppendMode.APPEND, true, true)) {
contentStream.drawImage(pdImage, 0, 0, width, height);
}
bim.flush();
} }
return imageDocument;
} catch (Exception e) { try {
throw e; bim = pdfRenderer.renderImageWithDPI(page, renderDpi, 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.",
page + 1);
}
throw e;
} catch (OutOfMemoryError e) {
throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, 300, e);
} catch (NegativeArraySizeException e) {
throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, 300, e);
}
PDPage originalPage = document.getPage(page);
float width = originalPage.getMediaBox().getWidth();
float height = originalPage.getMediaBox().getHeight();
PDPage newPage = new PDPage(new PDRectangle(width, height));
imageDocument.addPage(newPage);
PDImageXObject pdImage = LosslessFactory.createFromImage(imageDocument, bim);
PDPageContentStream contentStream =
new PDPageContentStream(imageDocument, newPage, AppendMode.APPEND, true, true);
contentStream.drawImage(pdImage, 0, 0, width, height);
contentStream.close();
} }
return imageDocument;
} }
private BufferedImage prepareImageForPdfToImage(int maxWidth, int height, String imageType) { private BufferedImage prepareImageForPdfToImage(int maxWidth, int height, String imageType) {
@@ -483,8 +463,8 @@ public class PdfUtils {
String contentType = file.getContentType(); String contentType = file.getContentType();
String originalFilename = Filenames.toSimpleFileName(file.getOriginalFilename()); String originalFilename = Filenames.toSimpleFileName(file.getOriginalFilename());
if (originalFilename != null if (originalFilename != null
&& (originalFilename.toLowerCase(Locale.ROOT).endsWith(".tiff") && (originalFilename.toLowerCase().endsWith(".tiff")
|| originalFilename.toLowerCase(Locale.ROOT).endsWith(".tif"))) { || originalFilename.toLowerCase().endsWith(".tif"))) {
ImageReader reader = ImageIO.getImageReadersByFormatName("tiff").next(); ImageReader reader = ImageIO.getImageReadersByFormatName("tiff").next();
reader.setInput(ImageIO.createImageInputStream(file.getInputStream())); reader.setInput(ImageIO.createImageInputStream(file.getInputStream()));
int numPages = reader.getNumImages(true); int numPages = reader.getNumImages(true);
@@ -574,39 +554,34 @@ public class PdfUtils {
boolean everyPage) boolean everyPage)
throws IOException { throws IOException {
try (PDDocument document = pdfDocumentFactory.load(pdfBytes)) { PDDocument document = pdfDocumentFactory.load(pdfBytes);
// Get the first page of the PDF
int pages = document.getNumberOfPages(); // Get the first page of the PDF
for (int i = 0; i < pages; i++) { int pages = document.getNumberOfPages();
PDPage page = document.getPage(i); for (int i = 0; i < pages; i++) {
try (PDPageContentStream contentStream = PDPage page = document.getPage(i);
new PDPageContentStream( try (PDPageContentStream contentStream =
document, new PDPageContentStream(
page, document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
PDPageContentStream.AppendMode.APPEND, // Create an image object from the image bytes
true, PDImageXObject image = PDImageXObject.createFromByteArray(document, imageBytes, "");
true)) { // Draw the image onto the page at the specified x and y coordinates
// Create an image object from the image bytes contentStream.drawImage(image, x, y);
PDImageXObject image = log.info("Image successfully overlayed onto PDF");
PDImageXObject.createFromByteArray(document, imageBytes, ""); if (!everyPage && i == 0) {
// Draw the image onto the page at the specified x and y coordinates break;
contentStream.drawImage(image, x, y);
log.info("Image successfully overlaid onto PDF");
if (!everyPage && i == 0) {
break;
}
} catch (IOException e) {
// Log an error message if there is an issue overlaying the image onto the PDF
log.error("Error overlaying image onto PDF", e);
throw e;
} }
} catch (IOException e) {
// Log an error message if there is an issue overlaying the image onto the PDF
log.error("Error overlaying image onto PDF", e);
throw e;
} }
// Create a ByteArrayOutputStream to save the PDF to
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
log.info("PDF successfully saved to byte array");
return baos.toByteArray();
} }
// Create a ByteArrayOutputStream to save the PDF to
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
log.info("PDF successfully saved to byte array");
return baos.toByteArray();
} }
public boolean containsTextInFile(PDDocument pdfDocument, String text, String pagesToCheck) public boolean containsTextInFile(PDDocument pdfDocument, String text, String pagesToCheck)
@@ -656,7 +631,7 @@ public class PdfUtils {
int actualPageCount = pdfDocument.getNumberOfPages(); int actualPageCount = pdfDocument.getNumberOfPages();
pdfDocument.close(); pdfDocument.close();
return switch (comparator.toLowerCase(Locale.ROOT)) { return switch (comparator.toLowerCase()) {
case "greater" -> actualPageCount > pageCount; case "greater" -> actualPageCount > pageCount;
case "equal" -> actualPageCount == pageCount; case "equal" -> actualPageCount == pageCount;
case "less" -> actualPageCount < pageCount; case "less" -> actualPageCount < pageCount;
@@ -6,12 +6,9 @@ import java.io.IOException;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.io.InterruptedIOException; import java.io.InterruptedIOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Semaphore; import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@@ -29,15 +26,11 @@ public class ProcessExecutor {
private static final Map<Processes, ProcessExecutor> instances = new ConcurrentHashMap<>(); private static final Map<Processes, ProcessExecutor> instances = new ConcurrentHashMap<>();
private static ApplicationProperties applicationProperties = new ApplicationProperties(); private static ApplicationProperties applicationProperties = new ApplicationProperties();
private static volatile UnoServerPool unoServerPool;
private final Semaphore semaphore; private final Semaphore semaphore;
private final boolean liveUpdates; private final boolean liveUpdates;
private long timeoutDuration; private long timeoutDuration;
private final Processes processType;
private ProcessExecutor( private ProcessExecutor(int semaphoreLimit, boolean liveUpdates, long timeout) {
Processes processType, int semaphoreLimit, boolean liveUpdates, long timeout) {
this.processType = processType;
this.semaphore = new Semaphore(semaphoreLimit); this.semaphore = new Semaphore(semaphoreLimit);
this.liveUpdates = liveUpdates; this.liveUpdates = liveUpdates;
this.timeoutDuration = timeout; this.timeoutDuration = timeout;
@@ -93,11 +86,6 @@ public class ProcessExecutor {
.getProcessExecutor() .getProcessExecutor()
.getSessionLimit() .getSessionLimit()
.getCalibreSessionLimit(); .getCalibreSessionLimit();
case IMAGEMAGICK ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getImageMagickSessionLimit();
case GHOSTSCRIPT -> case GHOSTSCRIPT ->
applicationProperties applicationProperties
.getProcessExecutor() .getProcessExecutor()
@@ -109,11 +97,6 @@ public class ProcessExecutor {
.getSessionLimit() .getSessionLimit()
.getOcrMyPdfSessionLimit(); .getOcrMyPdfSessionLimit();
case CFF_CONVERTER -> 1; case CFF_CONVERTER -> 1;
case FFMPEG ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getFfmpegSessionLimit();
}; };
long timeoutMinutes = long timeoutMinutes =
@@ -158,11 +141,6 @@ public class ProcessExecutor {
.getProcessExecutor() .getProcessExecutor()
.getTimeoutMinutes() .getTimeoutMinutes()
.getCalibreTimeoutMinutes(); .getCalibreTimeoutMinutes();
case IMAGEMAGICK ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getImageMagickTimeoutMinutes();
case GHOSTSCRIPT -> case GHOSTSCRIPT ->
applicationProperties applicationProperties
.getProcessExecutor() .getProcessExecutor()
@@ -174,21 +152,11 @@ public class ProcessExecutor {
.getTimeoutMinutes() .getTimeoutMinutes()
.getOcrMyPdfTimeoutMinutes(); .getOcrMyPdfTimeoutMinutes();
case CFF_CONVERTER -> 5L; case CFF_CONVERTER -> 5L;
case FFMPEG ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getFfmpegTimeoutMinutes();
}; };
return new ProcessExecutor( return new ProcessExecutor(semaphoreLimit, liveUpdates, timeoutMinutes);
processType, semaphoreLimit, liveUpdates, timeoutMinutes);
}); });
} }
public static void setUnoServerPool(UnoServerPool pool) {
unoServerPool = pool;
}
public ProcessExecutorResult runCommandWithOutputHandling(List<String> command) public ProcessExecutorResult runCommandWithOutputHandling(List<String> command)
throws IOException, InterruptedException { throws IOException, InterruptedException {
return runCommandWithOutputHandling(command, null); return runCommandWithOutputHandling(command, null);
@@ -198,22 +166,11 @@ public class ProcessExecutor {
List<String> command, File workingDirectory) throws IOException, InterruptedException { List<String> command, File workingDirectory) throws IOException, InterruptedException {
String messages = ""; String messages = "";
int exitCode = 1; int exitCode = 1;
UnoServerPool.UnoServerLease unoLease = null; semaphore.acquire();
boolean useSemaphore = true;
List<String> commandToRun = command;
if (shouldUseUnoServerPool(command)) {
unoLease = unoServerPool.acquireEndpoint();
commandToRun = applyUnoServerEndpoint(command, unoLease.getEndpoint());
useSemaphore = false;
}
if (useSemaphore) {
semaphore.acquire();
}
try { try {
validateCommand(commandToRun); log.info("Running command: {}", String.join(" ", command));
log.info("Running command: {}", String.join(" ", commandToRun)); ProcessBuilder processBuilder = new ProcessBuilder(command);
ProcessBuilder processBuilder = new ProcessBuilder(commandToRun);
// Use the working directory if it's set // Use the working directory if it's set
if (workingDirectory != null) { if (workingDirectory != null) {
@@ -291,9 +248,7 @@ public class ProcessExecutor {
outputReaderThread.join(); outputReaderThread.join();
boolean isQpdf = boolean isQpdf =
commandToRun != null command != null && !command.isEmpty() && command.get(0).contains("qpdf");
&& !commandToRun.isEmpty()
&& commandToRun.get(0).contains("qpdf");
if (!outputLines.isEmpty()) { if (!outputLines.isEmpty()) {
String outputMessage = String.join("\n", outputLines); String outputMessage = String.join("\n", outputLines);
@@ -334,195 +289,11 @@ public class ProcessExecutor {
} }
} }
} finally { } finally {
if (useSemaphore) { semaphore.release();
semaphore.release();
}
if (unoLease != null) {
unoLease.close();
}
} }
return new ProcessExecutorResult(exitCode, messages); return new ProcessExecutorResult(exitCode, messages);
} }
private boolean shouldUseUnoServerPool(List<String> command) {
if (processType != Processes.LIBRE_OFFICE || unoServerPool == null) {
return false;
}
if (unoServerPool.isEmpty()) {
return false;
}
if (command == null || command.isEmpty()) {
return false;
}
// Check if this is a UNO conversion by looking for unoconvert executable
String executable = command.get(0);
if (executable != null) {
// Extract basename from path for matching
String basename = executable;
int lastSlash = Math.max(executable.lastIndexOf('/'), executable.lastIndexOf('\\'));
if (lastSlash >= 0) {
basename = executable.substring(lastSlash + 1);
}
// Strip .exe extension on Windows
if (basename.toLowerCase(java.util.Locale.ROOT).endsWith(".exe")) {
basename = basename.substring(0, basename.length() - 4);
}
// Match common unoconvert variants (but NOT soffice)
String lowerBasename = basename.toLowerCase(java.util.Locale.ROOT);
if (lowerBasename.contains("unoconvert") || lowerBasename.equals("unoconv")) {
return true;
}
}
return false;
}
private List<String> applyUnoServerEndpoint(
List<String> command,
ApplicationProperties.ProcessExecutor.UnoServerEndpoint endpoint) {
if (endpoint == null || command == null || command.isEmpty()) {
return command;
}
List<String> updated = stripUnoEndpointArgs(command);
String host = endpoint.getHost();
int port = endpoint.getPort();
String hostLocation = endpoint.getHostLocation();
String protocol = endpoint.getProtocol();
// Normalize and validate host
if (host == null || host.isBlank()) {
host = "127.0.0.1";
}
// Normalize and validate port
if (port <= 0) {
port = 2003;
}
// Normalize and validate hostLocation (only auto|local|remote allowed)
if (hostLocation == null) {
hostLocation = "auto";
} else {
hostLocation = hostLocation.trim().toLowerCase(java.util.Locale.ROOT);
if (!Set.of("auto", "local", "remote").contains(hostLocation)) {
log.warn(
"Invalid hostLocation '{}' for endpoint {}:{}, defaulting to 'auto'",
hostLocation,
host,
port);
hostLocation = "auto";
}
}
// Normalize and validate protocol (only http|https allowed)
if (protocol == null) {
protocol = "http";
} else {
protocol = protocol.trim().toLowerCase(java.util.Locale.ROOT);
if (!Set.of("http", "https").contains(protocol)) {
log.warn(
"Invalid protocol '{}' for endpoint {}:{}, defaulting to 'http'",
protocol,
host,
port);
protocol = "http";
}
}
int insertIndex = Math.min(1, updated.size());
updated.add(insertIndex++, "--host");
updated.add(insertIndex++, host);
updated.add(insertIndex++, "--port");
updated.add(insertIndex++, String.valueOf(port));
// Only inject --host-location if non-default (for compatibility with older unoconvert)
if (!"auto".equals(hostLocation)) {
updated.add(insertIndex++, "--host-location");
updated.add(insertIndex++, hostLocation);
}
// Only inject --protocol if non-default (for compatibility with older unoconvert)
if (!"http".equals(protocol)) {
updated.add(insertIndex++, "--protocol");
updated.add(insertIndex, protocol);
}
return updated;
}
private List<String> stripUnoEndpointArgs(List<String> command) {
List<String> stripped = new ArrayList<>(command.size());
for (int i = 0; i < command.size(); i++) {
String arg = command.get(i);
if ("--host".equals(arg)
|| "--port".equals(arg)
|| "--host-location".equals(arg)
|| "--protocol".equals(arg)) {
i++;
continue;
}
if (arg != null
&& (arg.startsWith("--host=")
|| arg.startsWith("--port=")
|| arg.startsWith("--host-location=")
|| arg.startsWith("--protocol="))) {
continue;
}
stripped.add(arg);
}
return stripped;
}
private void validateCommand(List<String> command) {
if (command == null || command.isEmpty()) {
throw new IllegalArgumentException("Command must not be empty");
}
// Validate all arguments for null bytes and newlines (actual security concerns)
for (String arg : command) {
if (arg == null) {
throw new IllegalArgumentException("Command contains null argument");
}
if (arg.indexOf('\0') >= 0 || arg.indexOf('\n') >= 0 || arg.indexOf('\r') >= 0) {
throw new IllegalArgumentException("Command contains invalid characters");
}
}
// Validate executable (first argument)
String executable = command.get(0);
if (executable == null || executable.isBlank()) {
throw new IllegalArgumentException("Command executable must not be empty");
}
// Check for path traversal in executable
if (executable.contains("..")) {
throw new IllegalArgumentException(
"Command executable contains path traversal: " + executable);
}
// For absolute paths, verify the file exists and is executable
if (executable.contains("/") || executable.contains("\\")) {
Path execPath;
try {
execPath = Path.of(executable);
} catch (Exception e) {
throw new IllegalArgumentException("Invalid executable path: " + executable, e);
}
if (!Files.exists(execPath)) {
throw new IllegalArgumentException(
"Command executable does not exist: " + executable);
}
if (!Files.isRegularFile(execPath)) {
throw new IllegalArgumentException(
"Command executable is not a regular file: " + executable);
}
}
// For relative paths, trust that PATH resolution will work or fail appropriately
}
public enum Processes { public enum Processes {
LIBRE_OFFICE, LIBRE_OFFICE,
PDFTOHTML, PDFTOHTML,
@@ -530,13 +301,11 @@ public class ProcessExecutor {
WEASYPRINT, WEASYPRINT,
INSTALL_APP, INSTALL_APP,
CALIBRE, CALIBRE,
IMAGEMAGICK,
TESSERACT, TESSERACT,
QPDF, QPDF,
GHOSTSCRIPT, GHOSTSCRIPT,
OCR_MY_PDF, OCR_MY_PDF,
CFF_CONVERTER, CFF_CONVERTER
FFMPEG
} }
@Setter @Setter
@@ -325,11 +325,6 @@ public final class RegexPatternUtils {
return getPattern("\\+"); return getPattern("\\+");
} }
/** Pattern for splitting on pipe delimiter (used for hint lists in i18n messages) */
public Pattern getPipeDelimiterPattern() {
return getPattern("\\|");
}
/** Pattern for username validation */ /** Pattern for username validation */
public Pattern getUsernameValidationPattern() { public Pattern getUsernameValidationPattern() {
return getPattern("^[a-zA-Z0-9](?!.*[-@._+]{2,})[a-zA-Z0-9@._+-]{1,48}[a-zA-Z0-9]$"); return getPattern("^[a-zA-Z0-9](?!.*[-@._+]{2,})[a-zA-Z0-9@._+-]{1,48}[a-zA-Z0-9]$");
@@ -572,16 +567,6 @@ public final class RegexPatternUtils {
return getPattern("^[a-zA-Z0-9]{2,4}$", Pattern.CASE_INSENSITIVE); return getPattern("^[a-zA-Z0-9]{2,4}$", Pattern.CASE_INSENSITIVE);
} }
/** Pattern for splitting on line breaks (Unicode line separator) */
public Pattern getLineSeparatorPattern() {
return getPattern("\\R");
}
/** Pattern for removing leading asterisks and whitespace */
public Pattern getLeadingAsterisksWhitespacePattern() {
return getPattern("^[*\\s]+");
}
private record PatternKey(String regex, int flags) { private record PatternKey(String regex, int flags) {
// Record automatically provides equals, hashCode, and toString // Record automatically provides equals, hashCode, and toString
} }
@@ -26,7 +26,6 @@ public class RequestUriUtils {
|| normalizedUri.startsWith("/public/") || normalizedUri.startsWith("/public/")
|| normalizedUri.startsWith("/pdfjs/") || normalizedUri.startsWith("/pdfjs/")
|| normalizedUri.startsWith("/pdfjs-legacy/") || normalizedUri.startsWith("/pdfjs-legacy/")
|| normalizedUri.startsWith("/pdfium/")
|| normalizedUri.startsWith("/assets/") || normalizedUri.startsWith("/assets/")
|| normalizedUri.startsWith("/locales/") || normalizedUri.startsWith("/locales/")
|| normalizedUri.startsWith("/Login/") || normalizedUri.startsWith("/Login/")
@@ -38,12 +37,12 @@ public class RequestUriUtils {
} }
// Specific static files bundled with the frontend // Specific static files bundled with the frontend
if ("/robots.txt".equals(normalizedUri) if (normalizedUri.equals("/robots.txt")
|| "/favicon.ico".equals(normalizedUri) || normalizedUri.equals("/favicon.ico")
|| "/manifest.json".equals(normalizedUri) || normalizedUri.equals("/manifest.json")
|| "/site.webmanifest".equals(normalizedUri) || normalizedUri.equals("/site.webmanifest")
|| "/manifest-classic.json".equals(normalizedUri) || normalizedUri.equals("/manifest-classic.json")
|| "/index.html".equals(normalizedUri)) { || normalizedUri.equals("/index.html")) {
return true; return true;
} }
@@ -52,11 +51,6 @@ public class RequestUriUtils {
return true; 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 // Treat common static file extensions as static resources
return normalizedUri.endsWith(".svg") return normalizedUri.endsWith(".svg")
|| normalizedUri.endsWith(".png") || normalizedUri.endsWith(".png")
@@ -67,8 +61,7 @@ public class RequestUriUtils {
|| normalizedUri.endsWith(".css") || normalizedUri.endsWith(".css")
|| normalizedUri.endsWith(".mjs") || normalizedUri.endsWith(".mjs")
|| normalizedUri.endsWith(".html") || normalizedUri.endsWith(".html")
|| normalizedUri.endsWith(".toml") || normalizedUri.endsWith(".toml");
|| normalizedUri.endsWith(".wasm");
} }
public static boolean isFrontendRoute(String contextPath, String requestURI) { public static boolean isFrontendRoute(String contextPath, String requestURI) {
@@ -132,13 +125,11 @@ public class RequestUriUtils {
|| requestURI.endsWith("popularity.txt") || requestURI.endsWith("popularity.txt")
|| requestURI.endsWith(".js") || requestURI.endsWith(".js")
|| requestURI.endsWith(".toml") || requestURI.endsWith(".toml")
|| requestURI.endsWith(".wasm")
|| requestURI.contains("swagger") || requestURI.contains("swagger")
|| requestURI.startsWith("/api/v1/info") || requestURI.startsWith("/api/v1/info")
|| requestURI.startsWith("/site.webmanifest") || requestURI.startsWith("/site.webmanifest")
|| requestURI.startsWith("/fonts") || requestURI.startsWith("/fonts")
|| requestURI.startsWith("/pdfjs") || requestURI.startsWith("/pdfjs"));
|| requestURI.startsWith("/pdfium"));
} }
/** /**
@@ -171,17 +162,10 @@ public class RequestUriUtils {
// enableLogin) // enableLogin)
|| trimmedUri.startsWith( || trimmedUri.startsWith(
"/api/v1/ui-data/footer-info") // Public footer configuration "/api/v1/ui-data/footer-info") // Public footer configuration
|| trimmedUri.startsWith("/v1/api-docs")
|| trimmedUri.startsWith("/api/v1/invite/validate") || trimmedUri.startsWith("/api/v1/invite/validate")
|| trimmedUri.startsWith("/api/v1/invite/accept") || trimmedUri.startsWith("/api/v1/invite/accept")
// Health Endoints || trimmedUri.contains("/v1/api-docs");
|| trimmedUri.startsWith("/actuator/health")
|| trimmedUri.startsWith("/health")
|| trimmedUri.startsWith("/healthz")
|| trimmedUri.startsWith("/liveness")
|| trimmedUri.startsWith("/readiness")
|| trimmedUri.startsWith(
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|| trimmedUri.startsWith("/v1/api-docs");
} }
private static String stripContextPath(String contextPath, String requestURI) { private static String stripContextPath(String contextPath, String requestURI) {
@@ -1,281 +0,0 @@
package stirling.software.common.util;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Pattern;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import org.springframework.stereotype.Component;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.SsrfProtectionService;
@Component
@RequiredArgsConstructor
@Slf4j
public class SvgSanitizer {
private static final Set<String> DANGEROUS_ELEMENTS =
Set.of("script", "foreignobject", "iframe", "object", "embed", "handler", "listener");
private static final Set<String> URL_ATTRIBUTES = Set.of("href", "xlink:href", "src", "data");
private static final Pattern JAVASCRIPT_URL_PATTERN =
Pattern.compile("^\\s*javascript\\s*:", Pattern.CASE_INSENSITIVE);
private static final Pattern DATA_SCRIPT_PATTERN =
Pattern.compile(
"^\\s*data\\s*:[^,]*(?:script|javascript|vbscript)", Pattern.CASE_INSENSITIVE);
private static final Pattern NULL_BYTE_PATTERN = Pattern.compile("\u0000");
private final SsrfProtectionService ssrfProtectionService;
private final ApplicationProperties applicationProperties;
public byte[] sanitize(byte[] svgBytes) throws IOException {
if (svgBytes == null || svgBytes.length == 0) {
throw new IOException("SVG input is empty or null");
}
if (applicationProperties.getSystem().isDisableSanitize()) {
log.debug("SVG sanitization disabled by configuration");
return svgBytes;
}
try {
Document doc = parseSecurely(svgBytes);
Element root = doc.getDocumentElement();
if (root == null) {
throw new IOException("SVG document has no root element");
}
sanitizeNode(root);
byte[] result = serializeDocument(doc);
if (result == null || result.length == 0) {
throw new IOException("SVG sanitization produced empty output");
}
return result;
} catch (ParserConfigurationException | SAXException | TransformerException e) {
throw new IOException("Failed to sanitize SVG content", e);
}
}
private Document parseSecurely(byte[] svgBytes)
throws ParserConfigurationException, SAXException, IOException {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
factory.setNamespaceAware(true);
DocumentBuilder builder = factory.newDocumentBuilder();
return builder.parse(new ByteArrayInputStream(svgBytes));
}
private void sanitizeNode(Node node) {
if (node == null) {
return;
}
NodeList children = node.getChildNodes();
Set<Node> nodesToRemove = new HashSet<>();
for (int i = 0; i < children.getLength(); i++) {
Node child = children.item(i);
if (child.getNodeType() == Node.ELEMENT_NODE) {
String localName = child.getLocalName();
if (localName == null) {
localName = child.getNodeName();
}
if (isDangerousElement(localName)) {
log.warn("Removing dangerous SVG element: {}", localName);
nodesToRemove.add(child);
} else {
sanitizeNode(child);
}
}
}
for (Node toRemove : nodesToRemove) {
node.removeChild(toRemove);
}
if (node.getNodeType() == Node.ELEMENT_NODE) {
sanitizeAttributes((Element) node);
}
}
private void sanitizeAttributes(Element element) {
NamedNodeMap attributes = element.getAttributes();
Set<String> attributesToRemove = new HashSet<>();
for (int i = 0; i < attributes.getLength(); i++) {
Node attr = attributes.item(i);
String attrName = attr.getNodeName().toLowerCase();
String attrValue = attr.getNodeValue();
if (isEventHandler(attrName)) {
log.warn("Removing event handler attribute: {}", attrName);
attributesToRemove.add(attr.getNodeName());
continue;
}
if (isUrlAttribute(attrName)) {
if (isDangerousUrl(attrValue)) {
log.warn(
"Removing dangerous URL in attribute {}: {}",
attrName,
truncateForLog(attrValue));
attributesToRemove.add(attr.getNodeName());
continue;
}
if (isExternalUrl(attrValue) && !isUrlAllowed(attrValue)) {
log.warn(
"Removing SSRF-blocked URL in attribute {}: {}",
attrName,
truncateForLog(attrValue));
attributesToRemove.add(attr.getNodeName());
}
}
}
for (String attrName : attributesToRemove) {
element.removeAttribute(attrName);
}
}
private boolean isDangerousElement(String localName) {
return DANGEROUS_ELEMENTS.contains(localName.toLowerCase());
}
private boolean isEventHandler(String attrName) {
return attrName.startsWith("on");
}
private boolean isUrlAttribute(String attrName) {
return URL_ATTRIBUTES.contains(attrName.toLowerCase())
|| attrName.toLowerCase().endsWith(":href");
}
private boolean isDangerousUrl(String url) {
if (url == null || url.trim().isEmpty()) {
return false;
}
String normalized = normalizeUrl(url);
if (JAVASCRIPT_URL_PATTERN.matcher(normalized).find()) {
return true;
}
if (DATA_SCRIPT_PATTERN.matcher(normalized).find()) {
return true;
}
return false;
}
private String normalizeUrl(String url) {
if (url == null) {
return "";
}
String result = url.trim();
result = NULL_BYTE_PATTERN.matcher(result).replaceAll("");
for (int i = 0; i < 3; i++) {
try {
String decoded = URLDecoder.decode(result, StandardCharsets.UTF_8);
if (decoded.equals(result)) {
break; // No more decoding needed
}
result = decoded;
} catch (Exception e) {
log.debug("Failed to decode URL, continuing with current value", e);
break;
}
}
return result.toLowerCase();
}
private boolean isExternalUrl(String url) {
if (url == null || url.trim().isEmpty()) {
return false;
}
String normalized = normalizeUrl(url);
if (normalized.startsWith("#")) {
return false;
}
if (normalized.startsWith("data:")) {
return false;
}
return normalized.startsWith("http://")
|| normalized.startsWith("https://")
|| normalized.startsWith("//")
|| normalized.startsWith("file:");
}
private boolean isUrlAllowed(String url) {
if (ssrfProtectionService == null) {
return true;
}
return ssrfProtectionService.isUrlAllowed(url);
}
private byte[] serializeDocument(Document doc) throws TransformerException {
TransformerFactory transformerFactory = TransformerFactory.newInstance();
transformerFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
Transformer transformer = transformerFactory.newTransformer();
transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
transformer.setOutputProperty(OutputKeys.INDENT, "no");
transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no");
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
transformer.transform(new DOMSource(doc), new StreamResult(outputStream));
return outputStream.toByteArray();
}
private String truncateForLog(String value) {
if (value == null) {
return "null";
}
return value.length() > 50 ? value.substring(0, 50) + "..." : value;
}
}
@@ -28,17 +28,6 @@ public class TempFileManager {
private final TempFileRegistry registry; private final TempFileRegistry registry;
private final ApplicationProperties applicationProperties; private final ApplicationProperties applicationProperties;
/**
* Create a managed temporary file that will be tracked by the TempFileManager.
*
* @param suffix The suffix for the temporary file
* @return The created temporary file wrapper
* @throws IOException If an I/O error occurs
*/
public TempFile createManagedTempFile(String suffix) throws IOException {
return new TempFile(this, suffix);
}
/** /**
* Create a temporary file with the Stirling-PDF prefix. The file is automatically registered * Create a temporary file with the Stirling-PDF prefix. The file is automatically registered
* with the registry. * with the registry.
@@ -141,6 +130,7 @@ public class TempFileManager {
return deleted; return deleted;
} catch (IOException e) { } catch (IOException e) {
log.warn("Failed to delete temp file: {}", path.toString(), e); log.warn("Failed to delete temp file: {}", path.toString(), e);
return false;
} }
} }
return false; return false;
@@ -1,85 +0,0 @@
package stirling.software.common.util;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicBoolean;
import stirling.software.common.model.ApplicationProperties;
public class UnoServerPool {
private final List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> endpoints;
private final BlockingQueue<Integer> availableIndices;
public UnoServerPool(List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> endpoints) {
if (endpoints == null || endpoints.isEmpty()) {
this.endpoints = Collections.emptyList();
this.availableIndices = new LinkedBlockingQueue<>();
} else {
this.endpoints = new ArrayList<>(endpoints);
this.availableIndices = new LinkedBlockingQueue<>();
// Initialize queue with all endpoint indices
for (int i = 0; i < this.endpoints.size(); i++) {
this.availableIndices.offer(i);
}
}
}
public boolean isEmpty() {
return endpoints.isEmpty();
}
public UnoServerLease acquireEndpoint() throws InterruptedException {
if (endpoints.isEmpty()) {
return new UnoServerLease(defaultEndpoint(), null, this);
}
// Block until an endpoint index becomes available
Integer index = availableIndices.take();
return new UnoServerLease(endpoints.get(index), index, this);
}
private void releaseEndpoint(Integer index) {
if (index != null) {
availableIndices.offer(index);
}
}
private static ApplicationProperties.ProcessExecutor.UnoServerEndpoint defaultEndpoint() {
return new ApplicationProperties.ProcessExecutor.UnoServerEndpoint();
}
public static class UnoServerLease implements AutoCloseable {
private final ApplicationProperties.ProcessExecutor.UnoServerEndpoint endpoint;
private final Integer index;
private final UnoServerPool pool;
private final AtomicBoolean closed = new AtomicBoolean(false);
public UnoServerLease(
ApplicationProperties.ProcessExecutor.UnoServerEndpoint endpoint,
Integer index,
UnoServerPool pool) {
this.endpoint = endpoint;
this.index = index;
this.pool = pool;
}
public ApplicationProperties.ProcessExecutor.UnoServerEndpoint getEndpoint() {
return endpoint;
}
@Override
public void close() {
// Idempotent close: only release once even if close() called multiple times
if (!closed.compareAndSet(false, true)) {
return;
}
if (pool != null && index != null) {
pool.releaseEndpoint(index);
}
}
}
}
@@ -0,0 +1,14 @@
package stirling.software.common.util;
import java.util.Collection;
public class ValidationUtil {
public static boolean isStringEmpty(String input) {
return input == null || input.isBlank();
}
public static boolean isCollectionEmpty(Collection<String> input) {
return input == null || input.isEmpty();
}
}
@@ -69,6 +69,7 @@ public class WebResponseUtils {
// Open Byte Array and save document to it // Open Byte Array and save document to it
ByteArrayOutputStream baos = new ByteArrayOutputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos); document.save(baos);
document.close();
return baosToWebResponse(baos, docName); return baosToWebResponse(baos, docName);
} }

Some files were not shown because too many files have changed in this diff Show More