Compare commits

..
1740 changed files with 339969 additions and 428956 deletions
-1
View File
@@ -5,7 +5,6 @@ frontend/dist
frontend/build frontend/build
frontend/.vite frontend/.vite
frontend/.tauri frontend/.tauri
frontend/src-tauri/target
# Gradle build artifacts # Gradle build artifacts
.gradle .gradle
+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
@@ -1,6 +1,6 @@
""" """
Author: Ludy87 Author: Ludy87
Description: This script processes TOML translation files for localization checks. It compares translation files in a branch with Description: This script processes JSON translation files for localization checks. It compares translation files in a branch with
a reference file to ensure consistency. The script performs two main checks: a reference file to ensure consistency. The script performs two main checks:
1. Verifies that the number of translation keys in the translation files matches the reference file. 1. Verifies that the number of translation keys in the translation files matches the reference file.
2. Ensures that all keys in the translation files are present in the reference file and vice versa. 2. Ensures that all keys in the translation files are present in the reference file and vice versa.
@@ -9,26 +9,23 @@ The script also provides functionality to update the translation files to match
adjusting the format. 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_json.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_json.py --reference-file frontend/public/locales/en-GB/translation.json --branch "" --files frontend/public/locales/de-DE/translation.json frontend/public/locales/fr-FR/translation.json
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 tomli_w # For writing TOML files
def find_duplicate_keys(file_path, keys=None, prefix=""): def find_duplicate_keys(file_path, keys=None, prefix=""):
""" """
Identifies duplicate keys in a TOML file (including nested keys). Identifies duplicate keys in a JSON file (including nested keys).
:param file_path: Path to the TOML file. :param file_path: Path to the JSON file.
:param keys: Dictionary to track keys (used for recursion). :param keys: Dictionary to track keys (used for recursion).
:param prefix: Prefix for nested keys. :param prefix: Prefix for nested keys.
:return: List of tuples (key, first_occurrence_path, duplicate_path). :return: List of tuples (key, first_occurrence_path, duplicate_path).
@@ -38,10 +35,8 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
duplicates = [] duplicates = []
# Load TOML file with open(file_path, "r", encoding="utf-8") as file:
file_path = Path(file_path) data = json.load(file)
with file_path.open("rb") as file:
data = tomllib.load(file)
def process_dict(obj, current_prefix=""): def process_dict(obj, current_prefix=""):
for key, value in obj.items(): for key, value in obj.items():
@@ -59,19 +54,18 @@ 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 JSON files (e.g., 500 KB)
MAX_FILE_SIZE = 1000 * 1024 MAX_FILE_SIZE = 500 * 1024
def parse_toml_file(file_path): def parse_json_file(file_path):
""" """
Parses a TOML translation file and returns a flat dictionary of all keys. Parses a JSON translation file and returns a flat dictionary of all keys.
:param file_path: Path to the TOML file. :param file_path: Path to the JSON file.
:return: Dictionary with flattened keys. :return: Dictionary with flattened keys.
""" """
file_path = Path(file_path) with open(file_path, "r", encoding="utf-8") as file:
with file_path.open("rb") as file: data = json.load(file)
data = tomllib.load(file)
def flatten_dict(d, parent_key="", sep="."): def flatten_dict(d, parent_key="", sep="."):
items = {} items = {}
@@ -105,43 +99,38 @@ def unflatten_dict(d, sep="."):
return result return result
def write_toml_file(file_path, updated_properties): def write_json_file(file_path, updated_properties):
""" """
Writes updated properties back to the TOML file. Writes updated properties back to the JSON file.
:param file_path: Path to the TOML file. :param file_path: Path to the JSON file.
:param updated_properties: Dictionary of updated properties to write. :param updated_properties: Dictionary of updated properties to write.
""" """
nested_data = unflatten_dict(updated_properties) nested_data = unflatten_dict(updated_properties)
file_path = Path(file_path) with open(file_path, "w", encoding="utf-8", newline="\n") as file:
with file_path.open("wb") as file: json.dump(nested_data, file, ensure_ascii=False, indent=2)
tomli_w.dump(nested_data, file) file.write("\n") # Add trailing newline
def update_missing_keys(reference_file, file_list, branch=""): def update_missing_keys(reference_file, file_list, branch=""):
""" """
Updates missing keys in the translation files based on the reference file. Updates missing keys in the translation files based on the reference file.
:param reference_file: Path to the reference TOML file. :param reference_file: Path to the reference JSON file.
: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_json_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(".json")
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_json_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,29 +141,26 @@ 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_json_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):
update_missing_keys(reference_file, file_list, branch) update_missing_keys(reference_file, file_list, branch)
def read_toml_keys(file_path): def read_json_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_json_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}`")
reference_keys = read_toml_keys(reference_file) reference_keys = read_json_keys(reference_file)
has_differences = False has_differences = False
only_reference_file = True only_reference_file = True
@@ -184,44 +170,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(".json") or basename_current_file != "translation.json":
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_json_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 +240,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)
) )
] ]
) )
@@ -308,7 +272,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
report.append("## ❌ Overall Check Status: **_Failed_**") report.append("## ❌ Overall Check Status: **_Failed_**")
report.append("") report.append("")
report.append( report.append(
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/public/locales/en-GB/translation.toml)" f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.json](https://github.com/Stirling-Tools/Stirling-PDF/blob/V2/frontend/public/locales/en-GB/translation.json)"
) )
else: else:
report.append("## ✅ Overall Check Status: **_Success_**") report.append("## ✅ Overall Check Status: **_Success_**")
@@ -322,9 +286,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")
description="Find missing keys in TOML translation files"
)
parser.add_argument( parser.add_argument(
"--actor", "--actor",
required=False, required=False,
@@ -375,9 +337,9 @@ if __name__ == "__main__":
"public", "public",
"locales", "locales",
"*", "*",
"translation.toml", "translation.json",
) )
) )
update_missing_keys(args.reference_file, file_list) update_missing_keys(args.reference_file, file_list)
else: else:
check_for_differences(args.reference_file, file_list, args.branch, args.actor) check_for_differences(args.reference_file, file_list, args.branch, args.actor)
@@ -0,0 +1,403 @@
"""
Author: Ludy87
Description: This script processes .properties files for localization checks. It compares translation files in a branch with
a reference file to ensure consistency. The script performs two main checks:
1. Verifies that the number of lines (including comments and empty lines) in the translation files matches the reference file.
2. Ensures that all keys in the translation files are present in the reference file and vice versa.
The script also provides functionality to update the translation files to match the reference file by adding missing keys and
adjusting the format.
Usage:
python check_language_properties.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
"""
# Sample for Windows:
# python .github/scripts/check_language_properties.py --reference-file src\main\resources\messages_en_GB.properties --branch "" --files src\main\resources\messages_de_DE.properties src\main\resources\messages_uk_UA.properties
import copy
import glob
import os
import argparse
import re
def find_duplicate_keys(file_path):
"""
Identifies duplicate keys in a .properties file.
:param file_path: Path to the .properties file.
:return: List of tuples (key, first_occurrence_line, duplicate_line).
"""
keys = {}
duplicates = []
with open(file_path, "r", encoding="utf-8") as file:
for line_number, line in enumerate(file, start=1):
stripped_line = line.strip()
# Skip empty lines and comments
if not stripped_line or stripped_line.startswith("#"):
continue
# Split the line into key and value
if "=" in stripped_line:
key, _ = stripped_line.split("=", 1)
key = key.strip()
# Check if the key already exists
if key in keys:
duplicates.append((key, keys[key], line_number))
else:
keys[key] = line_number
return duplicates
# Maximum size for properties files (e.g., 200 KB)
MAX_FILE_SIZE = 200 * 1024
def parse_properties_file(file_path):
"""
Parses a .properties file and returns a structured list of its contents.
:param file_path: Path to the .properties file.
:return: List of dictionaries representing each line in the file.
"""
properties_list = []
with open(file_path, "r", encoding="utf-8") as file:
for line_number, line in enumerate(file, start=1):
stripped_line = line.strip()
# Handle empty lines
if not stripped_line:
properties_list.append(
{"line_number": line_number, "type": "empty", "content": ""}
)
continue
# Handle comments
if stripped_line.startswith("#"):
properties_list.append(
{
"line_number": line_number,
"type": "comment",
"content": stripped_line,
}
)
continue
# Handle key-value pairs
match = re.match(r"^([^=]+)=(.*)$", line)
if match:
key, value = match.groups()
properties_list.append(
{
"line_number": line_number,
"type": "entry",
"key": key.strip(),
"value": value.strip(),
}
)
return properties_list
def write_json_file(file_path, updated_properties):
"""
Writes updated properties back to the file in their original format.
:param file_path: Path to the .properties file.
:param updated_properties: List of updated properties to write.
"""
updated_lines = {entry["line_number"]: entry for entry in updated_properties}
# Sort lines by their numbers and retain comments and empty lines
all_lines = sorted(set(updated_lines.keys()))
original_format = []
for line in all_lines:
if line in updated_lines:
entry = updated_lines[line]
else:
entry = None
ref_entry = updated_lines[line]
if ref_entry["type"] in ["comment", "empty"]:
original_format.append(ref_entry)
elif entry is None:
# Add missing entries from the reference file
original_format.append(ref_entry)
elif entry["type"] == "entry":
# Replace entries with those from the current JSON
original_format.append(entry)
# Write the updated content back to the file
with open(file_path, "w", encoding="utf-8", newline="\n") as file:
for entry in original_format:
if entry["type"] == "comment":
file.write(f"{entry['content']}\n")
elif entry["type"] == "empty":
file.write(f"{entry['content']}\n")
elif entry["type"] == "entry":
file.write(f"{entry['key']}={entry['value']}\n")
def update_missing_keys(reference_file, file_list, branch=""):
"""
Updates missing keys in the translation files based on the reference file.
:param reference_file: Path to the reference .properties file.
:param file_list: List of translation files to update.
:param branch: Branch where the files are located.
"""
reference_properties = parse_properties_file(reference_file)
for file_path in file_list:
basename_current_file = os.path.basename(os.path.join(branch, file_path))
if (
basename_current_file == os.path.basename(reference_file)
or not file_path.endswith(".properties")
or not basename_current_file.startswith("messages_")
):
continue
current_properties = parse_properties_file(os.path.join(branch, file_path))
updated_properties = []
for ref_entry in reference_properties:
ref_entry_copy = copy.deepcopy(ref_entry)
for current_entry in current_properties:
if current_entry["type"] == "entry":
if ref_entry_copy["type"] != "entry":
continue
if ref_entry_copy["key"].lower() == current_entry["key"].lower():
ref_entry_copy["value"] = current_entry["value"]
updated_properties.append(ref_entry_copy)
write_json_file(os.path.join(branch, file_path), updated_properties)
def check_for_missing_keys(reference_file, file_list, branch):
update_missing_keys(reference_file, file_list, branch)
def read_properties(file_path):
if os.path.isfile(file_path) and os.path.exists(file_path):
with open(file_path, "r", encoding="utf-8") as file:
return file.read().splitlines()
return [""]
def check_for_differences(reference_file, file_list, branch, actor):
reference_branch = reference_file.split("/")[0]
basename_reference_file = os.path.basename(reference_file)
report = []
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
reference_lines = read_properties(reference_file)
has_differences = False
only_reference_file = True
file_arr = file_list
if len(file_list) == 1:
file_arr = file_list[0].split()
base_dir = os.path.abspath(
os.path.join(os.getcwd(), "app", "core", "src", "main", "resources")
)
for file_path in file_arr:
file_normpath = os.path.normpath(file_path)
absolute_path = os.path.abspath(file_normpath)
# Verify that file is within the expected directory
if not absolute_path.startswith(base_dir):
raise ValueError(f"Unsafe file found: {file_normpath}")
# Verify file size before processing
if os.path.getsize(os.path.join(branch, file_normpath)) > MAX_FILE_SIZE:
raise ValueError(
f"The file {file_normpath} is too large and could pose a security risk."
)
basename_current_file = os.path.basename(os.path.join(branch, file_normpath))
if (
basename_current_file == basename_reference_file
or (
# only local windows command
not file_normpath.startswith(
os.path.join(
"", "app", "core", "src", "main", "resources", "messages_"
)
)
and not file_normpath.startswith(
os.path.join(
os.getcwd(),
"app",
"core",
"src",
"main",
"resources",
"messages_",
)
)
)
or not file_normpath.endswith(".properties")
or not basename_current_file.startswith("messages_")
):
continue
only_reference_file = False
report.append(f"#### 📃 **File Check:** `{basename_current_file}`")
current_lines = read_properties(os.path.join(branch, file_path))
reference_line_count = len(reference_lines)
current_line_count = len(current_lines)
if reference_line_count != current_line_count:
report.append("")
report.append("1. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
has_differences = True
if reference_line_count > current_line_count:
report.append(
f" - **_Mismatched line count_**: {reference_line_count} (reference) vs {current_line_count} (current). Comments, empty lines, or translation strings are missing."
)
elif reference_line_count < current_line_count:
report.append(
f" - **_Too many lines_**: {reference_line_count} (reference) vs {current_line_count} (current). Please verify if there is an additional line that needs to be removed."
)
else:
report.append("1. **Test Status:** ✅ **_Passed_**")
# Check for missing or extra keys
current_keys = []
reference_keys = []
for line in current_lines:
if not line.startswith("#") and line != "" and "=" in line:
key, _ = line.split("=", 1)
current_keys.append(key)
for line in reference_lines:
if not line.startswith("#") and line != "" and "=" in line:
key, _ = line.split("=", 1)
reference_keys.append(key)
current_keys_set = set(current_keys)
reference_keys_set = set(reference_keys)
missing_keys = current_keys_set.difference(reference_keys_set)
extra_keys = reference_keys_set.difference(current_keys_set)
missing_keys_list = list(missing_keys)
extra_keys_list = list(extra_keys)
if missing_keys_list or extra_keys_list:
has_differences = True
missing_keys_str = "`, `".join(missing_keys_list)
extra_keys_str = "`, `".join(extra_keys_list)
report.append("2. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
if missing_keys_list:
spaces_keys_list = []
for key in missing_keys_list:
if " " in key:
spaces_keys_list.append(key)
if spaces_keys_list:
spaces_keys_str = "`, `".join(spaces_keys_list)
report.append(
f" - **_Keys containing unnecessary spaces_**: `{spaces_keys_str}`!"
)
report.append(
f" - **_Extra keys in `{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**."
)
if extra_keys_list:
report.append(
f" - **_Missing keys in `{basename_reference_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_current_file}`_**."
)
else:
report.append("2. **Test Status:** ✅ **_Passed_**")
if find_duplicate_keys(os.path.join(branch, file_normpath)):
has_differences = True
output = "\n".join(
[
f" - `{key}`: first at line {first}, duplicate at `line {duplicate}`"
for key, first, duplicate in find_duplicate_keys(
os.path.join(branch, file_normpath)
)
]
)
report.append("3. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
report.append(" - duplicate entries were found:")
report.append(output)
else:
report.append("3. **Test Status:** ✅ **_Passed_**")
report.append("")
report.append("---")
report.append("")
if has_differences:
report.append("## ❌ Overall Check Status: **_Failed_**")
report.append("")
report.append(
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/messages_en_GB.properties)"
)
else:
report.append("## ✅ Overall Check Status: **_Success_**")
report.append("")
report.append(
f"Thanks @{actor} for your help in keeping the translations up to date."
)
if not only_reference_file:
print("\n".join(report))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Find missing keys")
parser.add_argument(
"--actor",
required=False,
help="Actor from PR.",
)
parser.add_argument(
"--reference-file",
required=True,
help="Path to the reference file.",
)
parser.add_argument(
"--branch",
type=str,
required=True,
help="Branch name.",
)
parser.add_argument(
"--check-file",
type=str,
required=False,
help="List of changed files, separated by spaces.",
)
parser.add_argument(
"--files",
nargs="+",
required=False,
help="List of changed files, separated by spaces.",
)
args = parser.parse_args()
# Sanitize --actor input to avoid injection attacks
if args.actor:
args.actor = re.sub(r"[^a-zA-Z0-9_\\-]", "", args.actor)
# Sanitize --branch input to avoid injection attacks
if args.branch:
args.branch = re.sub(r"[^a-zA-Z0-9\\-]", "", args.branch)
file_list = args.files
if file_list is None:
if args.check_file:
file_list = [args.check_file]
else:
file_list = glob.glob(
os.path.join(
os.getcwd(),
"app",
"core",
"src",
"main",
"resources",
"messages_*.properties",
)
)
update_missing_keys(args.reference_file, file_list)
else:
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
-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
+128 -73
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,28 +290,27 @@ 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
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/logs:/logs:rw - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/logs:/logs:rw
environment: environment:
DISABLE_ADDITIONAL_FEATURES: "false" DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "true" SECURITY_ENABLELOGIN: "false"
SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
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 +318,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 +344,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 +361,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 +383,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 +411,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 +428,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 +459,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 +478,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
+18 -172
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 }}
@@ -188,7 +180,7 @@ jobs:
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: ./Dockerfile
push: true push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }} tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }}
build-args: VERSION_TAG=alpha build-args: VERSION_TAG=alpha
@@ -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
+55 -120
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,74 +262,58 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
include: docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "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
with: with:
builder: ${{ steps.buildx.outputs.name }} builder: ${{ steps.buildx.outputs.name }}
context: . context: .
file: ./${{ matrix.docker-rev }} file: ./docker/backend/${{ 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/
@@ -1,16 +1,19 @@
name: Check TOML Translation Files on PR name: Check Properties Files on PR
# This workflow validates TOML translation files
on: on:
pull_request_target: pull_request_target:
types: [opened, synchronize, reopened] types: [opened, synchronize, reopened]
paths: paths:
- "frontend/public/locales/*/translation.toml" - "app/core/src/main/resources/messages_*.properties"
- ".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
# 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: concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.ref }} group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.ref }}
cancel-in-progress: true cancel-in-progress: true
@@ -27,12 +30,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
@@ -70,22 +73,22 @@ jobs:
run: | run: |
echo "Fetching PR changed files..." echo "Fetching PR changed files..."
echo "Getting list of changed files from PR..." echo "Getting list of changed files from PR..."
# Check if PR number exists # Check if PR number exists
if [ -z "${{ steps.get-pr-data.outputs.pr_number }}" ]; then if [ -z "${{ steps.get-pr-data.outputs.pr_number }}" ]; then
echo "Error: PR number is empty" echo "Error: PR number is empty"
exit 1 exit 1
fi fi
# Get changed files and filter for TOML translation files # Get changed files and filter for properties files, handle case where no matches are found
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^frontend/public/locales/[a-zA-Z-]+/translation\.toml$' > changed_files.txt || echo "No matching TOML files found in PR" gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^app/core/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$' > changed_files.txt || echo "No matching properties files found in PR"
# Check if any files were found # Check if any files were found
if [ ! -s changed_files.txt ]; then if [ ! -s changed_files.txt ]; then
echo "No TOML translation files changed in this PR" echo "No properties files changed in this PR"
echo "Workflow will exit early as no relevant files to check" echo "Workflow will exit early as no relevant files to check"
exit 0 exit 0
fi fi
echo "Found $(wc -l < changed_files.txt) matching TOML files" echo "Found $(wc -l < changed_files.txt) matching properties files"
- name: Determine reference file - name: Determine reference file test
id: determine-file id: determine-file
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with: with:
@@ -122,11 +125,11 @@ jobs:
pull_number: prNumber, pull_number: prNumber,
}); });
// Filter for relevant TOML files based on the PR changes // Filter for relevant files based on the PR changes
const changedFiles = files const changedFiles = files
.filter(file => .filter(file =>
file.status !== "removed" && file.status !== "removed" &&
/^frontend\/public\/locales\/[a-zA-Z-]+\/translation\.toml$/.test(file.filename) /^app\/core\/src\/main\/resources\/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$/.test(file.filename)
) )
.map(file => file.filename); .map(file => file.filename);
@@ -166,16 +169,16 @@ jobs:
// Determine reference file // Determine reference file
let referenceFilePath; let referenceFilePath;
if (changedFiles.includes("frontend/public/locales/en-GB/translation.toml")) { if (changedFiles.includes("app/core/src/main/resources/messages_en_GB.properties")) {
console.log("Using PR branch reference file."); console.log("Using PR branch reference file.");
const { data: fileContent } = await github.rest.repos.getContent({ const { data: fileContent } = await github.rest.repos.getContent({
owner: prRepoOwner, owner: prRepoOwner,
repo: prRepoName, repo: prRepoName,
path: "frontend/public/locales/en-GB/translation.toml", path: "app/core/src/main/resources/messages_en_GB.properties",
ref: branch, ref: branch,
}); });
referenceFilePath = "pr-branch-translation-en-GB.toml"; referenceFilePath = "pr-branch-messages_en_GB.properties";
const content = Buffer.from(fileContent.content, "base64").toString("utf-8"); const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
fs.writeFileSync(referenceFilePath, content); fs.writeFileSync(referenceFilePath, content);
} else { } else {
@@ -183,11 +186,11 @@ jobs:
const { data: fileContent } = await github.rest.repos.getContent({ const { data: fileContent } = await github.rest.repos.getContent({
owner: repoOwner, owner: repoOwner,
repo: repoName, repo: repoName,
path: "frontend/public/locales/en-GB/translation.toml", path: "app/core/src/main/resources/messages_en_GB.properties",
ref: "main", ref: "main",
}); });
referenceFilePath = "main-branch-translation-en-GB.toml"; referenceFilePath = "main-branch-messages_en_GB.properties";
const content = Buffer.from(fileContent.content, "base64").toString("utf-8"); const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
fs.writeFileSync(referenceFilePath, content); fs.writeFileSync(referenceFilePath, content);
} }
@@ -195,19 +198,11 @@ jobs:
console.log(`Reference file path: ${referenceFilePath}`); console.log(`Reference file path: ${referenceFilePath}`);
core.exportVariable("REFERENCE_FILE", referenceFilePath); core.exportVariable("REFERENCE_FILE", referenceFilePath);
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: "3.12"
- name: Install Python dependencies
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
- name: Run Python script to check files - name: Run Python script to check files
id: run-check id: run-check
run: | run: |
echo "Running Python script to check TOML files..." echo "Running Python script to check files..."
python .github/scripts/check_language_toml.py \ python .github/scripts/check_language_properties.py \
--actor ${{ github.event.pull_request.user.login }} \ --actor ${{ github.event.pull_request.user.login }} \
--reference-file "${REFERENCE_FILE}" \ --reference-file "${REFERENCE_FILE}" \
--branch "pr-branch" \ --branch "pr-branch" \
@@ -218,7 +213,7 @@ jobs:
id: capture-output id: capture-output
run: | run: |
if [ -f result.txt ] && [ -s result.txt ]; then if [ -f result.txt ] && [ -s result.txt ]; then
echo "Capturing output..." echo "Test, capturing output..."
SCRIPT_OUTPUT=$(cat result.txt) SCRIPT_OUTPUT=$(cat result.txt)
echo "SCRIPT_OUTPUT<<EOF" >> $GITHUB_ENV echo "SCRIPT_OUTPUT<<EOF" >> $GITHUB_ENV
echo "$SCRIPT_OUTPUT" >> $GITHUB_ENV echo "$SCRIPT_OUTPUT" >> $GITHUB_ENV
@@ -232,7 +227,7 @@ jobs:
echo "FAIL_JOB=false" >> $GITHUB_ENV echo "FAIL_JOB=false" >> $GITHUB_ENV
fi fi
else else
echo "No output found." echo "No update found."
echo "SCRIPT_OUTPUT=" >> $GITHUB_ENV echo "SCRIPT_OUTPUT=" >> $GITHUB_ENV
echo "FAIL_JOB=false" >> $GITHUB_ENV echo "FAIL_JOB=false" >> $GITHUB_ENV
fi fi
@@ -254,7 +249,7 @@ jobs:
issue_number: issueNumber issue_number: issueNumber
}); });
const comment = comments.data.find(c => c.body.includes("## 🌐 TOML Translation Verification Summary")); const comment = comments.data.find(c => c.body.includes("## 🚀 Translation Verification Summary"));
// Only update or create comments by the action user // Only update or create comments by the action user
const expectedActor = "${{ steps.setup-bot.outputs.app-slug }}[bot]"; const expectedActor = "${{ steps.setup-bot.outputs.app-slug }}[bot]";
@@ -265,7 +260,7 @@ jobs:
owner: repoOwner, owner: repoOwner,
repo: repoName, repo: repoName,
comment_id: comment.id, comment_id: comment.id,
body: `## 🌐 TOML Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n` body: `## 🚀 Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
}); });
console.log("Updated existing comment."); console.log("Updated existing comment.");
} else if (!comment) { } else if (!comment) {
@@ -274,7 +269,7 @@ jobs:
owner: repoOwner, owner: repoOwner,
repo: repoName, repo: repoName,
issue_number: issueNumber, issue_number: issueNumber,
body: `## 🌐 TOML Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n` body: `## 🚀 Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
}); });
console.log("Created new comment."); console.log("Created new comment.");
} else { } else {
@@ -292,6 +287,6 @@ jobs:
run: | run: |
echo "Cleaning up temporary files..." echo "Cleaning up temporary files..."
rm -rf pr-branch rm -rf pr-branch
rm -f pr-branch-translation-en-GB.toml main-branch-translation-en-GB.toml changed_files.txt result.txt rm -f pr-branch-messages_en_GB.properties main-branch-messages_en_GB.properties changed_files.txt result.txt
echo "Cleanup complete." echo "Cleanup complete."
continue-on-error: true # Ensure cleanup runs even if previous steps fail continue-on-error: true # Ensure cleanup runs even if previous steps fail
+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
+210 -485
View File
@@ -1,4 +1,4 @@
name: Multi-OS Tauri Releases name: Test Installers Build
on: on:
workflow_dispatch: workflow_dispatch:
@@ -6,23 +6,7 @@ on:
test_mode: test_mode:
description: "Run in test mode (skip release step)" description: "Run in test mode (skip release step)"
required: false required: false
default: "true" default: "false"
type: choice
options:
- "true"
- "false"
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-master]
release: release:
types: [created] types: [created]
@@ -30,564 +14,305 @@ permissions:
contents: read contents: read
jobs: jobs:
determine-matrix: read_versions:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
version: ${{ steps.versionNumber.outputs.versionNumber }} version: ${{ steps.versionNumber.outputs.versionNumber }}
versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }}
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
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with: with:
java-version: "21" distribution: 'temurin'
distribution: "temurin" java-version: '21'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
# ✅ Get version from Gradle
- name: Get version number - name: Get version number
id: versionNumber id: versionNumber
run: | run: |
echo "Running gradlew printVersion..."
./gradlew printVersion --quiet
VERSION=$(./gradlew printVersion --quiet | tail -1) VERSION=$(./gradlew printVersion --quiet | tail -1)
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 # ✅ Get Mac-specific version from Gradle
id: set-matrix - name: Get version number mac
id: versionNumberMac
run: | run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then VERSION_MAC=$(./gradlew printMacVersion --quiet | tail -1)
case "${{ github.event.inputs.platform }}" in echo "versionNumberMac=$VERSION_MAC" >> $GITHUB_OUTPUT
"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/release 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-jars: build-portable:
needs: determine-matrix needs: read_versions
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
variant: disable_security: [true, false]
- name: "default" include:
disable_security: true - disable_security: false
build_frontend: true
file_suffix: ""
- name: "with-login"
disable_security: false
build_frontend: true
file_suffix: "-with-login" file_suffix: "-with-login"
- name: "server-only" - disable_security: true
disable_security: true file_suffix: ""
build_frontend: false
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
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: Generate jar (Disable Security=${{ matrix.disable_security }})
if: matrix.variant.build_frontend == true run: ./gradlew clean createExe
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Build JAR
run: ./gradlew build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
env: env:
MAVEN_USER: ${{ secrets.MAVEN_USER }} DISABLE_ADDITIONAL_FEATURES: ${{ matrix.disable_security }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.variant.disable_security }}
STIRLING_PDF_DESKTOP_UI: false STIRLING_PDF_DESKTOP_UI: false
- name: Rename JAR - name: Rename binaries
run: | run: |
echo "Version from determine-matrix: ${{ needs.determine-matrix.outputs.version }}" mkdir ./binaries
echo "Looking for: app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar" mv ./build/launch4j/Stirling-PDF.exe ./binaries/win-Stirling-PDF-portable-Server${{ matrix.file_suffix }}.exe
ls -la app/core/build/libs/ mv ./app/core/build/libs/stirling-pdf-${{ needs.read_versions.outputs.version }}.jar ./binaries/Stirling-PDF${{ matrix.file_suffix }}.jar
mkdir -p ./jar-dist
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 build 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 }}
path: ./jar-dist/*.jar
retention-days: 1 retention-days: 1
if-no-files-found: error
name: stirling${{ matrix.file_suffix }}-binaries
path: |
./binaries/*
build: sign_verify-portable:
needs: determine-matrix needs: [build-portable, read_versions]
runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false matrix:
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }} disable_security: [true, false]
runs-on: ${{ matrix.platform }} include:
env: - disable_security: false
SM_API_KEY: ${{ secrets.SM_API_KEY }} file_suffix: "with-login-"
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - disable_security: true
file_suffix: ""
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: Download build artifacts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: 22 name: stirling-${{ matrix.file_suffix }}binaries
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Setup Rust - name: Display structure of downloaded files
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable run: ls -R
- name: Upload signed artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
toolchain: stable retention-days: 1
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} if-no-files-found: error
name: stirling-${{ matrix.file_suffix }}signed
path: |
./*
!cosign.*
build-installers:
needs: read_versions
strategy:
matrix:
include:
- os: windows-latest
platform: win-
- os: macos-latest
platform: mac-
# - os: ubuntu-latest
# platform: linux-
runs-on: ${{ matrix.os }}
permissions:
contents: 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 - name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
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: Build Java backend with JLink # Install Windows dependencies
working-directory: ./ - name: Install WiX Toolset
shell: bash if: matrix.os == 'windows-latest'
run: | run: |
chmod +x ./gradlew curl -L -o wix.exe https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314.exe
echo "🔧 Building Stirling-PDF JAR..." .\wix.exe /install /quiet
./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Find the built JAR # Build installer
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1) - name: Build Installer
echo "✅ Built JAR: $STIRLING_JAR" run: ./gradlew build jpackage -x test --info
# 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: 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: true
BROWSER_OPEN: true
- name: Install frontend dependencies - name: Set up JDK (x86_64)
working-directory: ./frontend if: matrix.os == 'macos-latest'
run: npm ci
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
id: digicert-setup
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
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.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
shell: pwsh
run: | run: |
Write-Host "Setting up DigiCert KeyLocker environment..." curl -L -o jdk.tar.gz https://cdn.azul.com/zulu/bin/zulu17.56.15-ca-jdk17.0.14-macosx_x64.tar.gz
mkdir -p zulu17
tar -xzf jdk.tar.gz -C zulu17 --strip-components=1
echo "JAVA_HOME=$PWD/zulu17" >> $GITHUB_ENV
echo "$PWD/zulu17/bin" >> $GITHUB_PATH
# Decode client certificate - name: Verify JDK architecture
$certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}") if: matrix.os == 'macos-latest'
$certPath = "D:\Certificate_pkcs12.p12" run: file $JAVA_HOME/bin/java
[IO.File]::WriteAllBytes($certPath, $certBytes)
# Set environment variables - name: Build project and run jpackage (x86_64)
echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV if: matrix.os == 'macos-latest'
echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV run: arch -x86_64 ./gradlew jpackageMacX64
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 # Rename and collect artifacts based on OS
$pkcs11Config = $env:PKCS11_CONFIG - name: Prepare artifacts
if ($pkcs11Config) { id: prepare
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.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
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') && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master')
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') && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master')
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@51a9f1156b33df106d827c3a78f8f894946c5faa # v0.5.25
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 release or V2-master
SIGN: ${{ (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') && (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.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
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
$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
$pkcs11Config = $env:PKCS11_CONFIG
if (-not $pkcs11Config) {
Write-Host "[ERROR] PKCS11_CONFIG environment variable not set"
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..."
$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"
if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") {
Write-Host "[ERROR] Signing failed for $($file.Name)"
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 shell: bash
run: | run: |
mkdir -p ./dist ls -lah ./build/jpackage/
cd ./frontend/src-tauri/target mkdir ./binaries
if [ "${{ matrix.os }}" = "windows-latest" ]; then
# Find and rename artifacts based on platform mv "./build/jpackage/Stirling PDF-${{ needs.read_versions.outputs.version }}.exe" "./binaries/Stirling-PDF-win-installer.exe"
if [ "${{ matrix.platform }}" = "windows-latest" ]; then elif [ "${{ matrix.os }}" = "macos-latest" ]; then
find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \; mv "./build/jpackage/Stirling PDF-${{ needs.read_versions.outputs.versionMac }}.dmg" "./binaries/Stirling-PDF-mac-installer.dmg"
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \; mv "./build/jpackage/x86_64/Stirling PDF (x86_64)-${{ needs.read_versions.outputs.versionMac }}.dmg" "./binaries/Stirling-PDF-mac-x86_64-installer.dmg"
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 else
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \; mv "./build/jpackage/stirling-pdf_${{ needs.read_versions.outputs.version }}-1_amd64.deb" "./binaries/Stirling-PDF-linux-installer.deb"
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
fi fi
- name: Display structure of downloaded files
run: ls -R ./binaries
- 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 }}
path: ./dist/*
retention-days: 1 retention-days: 1
if-no-files-found: error
name: ${{ matrix.platform }}binaries
path: |
./binaries/*
sign_verify:
needs: [read_versions, build-installers]
strategy:
matrix:
include:
- os: windows-latest
platform: win-
- os: macos-latest
platform: mac-
# - os: ubuntu-latest
# platform: linux-
runs-on: ubuntu-latest
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: ${{ matrix.platform }}binaries
- name: Display structure of downloaded files
run: ls -R
- name: Install Cosign
if: matrix.os == 'windows-latest'
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
- name: Generate key pair
if: matrix.os == 'windows-latest'
run: cosign generate-key-pair
- name: Sign and generate attestations
if: matrix.os == 'windows-latest'
run: |
cosign sign-blob \
--key ./cosign.key \
--yes \
--output-signature ./Stirling-PDF-win-installer.exe.sig \
./Stirling-PDF-win-installer.exe
cosign attest-blob \
--predicate - \
--key ./cosign.key \
--yes \
--output-attestation ./Stirling-PDF-win-installer.exe.intoto.jsonl \
./Stirling-PDF-win-installer.exe
cosign verify-blob \
--key ./cosign.pub \
--signature ./Stirling-PDF-win-installer.exe.sig \
./Stirling-PDF-win-installer.exe
- name: Display structure of downloaded files
run: ls -R
- name: Upload signed artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
retention-days: 1
if-no-files-found: error
name: ${{ matrix.platform }}signed
path: |
./Stirling-PDF-${{ matrix.platform }}installer.*
./Stirling-PDF-${{ matrix.platform }}x86_64-installer.*
!cosign.*
create-release: create-release:
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master' if: github.event_name != 'workflow_dispatch' || github.event.inputs.test_mode != 'true'
needs: [determine-matrix, build, build-jars] needs: [read_versions, sign_verify, sign_verify-portable]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
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 signed artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
pattern: Stirling-PDF-*
path: ./artifacts/tauri
- name: Download JAR artifact (default)
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: jar
path: ./artifacts/jars
- name: Download JAR artifact (with login)
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: jar-with-login
path: ./artifacts/jars
- name: Download JAR artifact (server only)
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: jar-server
path: ./artifacts/jars
- name: Display structure of downloaded files - name: Display structure of downloaded files
run: ls -R ./artifacts run: ls -R
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
- name: Upload binaries to Release uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with: with:
tag_name: v${{ needs.determine-matrix.outputs.version }} tag_name: v${{ needs.read_versions.outputs.version }}
generate_release_notes: true generate_release_notes: true
files: | files: |
./artifacts/**/*.jar ./*signed/*
./artifacts/**/*.msi
./artifacts/**/*.dmg
./artifacts/**/*.deb
./artifacts/**/*.AppImage
draft: false
prerelease: false
+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"
+79 -100
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,17 +97,17 @@ 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: .
file: ./docker/embedded/Dockerfile file: ./Dockerfile
push: true push: true
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
@@ -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,76 +143,62 @@ 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-fat id: build-push-lite
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' if: github.ref != 'refs/heads/main'
with: with:
builder: ${{ steps.buildx.outputs.name }}
context: . context: .
file: ./docker/embedded/Dockerfile.fat file: ./Dockerfile.ultra-lite
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.meta2.outputs.tags }}
labels: ${{ steps.meta-fat.outputs.labels }} 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
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./Dockerfile.fat
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta3.outputs.tags }}
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: |
+180
View File
@@ -0,0 +1,180 @@
name: Release Artifacts
on:
workflow_dispatch:
release:
types: [created]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
disable_security: [true, false]
include:
- disable_security: false
file_suffix: "-with-login"
- disable_security: true
file_suffix: ""
outputs:
version: ${{ steps.versionNumber.outputs.versionNumber }}
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 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
- name: Generate jar (Disable Security=${{ matrix.disable_security }})
run: ./gradlew clean createExe
env:
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.disable_security }}
STIRLING_PDF_DESKTOP_UI: false
- name: Get version number
id: versionNumber
run: |
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Rename binaries
run: |
mv ./build/launch4j/Stirling-PDF.exe ./build/launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
mv ./build/libs/Stirling-PDF-${{ steps.versionNumber.outputs.versionNumber }}.jar ./build/libs/Stirling-PDF${{ matrix.file_suffix }}.jar
- name: Debug build artifacts
run: |
echo "Current Directory: $(pwd)"
ls -R ./build/libs
ls -R ./build/launch4j
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: binaries${{ matrix.file_suffix }}
path: |
./build/launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.*
./build/libs/Stirling-PDF${{ matrix.file_suffix }}.*
sign_verify:
needs: build
runs-on: ubuntu-latest
strategy:
matrix:
disable_security: [true, false]
include:
- disable_security: false
file_suffix: "-with-login"
- disable_security: true
file_suffix: ""
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: binaries${{ matrix.file_suffix }}
- 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
run: |
cosign sign-blob \
--key ./cosign.key \
--yes \
--output-signature ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.sig \
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
cosign attest-blob \
--predicate - \
--key ./cosign.key \
--yes \
--output-attestation ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.intoto.jsonl \
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
cosign verify-blob \
--key ./cosign.pub \
--signature ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.sig \
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
cosign sign-blob \
--key ./cosign.key \
--yes \
--output-signature ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.sig \
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
cosign attest-blob \
--predicate - \
--key ./cosign.key \
--yes \
--output-attestation ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.intoto.jsonl \
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
cosign verify-blob \
--key ./cosign.pub \
--signature ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.sig \
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
- name: Upload signed artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: signed${{ matrix.file_suffix }}
path: |
./libs/Stirling-PDF${{ matrix.file_suffix }}.*
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.*
release:
needs: [build, sign_verify]
runs-on: ubuntu-latest
permissions:
contents: write
strategy:
matrix:
disable_security: [true, false]
include:
- disable_security: false
file_suffix: "-with-login"
- disable_security: true
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Download signed artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: signed${{ matrix.file_suffix }}
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
with:
tag_name: v${{ needs.build.outputs.version }}
generate_release_notes: true
files: |
./libs/Stirling-PDF*
./launch4j/Stirling-PDF-Server*
+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: |
+122
View File
@@ -0,0 +1,122 @@
name: Sync Files
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "build.gradle"
- "README.md"
- "app/core/src/main/resources/messages_*.properties"
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
- "scripts/ignore_translation.toml"
# 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:
sync-files:
runs-on: ubuntu-latest
env:
# Prevents sdist builds → no tar extraction
PIP_ONLY_BINARY: ":all:"
PIP_DISABLE_PIP_VERSION_CHECK: "1"
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: 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 Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
- name: Sync translation property files
run: |
python .github/scripts/check_language_properties.py --reference-file "app/core/src/main/resources/messages_en_GB.properties" --branch main
- name: Commit translation files
run: |
git add app/core/src/main/resources/messages_*.properties
git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "No changes detected"
- name: Install dependencies
# Wheels-only + Hash-Pinning
run: |
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
- name: Sync README.md
run: |
python scripts/counter_translation.py
- name: Run git add
run: |
git add README.md scripts/ignore_translation.toml
git diff --staged --quiet || git commit -m ":memo: Sync README.md & scripts/ignore_translation.toml" || echo "No changes detected"
- name: Create Pull Request
if: always()
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: Update files
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: sync_readme
title: ":globe_with_meridians: Sync Translations + Update README Progress Table"
body: |
### Description of Changes
This Pull Request was automatically generated to synchronize updates to translation files and documentation. Below are the details of the changes made:
#### **1. Synchronization of Translation Files**
- Updated translation files (`messages_*.properties`) to reflect changes in the reference file `messages_en_GB.properties`.
- Ensured consistency and synchronization across all supported language files.
- Highlighted any missing or incomplete translations.
#### **2. Update README.md**
- Generated the translation progress table in `README.md`.
- Added a summary of the current translation status for all supported languages.
- Included up-to-date statistics on translation coverage.
#### **Why these changes are necessary**
- Keeps translation files aligned with the latest reference updates.
- Ensures the documentation reflects the current translation progress.
---
Auto-generated by [create-pull-request][1].
[1]: https://github.com/peter-evans/create-pull-request
draft: false
delete-branch: true
labels: github-actions
sign-commits: true
add-paths: |
README.md
app/core/src/main/resources/messages_*.properties
+23 -33
View File
@@ -1,17 +1,15 @@
name: Sync Files (TOML) name: Sync Files V2
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
branches: branches:
- main - V2
- 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.json"
- "app/core/src/main/resources/static/3rdPartyLicenses.json" - "app/core/src/main/resources/static/3rdPartyLicenses.json"
- "scripts/ignore_translation.toml" - "scripts/ignore_translation.toml"
@@ -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,30 +47,26 @@ 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: Sync translation JSON files
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt -r ./.github/scripts/requirements_pre_commit.txt
- 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_json.py --reference-file "frontend/public/locales/en-GB/translation.json" --branch V2
- 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.json
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" || echo "No changes detected"
- name: Install 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_v2.py
- name: Run git add - name: Run git add
run: | run: |
@@ -83,29 +75,28 @@ 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
committer: ${{ steps.setup-bot.outputs.committer }} committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }} author: ${{ steps.setup-bot.outputs.committer }}
signoff: true signoff: true
branch: sync_readme_v3 branch: sync_readme_v2
base: main base: V2
title: ":globe_with_meridians: Sync Translations + Update README Progress Table" title: ":globe_with_meridians: [V2] Sync Translations + Update README Progress Table"
body: | body: |
### Description of Changes ### Description of Changes
This Pull Request was automatically generated to synchronize updates to translation files and documentation. Below are the details of the changes made: This Pull Request was automatically generated to synchronize updates to translation files and documentation for the **V2 branch**. Below are the details of the changes made:
#### **1. Synchronization of Translation Files** #### **1. Synchronization of Translation Files**
- Updated translation files (`frontend/public/locales/*/translation.toml`) to reflect changes in the reference file `en-GB/translation.toml`. - Updated translation files (`frontend/public/locales/*/translation.json`) to reflect changes in the reference file `en-GB/translation.json`.
- Ensured consistency and synchronization across all supported language files. - Ensured consistency and synchronization across all supported language files.
- Highlighted any missing or incomplete translations. - Highlighted any missing or incomplete translations.
- **Format**: TOML
#### **2. Update README.md** #### **2. Update README.md**
- Generated the translation progress table in `README.md` using `counter_translation_v3.py`. - Generated the translation progress table in `README.md`.
- Added a summary of the current translation status for all supported languages. - Added a summary of the current translation status for all supported languages.
- Included up-to-date statistics on translation coverage. - Included up-to-date statistics on translation coverage.
@@ -124,5 +115,4 @@ jobs:
sign-commits: true sign-commits: true
add-paths: | add-paths: |
README.md README.md
frontend/public/locales/*/translation.toml frontend/public/locales/*/translation.json
scripts/ignore_translation.toml
+34 -452
View File
@@ -14,31 +14,24 @@ on:
- macos - macos
- linux - linux
pull_request: pull_request:
branches: [main, V2-tauri-windows] branches: [main, V2]
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: |
@@ -68,17 +61,14 @@ jobs:
fail-fast: false fail-fast: false
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }} matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
runs-on: ${{ matrix.platform }} runs-on: ${{ matrix.platform }}
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
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 +77,47 @@ 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 +133,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 +149,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,97 +163,16 @@ 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)
- 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@af7f4b07694120e514fe5b19f0598af3c20256e3 # v1.2.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 - name: Import Apple Developer Certificate
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel' if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
@@ -313,7 +219,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 }}
@@ -323,177 +229,13 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }} APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} SIGN: 1
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 CI: true
with: with:
projectPath: ./frontend projectPath: ./frontend
tauriScript: npx tauri tauriScript: npx tauri
args: ${{ matrix.args }} 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 ""
# List available certificates and check if they have certificates attached
Write-Host "Checking for available certificates..."
$certList = & smctl keypair ls 2>&1
Write-Host "Keypair list output:"
Write-Host $certList
Write-Host ""
# Parse the output to check certificate status
$lines = $certList -split "`n"
$foundKeypair = $false
$hasCertificate = $false
foreach ($line in $lines) {
if ($line -match "${{ secrets.SM_KEYPAIR_ALIAS }}") {
$foundKeypair = $true
Write-Host "[SUCCESS] Found keypair in list"
# Check if this line has certificate info (not just empty spaces after alias)
$parts = $line -split "\s+"
if ($parts.Count -gt 2 -and $parts[1] -ne "" -and $parts[1] -ne "CERTIFICATE") {
$hasCertificate = $true
Write-Host "[SUCCESS] Certificate is associated with keypair"
}
}
}
if (-not $foundKeypair) {
Write-Host "[ERROR] Keypair not found: ${{ secrets.SM_KEYPAIR_ALIAS }}"
Write-Host "Available keypairs are listed above"
Write-Host ""
Write-Host "Please verify:"
Write-Host " 1. Keypair alias is correct in GitHub secret"
Write-Host " 2. API key has access to this keypair"
exit 1
}
if (-not $hasCertificate) {
Write-Host "[ERROR] No certificate associated with keypair"
Write-Host "This usually means:"
Write-Host " 1. Certificate not yet synced to KeyLocker (run sync manually)"
Write-Host " 2. Certificate is pending approval"
Write-Host " 3. Certificate needs to be attached to the keypair"
Write-Host ""
Write-Host "Try running in DigiCert ONE portal:"
Write-Host " smctl keypair sync"
exit 1
}
Write-Host "[SUCCESS] Certificate check passed"
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"
Write-Host ""
Write-Host "Possible issues:"
Write-Host " 1. Certificate not fully synced to KeyLocker (wait a few minutes)"
Write-Host " 2. Incorrect keypair alias"
Write-Host " 3. API key lacks signing permissions"
Write-Host ""
Write-Host "Please verify in DigiCert ONE portal:"
Write-Host " - Certificate status is 'Issued' (not Pending)"
Write-Host " - Keypair status is 'Online'"
Write-Host " - 'Can sign' is set to 'Yes'"
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: Verify notarization (macOS only) - name: Verify notarization (macOS only)
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel' if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
run: | run: |
@@ -521,78 +263,19 @@ 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" \;
fi fi
- name: Verify Windows Code Signature
if: matrix.platform == 'windows-latest' && github.ref == 'refs/heads/main'
shell: pwsh
run: |
Write-Host "Verifying Windows code signatures..."
$exePath = "./dist/Stirling-PDF-${{ matrix.name }}.exe"
$msiPath = "./dist/Stirling-PDF-${{ matrix.name }}.msi"
$allSigned = $true
$usingKeyLocker = "${{ env.SM_API_KEY }}" -ne ""
$usingPfx = "${{ env.WINDOWS_CERTIFICATE }}" -ne ""
# Check EXE signature
if (Test-Path $exePath) {
$exeSig = Get-AuthenticodeSignature -FilePath $exePath
Write-Host "EXE Signature Status: $($exeSig.Status)"
Write-Host "EXE Signer: $($exeSig.SignerCertificate.Subject)"
Write-Host "EXE Timestamp: $($exeSig.TimeStamperCertificate.NotAfter)"
if ($exeSig.Status -ne "Valid") {
Write-Host "[WARNING] EXE is not properly signed (Status: $($exeSig.Status))"
if ($usingKeyLocker -or $usingPfx) {
Write-Host "[ERROR] Certificate was provided but signing failed"
$allSigned = $false
} else {
Write-Host "[INFO] Building unsigned binary (no certificate provided)"
}
} else {
Write-Host "[SUCCESS] EXE is properly signed"
}
}
# Check MSI signature
if (Test-Path $msiPath) {
$msiSig = Get-AuthenticodeSignature -FilePath $msiPath
Write-Host "MSI Signature Status: $($msiSig.Status)"
Write-Host "MSI Signer: $($msiSig.SignerCertificate.Subject)"
Write-Host "MSI Timestamp: $($msiSig.TimeStamperCertificate.NotAfter)"
if ($msiSig.Status -ne "Valid") {
Write-Host "[WARNING] MSI is not properly signed (Status: $($msiSig.Status))"
if ($usingKeyLocker -or $usingPfx) {
Write-Host "[ERROR] Certificate was provided but signing failed"
$allSigned = $false
} else {
Write-Host "[INFO] Building unsigned binary (no certificate provided)"
}
} else {
Write-Host "[SUCCESS] MSI is properly signed"
}
}
if (($usingKeyLocker -or $usingPfx) -and -not $allSigned) {
Write-Host "[ERROR] Code signing verification failed"
exit 1
} else {
Write-Host "[SUCCESS] Code signature verification completed"
}
- 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 +291,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 +323,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."
+23 -32
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 }}
@@ -70,7 +66,7 @@ jobs:
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: ./Dockerfile
push: true push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }} tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }} build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
@@ -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
+3 -34
View File
@@ -17,8 +17,8 @@ local.properties
version.properties version.properties
#### Stirling-PDF Files ### #### Stirling-PDF Files ###
pipeline/ pipeline/watchedFolders/
!pipeline/.gitkeep pipeline/finishedFolders/
customFiles/ customFiles/
configs/ configs/
watchedFolders/ watchedFolders/
@@ -31,25 +31,8 @@ exampleYmlFiles/stirling/
/testing/file_snapshots /testing/file_snapshots
SwaggerDoc.json SwaggerDoc.json
# Frontend build artifacts copied to backend static resources
# These are generated by npm build and should not be committed
app/core/src/main/resources/static/assets/
app/core/src/main/resources/static/index.html
app/core/src/main/resources/static/locales/
app/core/src/main/resources/static/Login/
app/core/src/main/resources/static/classic-logo/
app/core/src/main/resources/static/modern-logo/
app/core/src/main/resources/static/og_images/
app/core/src/main/resources/static/samples/
app/core/src/main/resources/static/manifest-classic.json
app/core/src/main/resources/static/robots.txt
app/core/src/main/resources/static/pdfium/
app/core/src/main/resources/static/vendor/
# Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc.
# Gradle # Gradle
.gradle .gradle
.gradle-home
.lock .lock
# External tool builders # External tool builders
@@ -149,7 +132,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 +196,7 @@ id_ed25519.pub
.mypy_cache .mypy_cache
.pytest_cache .pytest_cache
.ipynb_checkpoints .ipynb_checkpoints
.build-cache
**/jcef-bundle/ **/jcef-bundle/
@@ -231,16 +213,3 @@ node_modules/
test_batch.json test_batch.json
*.backup.*.json *.backup.*.json
frontend/public/locales/*/translation.backup*.json frontend/public/locales/*/translation.backup*.json
# Development/build artifacts
.gradle-cache/
scripts/pdf-collection/
**/tmp/
*.backup
# Type3 development data
docs/type3/signatures/
# Type3 sample PDFs (development only)
**/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
+15 -7
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: [
{ {
@@ -202,10 +202,10 @@ const [ToolName] = (props: BaseToolProps) => {
## 5. Add Translations ## 5. Add Translations
Update translation files. **Important: Only update `en-GB` files** - other languages are handled separately. Update translation files. **Important: Only update `en-GB` files** - other languages are handled separately.
**File to update:** `frontend/public/locales/en-GB/translation.toml` **File to update:** `frontend/public/locales/en-GB/translation.json`
**Required Translation Keys**: **Required Translation Keys**:
```toml ```json
{ {
"home": { "home": {
"[toolName]": { "[toolName]": {
@@ -251,20 +251,28 @@ Update translation files. **Important: Only update `en-GB` files** - other langu
``` ```
**Translation Notes:** **Translation Notes:**
- **Only update `en-GB/translation.toml`** - other locale files are managed separately - **Only update `en-GB/translation.json`** - other locale files are managed separately
- Use descriptive keys that match your component's `t()` calls - Use descriptive keys that match your component's `t()` calls
- Include tooltip translations if you created tooltip hooks - Include tooltip translations if you created tooltip hooks
- 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.
-4
View File
@@ -6,10 +6,6 @@ Portions of this software are licensed as follows:
* All content that resides under the "app/proprietary/" directory of this repository, * All content that resides under the "app/proprietary/" directory of this repository,
if that directory exists, is licensed under the license defined in "app/proprietary/LICENSE". if that directory exists, is licensed under the license defined in "app/proprietary/LICENSE".
* All content that resides under the "frontend/src/proprietary/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/proprietary/LICENSE".
* All content that resides under the "frontend/src/desktop/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/desktop/LICENSE".
* Content outside of the above mentioned directories or restrictions above is * Content outside of the above mentioned directories or restrictions above is
available under the MIT License as defined below. available under the MIT License as defined below.
+152 -48
View File
@@ -1,69 +1,173 @@
<p align="center"> <p align="center"><img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling.png" width="80"></p>
<img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling.png" width="80" alt="Stirling PDF logo"> <h1 align="center">Stirling-PDF</h1>
</p>
<h1 align="center">Stirling PDF - The Open-Source PDF Platform</h1> [![Docker Pulls](https://img.shields.io/docker/pulls/frooodle/s-pdf)](https://hub.docker.com/r/frooodle/s-pdf)
[![Discord](https://img.shields.io/discord/1068636748814483718?label=Discord)](https://discord.gg/HYmhKj45pU)
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/Stirling-Tools/Stirling-PDF/badge)](https://scorecard.dev/viewer/?uri=github.com/Stirling-Tools/Stirling-PDF)
[![GitHub Repo stars](https://img.shields.io/github/stars/stirling-tools/stirling-pdf?style=social)](https://github.com/Stirling-Tools/stirling-pdf)
Stirling PDF is a powerful, open-source PDF editing platform. Run it as a personal desktop app, in the browser, or deploy it on your own servers with a private API. Edit, sign, redact, convert, and automate PDFs without sending documents to external services. <a href="https://www.producthunt.com/posts/stirling-pdf?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-stirling&#0045;pdf" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=641239&theme=light" alt="Stirling&#0032;PDF - Open&#0032;source&#0032;locally&#0032;hosted&#0032;web&#0032;PDF&#0032;editor | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
[![Deploy to DO](https://www.deploytodo.com/do-btn-blue.svg)](https://cloud.digitalocean.com/apps/new?repo=https://github.com/Stirling-Tools/Stirling-PDF/tree/digitalOcean&refcode=c3210994b1af)
<p align="center"> [Stirling-PDF](https://www.stirlingpdf.com) is a robust, locally hosted web-based PDF manipulation tool using Docker. It enables you to carry out various operations on PDF files, including splitting, merging, converting, reorganizing, adding images, rotating, compressing, and more. This locally hosted web application has evolved to encompass a comprehensive set of features, addressing all your PDF requirements.
<a href="https://hub.docker.com/r/stirlingtools/stirling-pdf">
<img src="https://img.shields.io/docker/pulls/frooodle/s-pdf" alt="Docker Pulls">
</a>
<a href="https://discord.gg/HYmhKj45pU">
<img src="https://img.shields.io/discord/1068636748814483718?label=Discord" alt="Discord">
</a>
<a href="https://scorecard.dev/viewer/?uri=github.com/Stirling-Tools/Stirling-PDF">
<img src="https://api.scorecard.dev/projects/github.com/Stirling-Tools/Stirling-PDF/badge" alt="OpenSSF Scorecard">
</a>
<a href="https://github.com/Stirling-Tools/stirling-pdf">
<img src="https://img.shields.io/github/stars/stirling-tools/stirling-pdf?style=social" alt="GitHub Repo stars">
</a>
</p>
![Stirling PDF - Dashboard](images/home-light.png) All files and PDFs exist either exclusively on the client side, reside in server memory only during task execution, or temporarily reside in a file solely for the execution of the task. Any file downloaded by the user will have been deleted from the server by that point.
## Key Capabilities Homepage: [https://stirlingpdf.com](https://stirlingpdf.com)
- **Everywhere you work** - Desktop client, browser UI, and self-hosted server with a private API. All documentation available at [https://docs.stirlingpdf.com/](https://docs.stirlingpdf.com/)
- **50+ PDF tools** - Edit, merge, split, sign, redact, convert, OCR, compress, and more.
- **Automation & workflows** - No-code pipelines direct in UI with APIs to process millions of PDFs.
- **Enterprise‑grade** - SSO, auditing, and flexible on‑prem deployments.
- **Developer platform** - REST APIs available for nearly all tools to integrate into your existing systems.
- **Global UI** - Interface available in 40+ languages.
For a full feature list, see the docs: **https://docs.stirlingpdf.com** ![stirling-home](images/stirling-home.jpg)
## Quick Start ## Features
```bash - 50+ PDF Operations
docker run -p 8080:8080 docker.stirlingpdf.com/stirlingtools/stirling-pdf - Parallel file processing and downloads
``` - Dark mode support
- Custom download options
- Custom 'Pipelines' to run multiple features in a automated queue
- API for integration with external scripts
- Optional Login and Authentication support (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/System%20and%20Security) for documentation)
- Database Backup and Import (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/DATABASE) for documentation)
- Enterprise features like SSO (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration) for documentation)
Then open: http://localhost:8080 ## PDF Features
For full installation options (including desktop and Kubernetes), see our [Documentation Guide](https://docs.stirlingpdf.com/#documentation-guide). ### Page Operations
## Resources - View and modify PDFs - View multi-page PDFs with custom viewing, sorting, and searching. Plus, on-page edit features like annotating, drawing, and adding text and images. (Using PDF.js with Joxit and Liberation fonts)
- Full interactive GUI for merging/splitting/rotating/moving PDFs and their pages
- Merge multiple PDFs into a single resultant file
- Split PDFs into multiple files at specified page numbers or extract all pages as individual files
- Reorganize PDF pages into different orders
- Rotate PDFs in 90-degree increments
- Remove pages
- Multi-page layout (format PDFs into a multi-paged page)
- Scale page contents size by set percentage
- Adjust contrast
- Crop PDF
- Auto-split PDF (with physically scanned page dividers)
- Extract page(s)
- Convert PDF to a single page
- Overlay PDFs on top of each other
- PDF to a single page
- Split PDF by sections
- [**Documentation**](https://docs.stirlingpdf.com) ### Conversion Operations
- [**Homepage**](https://stirling.com)
- [**API Docs**](https://registry.scalar.com/@stirlingpdf/apis/stirling-pdf-processing-api/)
- [**Server Plan & Enterprise**](https://docs.stirlingpdf.com/Paid-Offerings)
## Support - Convert PDFs to and from images
- Convert any common file to PDF (using LibreOffice)
- Convert PDF to Word/PowerPoint/others (using LibreOffice)
- Convert HTML to PDF
- Convert PDF to XML
- Convert PDF to CSV
- URL to PDF
- Markdown to PDF
- **Community** [Discord](https://discord.gg/HYmhKj45pU) ### Security & Permissions
- **Bug Reports**: [Github issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
## Contributing - Add and remove passwords
- Change/set PDF permissions
- Add watermark(s)
- Certify/sign PDFs
- Sanitize PDFs
- Auto-redact text
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. ### Other Operations
For development setup, see the [Developer Guide](DeveloperGuide.md). - Add/generate/write signatures
- Split by Size or PDF
- Repair PDFs
- Detect and remove blank pages
- Compare two PDFs and show differences in text
- Add images to PDFs
- Compress PDFs to decrease their filesize (using qpdf)
- Extract images from PDF
- Remove images from PDF
- Extract images from scans
- Remove annotations
- Add page numbers
- Auto-rename files by detecting PDF header text
- OCR on PDF (using Tesseract OCR)
- PDF/A conversion (using LibreOffice)
- Edit metadata
- Flatten PDFs
- Get all information on a PDF to view or export as JSON
- Show/detect embedded JavaScript
For adding translations, see the [Translation Guide](devGuide/HowToAddNewLanguage.md).
## License
Stirling PDF is open-core. See [LICENSE](LICENSE) for details. # 📖 Get Started
Visit our comprehensive documentation at [docs.stirlingpdf.com](https://docs.stirlingpdf.com) for:
- Installation guides for all platforms
- Configuration options
- Feature documentation
- API reference
- Security setup
- Enterprise features
## Supported Languages
Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![64%](https://geps.dev/progress/64) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![24%](https://geps.dev/progress/24) |
| Basque (Euskara) (eu_ES) | ![14%](https://geps.dev/progress/14) |
| Bulgarian (Български) (bg_BG) | ![26%](https://geps.dev/progress/26) |
| Catalan (Català) (ca_CA) | ![26%](https://geps.dev/progress/26) |
| Croatian (Hrvatski) (hr_HR) | ![24%](https://geps.dev/progress/24) |
| Czech (Česky) (cs_CZ) | ![26%](https://geps.dev/progress/26) |
| Danish (Dansk) (da_DK) | ![23%](https://geps.dev/progress/23) |
| Dutch (Nederlands) (nl_NL) | ![23%](https://geps.dev/progress/23) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![63%](https://geps.dev/progress/63) |
| German (Deutsch) (de_DE) | ![64%](https://geps.dev/progress/64) |
| Greek (Ελληνικά) (el_GR) | ![26%](https://geps.dev/progress/26) |
| Hindi (हिंदी) (hi_IN) | ![26%](https://geps.dev/progress/26) |
| Hungarian (Magyar) (hu_HU) | ![29%](https://geps.dev/progress/29) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![24%](https://geps.dev/progress/24) |
| Irish (Gaeilge) (ga_IE) | ![26%](https://geps.dev/progress/26) |
| Italian (Italiano) (it_IT) | ![64%](https://geps.dev/progress/64) |
| Japanese (日本語) (ja_JP) | ![47%](https://geps.dev/progress/47) |
| Korean (한국어) (ko_KR) | ![26%](https://geps.dev/progress/26) |
| Norwegian (Norsk) (no_NB) | ![24%](https://geps.dev/progress/24) |
| Persian (فارسی) (fa_IR) | ![26%](https://geps.dev/progress/26) |
| Polish (Polski) (pl_PL) | ![27%](https://geps.dev/progress/27) |
| Portuguese (Português) (pt_PT) | ![26%](https://geps.dev/progress/26) |
| Portuguese Brazilian (Português) (pt_BR) | ![64%](https://geps.dev/progress/64) |
| Romanian (Română) (ro_RO) | ![22%](https://geps.dev/progress/22) |
| Russian (Русский) (ru_RU) | ![63%](https://geps.dev/progress/63) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![28%](https://geps.dev/progress/28) |
| Simplified Chinese (简体中文) (zh_CN) | ![65%](https://geps.dev/progress/65) |
| Slovakian (Slovensky) (sk_SK) | ![19%](https://geps.dev/progress/19) |
| Slovenian (Slovenščina) (sl_SI) | ![27%](https://geps.dev/progress/27) |
| Spanish (Español) (es_ES) | ![64%](https://geps.dev/progress/64) |
| Swedish (Svenska) (sv_SE) | ![25%](https://geps.dev/progress/25) |
| Thai (ไทย) (th_TH) | ![23%](https://geps.dev/progress/23) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) |
| Traditional Chinese (繁體中文) (zh_TW) | ![29%](https://geps.dev/progress/29) |
| Turkish (Türkçe) (tr_TR) | ![28%](https://geps.dev/progress/28) |
| Ukrainian (Українська) (uk_UA) | ![28%](https://geps.dev/progress/28) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![21%](https://geps.dev/progress/21) |
| Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) |
## Stirling PDF Enterprise
Stirling PDF offers an Enterprise edition of its software. This is the same great software but with added features, support and comforts.
Check out our [Enterprise docs](https://docs.stirlingpdf.com/Pro)
## 🤝 Looking to contribute?
Join our community:
- [Contribution Guidelines](CONTRIBUTING.md)
- [Translation Guide (How to add custom languages)](devGuide/HowToAddNewLanguage.md)
- [Developer Guide](devGuide/DeveloperGuide.md)
- [Issue Tracker](https://github.com/Stirling-Tools/Stirling-PDF/issues)
- [Discord Community](https://discord.gg/HYmhKj45pU)
-258
View File
@@ -1,258 +0,0 @@
# Windows Code Signing Setup Guide
This guide explains how to set up Windows code signing for Stirling-PDF desktop application builds.
## Overview
Windows code signing is essential for:
- Preventing Windows SmartScreen warnings
- Building trust with users
- Enabling Microsoft Store distribution
- Professional application distribution
## Certificate Types
### OV Certificate (Organization Validated)
- More affordable option
- Requires business verification
- May trigger SmartScreen warnings initially until reputation builds
- Suitable for most independent software vendors
### EV Certificate (Extended Validation)
- Premium option with immediate SmartScreen reputation
- Requires hardware security module (HSM) or cloud-based signing
- Higher cost but provides immediate trust
- Required since June 2023 for new certificates
## Obtaining a Certificate
### Certificate Authorities
Popular certificate authorities for Windows code signing:
- DigiCert
- Sectigo (formerly Comodo)
- GlobalSign
- SSL.com
### Certificate Format
You'll receive a certificate in one of these formats:
- `.pfx` or `.p12` (preferred - contains both certificate and private key)
- `.cer` + private key (needs conversion to .pfx)
### Converting to PFX (if needed)
If you have separate certificate and private key files:
```bash
openssl pkcs12 -export -out certificate.pfx -inkey private-key.key -in certificate.cer
```
## Setting Up GitHub Secrets
### Required Secrets
Navigate to your GitHub repository → Settings → Secrets and variables → Actions
Add the following secrets:
#### 1. `WINDOWS_CERTIFICATE`
- **Description**: Base64-encoded .pfx certificate file
- **How to create**:
**On macOS/Linux:**
```bash
base64 -i certificate.pfx | pbcopy # Copies to clipboard
```
**On Windows (PowerShell):**
```powershell
[Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx")) | Set-Clipboard
```
Paste the entire base64 string into the GitHub secret.
#### 2. `WINDOWS_CERTIFICATE_PASSWORD`
- **Description**: Password for the .pfx certificate
- **Value**: The password you set when creating/exporting the .pfx file
### Optional Secrets for Tauri Updater
If you're using Tauri's built-in updater feature:
#### `TAURI_SIGNING_PRIVATE_KEY`
- Generated using Tauri CLI: `npm run tauri signer generate`
- Used for update package verification
#### `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`
- Password for the Tauri signing key
## Configuration Files
### 1. Tauri Configuration (frontend/src-tauri/tauri.conf.json)
The Windows signing configuration is already set up:
```json
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.digicert.com"
}
```
**Configuration Options:**
- `certificateThumbprint`: Automatically extracted from imported certificate (leave as `null`)
- `digestAlgorithm`: Hashing algorithm - `sha256` is recommended
- `timestampUrl`: Timestamp server to prove signing time (survives certificate expiration)
**Alternative Timestamp Servers:**
- DigiCert: `http://timestamp.digicert.com`
- Sectigo: `http://timestamp.sectigo.com`
- GlobalSign: `http://timestamp.globalsign.com`
### 2. GitHub Workflow (.github/workflows/tauri-build.yml)
The workflow includes three Windows signing steps:
1. **Import Certificate**: Decodes and imports the .pfx certificate into Windows certificate store
2. **Build Tauri App**: Builds and signs the application using the imported certificate
3. **Verify Signature**: Validates that both .exe and .msi files are properly signed
## Testing the Setup
### 1. Local Testing (Windows Only)
Before pushing to GitHub, test locally:
```powershell
# Set environment variables
$env:WINDOWS_CERTIFICATE = [Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx"))
$env:WINDOWS_CERTIFICATE_PASSWORD = "your-certificate-password"
# Build the application
cd frontend
npm run tauri build
# Verify the signature
Get-AuthenticodeSignature "./src-tauri/target/release/bundle/msi/Stirling-PDF_*.msi"
```
### 2. GitHub Actions Testing
1. Push your changes to a branch
2. Manually trigger the workflow:
- Go to Actions → Build Tauri Applications
- Click "Run workflow"
- Select "windows" platform
3. Check the build logs for:
- ✅ Certificate import success
- ✅ Build completion
- ✅ Signature verification
### 3. Verifying Signed Binaries
After downloading the built artifacts:
**Windows (PowerShell):**
```powershell
Get-AuthenticodeSignature "Stirling-PDF-windows-x86_64.exe"
Get-AuthenticodeSignature "Stirling-PDF-windows-x86_64.msi"
```
Look for:
- Status: `Valid`
- Signer: Your organization name
- Timestamp: Recent date/time
**Windows (GUI):**
1. Right-click the .exe or .msi file
2. Select "Properties"
3. Go to "Digital Signatures" tab
4. Verify signature details
## Troubleshooting
### "HashMismatch" Status
- Certificate doesn't match the binary
- Possible file corruption during download
- Re-download and verify
### "NotSigned" Status
- Certificate wasn't imported correctly
- Check GitHub secrets are set correctly
- Verify base64 encoding is complete (no truncation)
### "UnknownError" Status
- Timestamp server unreachable
- Try alternative timestamp URL in tauri.conf.json
- Check network connectivity in GitHub Actions
### SmartScreen Still Shows Warnings
- Normal for OV certificates initially
- Reputation builds over time with user downloads
- Consider EV certificate for immediate reputation
### Certificate Not Found During Build
- Verify `WINDOWS_CERTIFICATE` secret is set
- Check base64 encoding is correct (no extra whitespace)
- Ensure password is correct
## Security Best Practices
1. **Never commit certificates to version control**
- Keep .pfx files secure and backed up
- Use GitHub secrets for CI/CD
2. **Rotate certificates before expiration**
- Set calendar reminders
- Update GitHub secrets with new certificate
3. **Use strong passwords**
- Certificate password should be complex
- Store securely (password manager)
4. **Monitor certificate usage**
- Review GitHub Actions logs
- Set up notifications for failed builds
5. **Limit access to secrets**
- Only repository admins should access secrets
- Audit secret access regularly
## Certificate Lifecycle
### Before Expiration
1. Obtain new certificate from CA (typically annual renewal)
2. Convert to .pfx format if needed
3. Update `WINDOWS_CERTIFICATE` secret with new base64-encoded certificate
4. Update `WINDOWS_CERTIFICATE_PASSWORD` if password changed
5. Test build to verify new certificate works
### Expired Certificates
- Signed binaries remain valid (timestamp proves signing time)
- New builds will fail until certificate is renewed
- Users can still install previously signed versions
## Cost Considerations
### Certificate Costs (Annual, as of 2024)
- **OV Certificate**: $100-400/year
- **EV Certificate**: $400-1000/year
### Choosing the Right Certificate
- **Open source / early stage**: Start with OV
- **Commercial / enterprise**: Consider EV for better trust
- **Microsoft Store**: EV certificate required
## Additional Resources
- [Tauri Windows Signing Documentation](https://v2.tauri.app/distribute/sign/windows/)
- [Microsoft Code Signing Overview](https://docs.microsoft.com/windows/win32/seccrypto/cryptography-tools)
- [DigiCert Code Signing Guide](https://www.digicert.com/signing/code-signing-certificates)
- [Windows SmartScreen FAQ](https://support.microsoft.com/windows/smartscreen-faq)
## Support
If you encounter issues with Windows code signing:
1. Check GitHub Actions logs for detailed error messages
2. Verify all secrets are set correctly
3. Test certificate locally first (Windows environment required)
4. Open an issue in the repository with relevant logs (remove sensitive data)
-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 -10
View File
@@ -29,23 +29,18 @@ 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 'com.github.junrar:junrar:7.5.5' // RAR archive support for CBR files
api "org.apache.pdfbox:xmpbox:$pdfboxVersion"
api "org.apache.pdfbox:preflight:$pdfboxVersion"
api 'com.github.junrar:junrar:7.5.7' // RAR archive support for CBR files
api 'jakarta.servlet:jakarta.servlet-api:6.1.0' api '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'
} }
@@ -22,13 +22,25 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Analysis", name = "Analysis",
description = description =
""" """
Read-only inspection of PDFs: page count, page sizes, fonts, form fields, annotations, document properties, and security details. Document analysis and information extraction services for content intelligence and insights.
Use these endpoints to understand what's inside a document without changing it.
Typical uses: This endpoint group provides analytical capabilities to understand document structure,
• Get page counts and dimensions for layout or print rules extract information, and generate insights from PDF content for automated processing.
• List fonts and annotations to spot compatibility issues
• Inspect form fields before deciding how to fill or modify them Common use cases:
• Pull metadata and security settings for audits or reports • Document inventory management and content audit for compliance verification
• Quality assurance workflows and business intelligence analytics
• Migration planning, accessibility evaluation, and document forensics
Business applications:
• Legal discovery, financial document review, and healthcare records analysis
• Academic research, government processing, and publishing optimization
Operational scenarios:
• Large-scale profiling, migration assessment, and performance optimization
• Automated quality control and content strategy development
Target users: Data analysts, QA teams, administrators, and business intelligence
professionals requiring detailed document insights.
""") """)
public @interface AnalysisApi {} public @interface AnalysisApi {}
@@ -22,13 +22,25 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Convert", name = "Convert",
description = description =
""" """
Convert PDFs to and from other formats (Word, images, HTML, Markdown, PDF/A, CBZ/CBR, EML, etc.). Document format transformation services for cross-platform compatibility and workflow integration.
This group also powers the text-editor / jobId-based editing flow for incremental PDF edits.
Typical uses: This endpoint group enables transformation between various formats, supporting
• Turn PDFs into Word or text for editing diverse business workflows and system integrations for mixed document ecosystems.
• Convert office files, images, HTML, or email (EML) into PDFs
• Create PDF/A for long-term archiving Common use cases:
• Export PDFs as images, HTML, CSV, or Markdown for search, analysis, or reuse • Legacy system integration, document migration, and cross-platform sharing
• Archive standardization, publishing preparation, and content adaptation
• Accessibility compliance and mobile-friendly document preparation
Business applications:
• Enterprise content management, digital publishing, and educational platforms
• Legal document processing, healthcare interoperability, and government standardization
Integration scenarios:
• API-driven pipelines, automated workflow preparation, and batch conversions
• Real-time format adaptation for user requests
Target users: System integrators, content managers, digital archivists, and
organizations requiring flexible document format interoperability.
""") """)
public @interface ConvertApi {} public @interface ConvertApi {}
@@ -22,13 +22,25 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Filter", name = "Filter",
description = description =
""" """
Check basic properties of PDFs before you process them: page count, file size, page size/rotation, and whether they contain text or images. Document content filtering and search operations for information discovery and organization.
Use these endpoints as a "pre-check" step to decide what to do with a file next.
Typical uses: This endpoint group enables intelligent content discovery and organization within
• Reject files that are too big or too small document collections for content-based processing and information extraction.
• Detect image-only PDFs that should go through OCR
• Ensure a document has enough pages before it enters a workflow Common use cases:
• Check orientation of pages before printing or merging • Legal discovery, research organization, and compliance auditing
• Content moderation, academic research, and business intelligence
• Quality assurance and content validation workflows
Business applications:
• Contract analysis, financial review, and healthcare records organization
• Government processing, educational curation, and IP protection
Workflow scenarios:
• Large-scale processing, automated classification, and information extraction
• Document preparation for further processing or analysis
Target users: Legal professionals, researchers, compliance officers, and
organizations requiring intelligent document content discovery and organization.
""") """)
public @interface FilterApi {} public @interface FilterApi {}
@@ -22,13 +22,21 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "General", name = "General",
description = description =
""" """
Page-level PDF editing: split, merge, rotate, crop, rearrange, and scale pages. Core PDF processing operations for fundamental document manipulation workflows.
These endpoints handle most daily "I opened a PDF editor just to…" type tasks.
Typical uses: This endpoint group provides essential PDF functionality that forms the foundation
• Split a large PDF into smaller files (by pages, chapters, or size) of most document processing workflows across various industries.
• Merge several PDFs into one report or pack
• Rotate or reorder pages before sending or archiving Common use cases:
• Turn a multi-page document into one long scrolling page • Document preparation for archival systems and content organization
• File preparation for distribution, accessibility compliance, and batch processing
• Document consolidation for reporting and legal compliance workflows
Typical applications:
• Content management, publishing workflows, and educational content distribution
• Business process automation and archive management
Target users: Content managers, document processors, and organizations requiring
reliable foundational PDF manipulation capabilities.
""") """)
public @interface GeneralApi {} public @interface GeneralApi {}
@@ -22,15 +22,25 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Misc", name = "Misc",
description = description =
""" """
Tools that don't fit neatly elsewhere: OCR, compress, repair, flatten, extract images, update metadata, add stamps/page numbers/images, and more. Specialized utilities and supplementary tools for enhanced document processing workflows.
These endpoints help fix problem PDFs and prepare them for sharing, storage, or further processing.
Typical uses: This endpoint group provides utility operations that support core document processing
• Repair a damaged PDF or remove blank pages tasks and address specific workflow needs in real-world scenarios.
• Run OCR on scanned PDFs so they become searchable
• Compress large PDFs for email or web download Common use cases:
• Extract embedded images or scans • Document optimization for bandwidth-limited environments and storage cost management
• Add page numbers, stamps, or overlay an image (e.g. logo, seal) • Document repair, content extraction, and validation for quality assurance
• Update PDF metadata (title, author, etc.) • Accessibility improvement and custom processing for specialized needs
Business applications:
• Web publishing optimization, email attachment management, and archive efficiency
• Mobile compatibility, print production, and legacy document recovery
Operational scenarios:
• Batch processing, quality control, and performance optimization
• Troubleshooting and recovery of problematic documents
Target users: System administrators, document specialists, and organizations requiring
specialized document processing and optimization tools.
""") """)
public @interface MiscApi {} public @interface MiscApi {}
@@ -22,12 +22,25 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Pipeline", name = "Pipeline",
description = description =
""" """
Run several PDF operations in one configured pipeline instead of calling multiple endpoints yourself. Automated document processing workflows for complex multi-stage business operations.
Useful when you always do the same steps in sequence (for example: convert → OCR → compress → watermark).
Typical uses: This endpoint group enables organizations to create sophisticated document processing
• Process incoming invoices in one go (clean, OCR, compress, stamp, etc.) workflows that combine multiple operations into streamlined, repeatable processes.
• Normalise documents before they enter an archive
• Wrap a complex document flow behind a single API call for your own apps Common use cases:
• Invoice processing, legal document review, and healthcare records standardization
• Government processing, educational content preparation, and publishing automation
• Contract lifecycle management and approval processes
Business applications:
• Automated compliance reporting, large-scale migration, and quality assurance
• Archive preparation, content delivery, and document approval workflows
Operational scenarios:
• Scheduled batch processing and event-driven document processing
• Multi-department coordination and business system integration
Target users: Business process managers, IT automation specialists, and organizations
requiring consistent, repeatable document processing workflows.
""") """)
public @interface PipelineApi {} public @interface PipelineApi {}
@@ -22,13 +22,25 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Security", name = "Security",
description = description =
""" """
Protect and clean PDFs: passwords, digital signatures, redaction, and sanitizing. Document security and protection services for confidential and sensitive content.
These endpoints help you control who can open a file, what they can do with it, and remove sensitive content when needed.
Typical uses: This endpoint group provides essential security operations for organizations handling
• Add or remove a password on a PDF sensitive documents and materials requiring controlled access.
• Redact personal or confidential information (manually or automatically)
• Validate or remove digital signatures Common use cases:
• Sanitize a PDF to strip scripts and embedded content • Legal confidentiality, healthcare privacy (HIPAA), and financial regulatory compliance
• Government classified handling, corporate IP protection, and educational privacy (FERPA)
• Contract security for business transactions
Business applications:
• Document authentication, confidential sharing, and secure archiving
• Content watermarking, access control, and privacy protection through redaction
Industry scenarios:
• Legal discovery, medical records exchange, financial audit documentation
• Enterprise policy enforcement and data governance
Target users: Legal professionals, healthcare administrators, compliance officers,
government agencies, and enterprises handling sensitive content.
""") """)
public @interface SecurityApi {} public @interface SecurityApi {}
@@ -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;
@@ -42,7 +41,7 @@ public class CustomPDFDocumentFactory {
// Memory thresholds and limits // Memory thresholds and limits
public static final long SMALL_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB private static final long SMALL_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB
// Files smaller than this threshold are loaded entirely in memory for better performance. // Files smaller than this threshold are loaded entirely in memory for better performance.
// These files use IOUtils.createMemoryOnlyStreamCache() which keeps all document data in RAM. // These files use IOUtils.createMemoryOnlyStreamCache() which keeps all document data in RAM.
// No temp files are created for document data, reducing I/O operations but consuming more // No temp files are created for document data, reducing I/O operations but consuming more
@@ -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)) {
@@ -9,7 +9,6 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
import java.util.function.Supplier; import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -38,9 +37,6 @@ public class JobExecutorService {
private final ExecutorService executor = ExecutorFactory.newVirtualOrCachedThreadExecutor(); private final ExecutorService executor = ExecutorFactory.newVirtualOrCachedThreadExecutor();
private final long effectiveTimeoutMs; private final long effectiveTimeoutMs;
@Autowired(required = false)
private JobOwnershipService jobOwnershipService;
public JobExecutorService( public JobExecutorService(
TaskManager taskManager, TaskManager taskManager,
FileStorage fileStorage, FileStorage fileStorage,
@@ -102,17 +98,11 @@ public class JobExecutorService {
long customTimeoutMs, long customTimeoutMs,
boolean queueable, boolean queueable,
int resourceWeight) { int resourceWeight) {
// Generate base UUID String jobId = UUID.randomUUID().toString();
String baseJobId = UUID.randomUUID().toString();
// Scope job to authenticated user if security is enabled // Store the job ID in the request for potential use by other components
String scopedJobKey = getScopedJobKey(baseJobId);
log.debug("Generated jobId: {} (base: {})", scopedJobKey, baseJobId);
// Store the scoped job ID in the request for potential use by other components
if (request != null) { if (request != null) {
request.setAttribute("jobId", scopedJobKey); request.setAttribute("jobId", jobId);
// Also track this job ID in the user's session for authorization purposes // Also track this job ID in the user's session for authorization purposes
// This ensures users can only cancel their own jobs // This ensures users can only cancel their own jobs
@@ -126,13 +116,11 @@ public class JobExecutorService {
request.getSession().setAttribute("userJobIds", userJobIds); request.getSession().setAttribute("userJobIds", userJobIds);
} }
userJobIds.add(scopedJobKey); userJobIds.add(jobId);
log.debug("Added scoped job ID {} to user session", scopedJobKey); log.debug("Added job ID {} to user session", jobId);
} }
} }
String jobId = scopedJobKey;
// Determine which timeout to use // Determine which timeout to use
long timeoutToUse = customTimeoutMs > 0 ? customTimeoutMs : effectiveTimeoutMs; long timeoutToUse = customTimeoutMs > 0 ? customTimeoutMs : effectiveTimeoutMs;
@@ -161,31 +149,17 @@ public class JobExecutorService {
taskManager.createTask(jobId); taskManager.createTask(jobId);
// Create a specialized wrapper that updates the TaskManager // Create a specialized wrapper that updates the TaskManager
final String capturedJobIdForQueue = jobId;
Supplier<Object> wrappedWork = Supplier<Object> wrappedWork =
() -> { () -> {
try { try {
// Set jobId in ThreadLocal context for the queued job
stirling.software.common.util.JobContext.setJobId(
capturedJobIdForQueue);
log.debug(
"Set jobId {} in JobContext for queued job execution",
capturedJobIdForQueue);
Object result = work.get(); Object result = work.get();
processJobResult(capturedJobIdForQueue, result); processJobResult(jobId, result);
return result; return result;
} catch (Exception e) { } catch (Exception e) {
log.error( log.error(
"Error executing queued job {}: {}", "Error executing queued job {}: {}", jobId, e.getMessage(), e);
capturedJobIdForQueue, taskManager.setError(jobId, e.getMessage());
e.getMessage(),
e);
taskManager.setError(capturedJobIdForQueue, e.getMessage());
throw e; throw e;
} finally {
// Clean up ThreadLocal to avoid memory leaks
stirling.software.common.util.JobContext.clear();
} }
}; };
@@ -197,36 +171,21 @@ public class JobExecutorService {
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null)); return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
} else if (async) { } else if (async) {
taskManager.createTask(jobId); taskManager.createTask(jobId);
// Capture the jobId for the async thread
final String capturedJobId = jobId;
executor.execute( executor.execute(
() -> { () -> {
try { try {
log.debug( log.debug(
"Running async job {} with timeout {} ms", "Running async job {} with timeout {} ms", jobId, timeoutToUse);
capturedJobId,
timeoutToUse);
// Set jobId in ThreadLocal context for the async thread
stirling.software.common.util.JobContext.setJobId(capturedJobId);
log.debug(
"Set jobId {} in JobContext for async execution",
capturedJobId);
// Execute with timeout // Execute with timeout
Object result = executeWithTimeout(() -> work.get(), timeoutToUse); Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
processJobResult(capturedJobId, result); processJobResult(jobId, result);
} catch (TimeoutException te) { } catch (TimeoutException te) {
log.error("Job {} timed out after {} ms", jobId, timeoutToUse); log.error("Job {} timed out after {} ms", jobId, timeoutToUse);
taskManager.setError(jobId, "Job timed out"); taskManager.setError(jobId, "Job timed out");
} catch (Exception e) { } catch (Exception e) {
log.error("Error executing job {}: {}", jobId, e.getMessage(), e); log.error("Error executing job {}: {}", jobId, e.getMessage(), e);
taskManager.setError(jobId, e.getMessage()); taskManager.setError(jobId, e.getMessage());
} finally {
// Clean up ThreadLocal to avoid memory leaks
stirling.software.common.util.JobContext.clear();
} }
}); });
@@ -235,10 +194,6 @@ public class JobExecutorService {
try { try {
log.debug("Running sync job with timeout {} ms", timeoutToUse); log.debug("Running sync job with timeout {} ms", timeoutToUse);
// Make jobId available to downstream components on the worker thread
stirling.software.common.util.JobContext.setJobId(jobId);
log.debug("Set jobId {} in JobContext for sync execution", jobId);
// Execute with timeout // Execute with timeout
Object result = executeWithTimeout(() -> work.get(), timeoutToUse); Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
@@ -253,29 +208,11 @@ 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
return ResponseEntity.internalServerError() return ResponseEntity.internalServerError()
.body(Map.of("error", "Job failed: " + e.getMessage())); .body(Map.of("error", "Job failed: " + e.getMessage()));
} finally {
stirling.software.common.util.JobContext.clear();
} }
} }
} }
@@ -529,23 +466,8 @@ public class JobExecutorService {
throws TimeoutException, Exception { throws TimeoutException, Exception {
// Use the same executor as other async jobs for consistency // Use the same executor as other async jobs for consistency
// This ensures all operations run on the same thread pool // This ensures all operations run on the same thread pool
String currentJobId = stirling.software.common.util.JobContext.getJobId();
java.util.concurrent.CompletableFuture<T> future = java.util.concurrent.CompletableFuture<T> future =
java.util.concurrent.CompletableFuture.supplyAsync( java.util.concurrent.CompletableFuture.supplyAsync(supplier, executor);
() -> {
if (currentJobId != null) {
stirling.software.common.util.JobContext.setJobId(currentJobId);
}
try {
return supplier.get();
} finally {
if (currentJobId != null) {
stirling.software.common.util.JobContext.clear();
}
}
},
executor);
try { try {
return future.get(timeoutMs, TimeUnit.MILLISECONDS); return future.get(timeoutMs, TimeUnit.MILLISECONDS);
@@ -561,18 +483,4 @@ public class JobExecutorService {
throw new Exception("Execution was interrupted", e); throw new Exception("Execution was interrupted", e);
} }
} }
/**
* Get a scoped job key that includes user ownership when security is enabled.
*
* @param baseJobId the base job identifier
* @return scoped job key, or just baseJobId if no ownership service available
*/
private String getScopedJobKey(String baseJobId) {
if (jobOwnershipService != null) {
return jobOwnershipService.createScopedJobKey(baseJobId);
}
// Security disabled, return unsecured job key
return baseJobId;
}
} }
@@ -1,42 +0,0 @@
package stirling.software.common.service;
import java.util.Optional;
/**
* Service interface for managing job ownership and access control. Implementations can provide
* user-scoped job isolation when security is enabled, or no-op behavior when security is disabled.
*/
public interface JobOwnershipService {
/**
* Get the current authenticated user's identifier.
*
* @return Optional containing user identifier, or empty if not authenticated
*/
Optional<String> getCurrentUserId();
/**
* Create a scoped job key that includes user ownership when security is enabled.
*
* @param jobId the base job identifier
* @return scoped job key in format "userId:jobId", or just jobId if no user authenticated
*/
String createScopedJobKey(String jobId);
/**
* Validate that the current user has access to the given job.
*
* @param scopedJobKey the scoped job key to validate
* @return true if current user owns the job or no authentication is required
* @throws SecurityException if current user does not own the job
*/
boolean validateJobAccess(String scopedJobKey);
/**
* Extract the base job ID from a scoped job key.
*
* @param scopedJobKey the scoped job key
* @return the base job ID without user prefix
*/
String extractJobId(String scopedJobKey);
}
@@ -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;
}
}
}
@@ -1,21 +0,0 @@
package stirling.software.common.service;
import java.io.IOException;
/**
* Interface for personal signature access (proprietary feature). Implemented only in proprietary
* module to provide authenticated users access to their personal signatures.
*/
public interface PersonalSignatureServiceInterface {
/**
* Get a personal signature from the user's folder. Only checks personal folder, not shared
* folder.
*
* @param username Username of the signature owner
* @param fileName Signature filename
* @return Personal signature image bytes
* @throws IOException If file not found or read error
*/
byte[] getPersonalSignatureBytes(String username, String fileName) throws IOException;
}
@@ -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,21 +33,11 @@ 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(
"OCR images.json", "OCR images.json",
"Prepare-pdfs-for-email.json", "Prepare-pdfs-for-email.json",
"Pre-publish-sanitization.json",
"split-rotate-auto-rename.json"); "split-rotate-auto-rename.json");
private final String DEFAULT_WEBUI_CONFIGS_DIR = "defaultWebUIConfigs"; private final String DEFAULT_WEBUI_CONFIGS_DIR = "defaultWebUIConfigs";
@@ -97,18 +85,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 +153,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 +164,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 +179,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 +200,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 +268,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 +278,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 +288,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 +307,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 +325,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 +364,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 +385,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 +441,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 +456,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 +722,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 +732,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 +939,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());

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