Compare commits

..
Author SHA1 Message Date
Anthony Stirling fc13a6ec66 ui 2025-12-22 00:50:27 +00:00
482 changed files with 12734 additions and 35905 deletions
+13 -35
View File
@@ -2,28 +2,26 @@ build: &build
- build.gradle
- app/(common|core|proprietary)/build.gradle
openapi: &openapi
- *build
app: &app
- app/(common|core|proprietary)/src/main/java/**
docker: &docker
- Dockerfile
- Dockerfile.fat
- Dockerfile.ultra-lite
- ".github/workflows/build.yml"
- scripts/init.sh
- scripts/init-without-ocr.sh
- exampleYmlFiles/**
openapi: &openapi
- build.gradle
- app/(common|core|proprietary)/build.gradle
- app/(common|core|proprietary)/src/main/java/**
project: &project
- app/(common|core|proprietary)/src/(main|test)/java/**
- *build
- "app/(common|core|proprietary)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*"
- app/(common|core|proprietary)/build.gradle
- 'app/(common|core|proprietary)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*'
- exampleYmlFiles/**
- gradle/**
- libs/**
- "testing/**/!(requirements*.txt|requirements*.in)*"
- *docker
- testing/**
- build.gradle
- Dockerfile
- Dockerfile.fat
- Dockerfile.ultra-lite
- gradle.properties
- gradlew
- gradlew.bat
@@ -31,30 +29,10 @@ project: &project
- settings.gradle
- frontend/**
- docker/**
- scripts/RestartHelper.java
- testing/**
frontend: &frontend
- frontend/**
- .github/workflows/testdriver.yml
- testing/**
- docker/**
- scripts/translations/*.py
- scripts/build-tauri-jlink.bat
- scripts/build-tauri-jlink.sh
- scripts/convert_cff_to_ttf.py
- scripts/harvest_type3_fonts.py
- scripts/ignore_translation.toml
- scripts/index_type3_catalogue.py
- scripts/summarize_type3_signatures.py
- scripts/type3_to_cff.py
- scripts/update_type3_library.py
licenses-frontend: &licenses-frontend
- ".github/workflows/frontend-backend-licenses-update.yml"
- "frontend/package.json"
- "frontend/package-lock.json"
- "frontend/scripts/generate-licenses.js"
licenses-backend: &licenses-backend
- ".github/workflows/frontend-backend-licenses-update.yml"
- *build
+1 -1
View File
@@ -1 +1 @@
allow-ghsas: GHSA-wrw7-89jp-8q8g
allow-ghsas: GHSA-wrw7-89jp-8q8g
-5
View File
@@ -1,9 +1,4 @@
{
"label_changer": [
"Frooodle",
"Ludy87",
"balazs-szucs"
],
"repo_devs": [
"Frooodle",
"sf298",
+2 -49
View File
@@ -6,65 +6,18 @@
version: 2
updates:
- package-ecosystem: "gradle" # See documentation for possible values
directories:
- "/" # Location of package manifests
- "/app/common"
- "/app/core"
- "/app/proprietary"
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
open-pull-requests-limit: 10
rebase-strategy: "auto"
- package-ecosystem: "docker"
directory: "/" # Location of Dockerfile
schedule:
interval: "weekly"
rebase-strategy: "auto"
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
rebase-strategy: "auto"
- package-ecosystem: npm
directory: /devTools
schedule:
interval: "weekly"
rebase-strategy: "auto"
- package-ecosystem: docker
directory: /docker/backend
schedule:
interval: "weekly"
rebase-strategy: "auto"
- package-ecosystem: docker
directory: /docker/embedded
schedule:
interval: "weekly"
rebase-strategy: "auto"
- package-ecosystem: docker
directory: /docker/frontend
schedule:
interval: "weekly"
rebase-strategy: "auto"
- package-ecosystem: npm
directory: /frontend
schedule:
interval: "weekly"
rebase-strategy: "auto"
- package-ecosystem: cargo
directory: /frontend/src-tauri
schedule:
interval: "weekly"
rebase-strategy: "auto"
- package-ecosystem: pip
directory: /testing/cucumber
schedule:
interval: "weekly"
rebase-strategy: "auto"
+7 -15
View File
@@ -46,34 +46,26 @@ labels:
- label: 'API'
title: '.*openapi.*|.*swagger.*|.*api.*'
- label: 'v3'
base-branch: 'V3'
- label: 'v2'
base-branch: 'V2'
- label: 'Translation'
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/remove_translation_keys.sh'
- 'scripts/replace_translation_line.sh'
- 'scripts/translations/.*'
- '.github/scripts/check_language_toml.py'
- 'scripts/counter_translation_v3.py'
- 'app/core/src/main/resources/templates/fragments/languages.html'
- '.github/scripts/check_language_properties.py'
- label: 'Front End'
files:
- 'app/core/src/main/resources/templates/.*'
- 'app/proprietary/src/main/resources/templates/.*'
- 'app/core/src/main/resources/static/.*'
- 'app/proprietary/src/main/resources/static/.*'
- 'app/core/src/main/java/stirling/software/SPDF/controller/web/.*'
- 'app/core/src/main/java/stirling/software/SPDF/UI/.*'
- 'app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/.*'
- 'frontend/**'
- 'frontend/.*'
- 'frontend/**/.*'
- label: 'Tauri'
files:
- 'frontend/src-tauri/**'
- 'frontend/src-tauri/.*'
- label: 'Java'
files:
-17
View File
@@ -84,9 +84,6 @@
- name: "v2"
color: "FFFF00"
description: "Issues or pull requests related to the v2 branch"
- name: "v3"
color: "FFA500"
description: "Issues or pull requests related to the v3 branch"
- name: "wontfix"
description: "This will not be worked on"
color: "FFFFFF"
@@ -187,17 +184,3 @@
- name: "codex"
color: "ededed"
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 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)
- [ ] 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:
categories:
- title: Breaking Changes
labels:
- break-change
- title: Bug Fixes
labels:
- Bug
+10 -10
View File
@@ -14,10 +14,12 @@ Usage:
# Sample for Windows:
# python .github/scripts/check_language_toml.py --reference-file frontend/public/locales/en-GB/translation.toml --branch "" --files frontend/public/locales/de-DE/translation.toml frontend/public/locales/fr-FR/translation.toml
import copy
import glob
import os
import argparse
import re
import json
import tomllib # Python 3.11+ (stdlib)
import tomli_w # For writing TOML files
@@ -36,7 +38,7 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
duplicates = []
# Load TOML file
with open(file_path, "rb") as file:
with open(file_path, 'rb') as file:
data = tomllib.load(file)
def process_dict(obj, current_prefix=""):
@@ -65,7 +67,7 @@ def parse_toml_file(file_path):
:param file_path: Path to the TOML file.
:return: Dictionary with flattened keys.
"""
with open(file_path, "rb") as file:
with open(file_path, 'rb') as file:
data = tomllib.load(file)
def flatten_dict(d, parent_key="", sep="."):
@@ -191,13 +193,13 @@ def check_for_differences(reference_file, file_list, branch, actor):
basename_current_file = os.path.basename(os.path.join(branch, file_normpath))
locale_dir = os.path.basename(os.path.dirname(file_normpath))
if basename_current_file == basename_reference_file and locale_dir == "en-GB":
if (
basename_current_file == basename_reference_file
and locale_dir == "en-GB"
):
continue
if (
not file_normpath.endswith(".toml")
or basename_current_file != "translation.toml"
):
if not file_normpath.endswith(".toml") or basename_current_file != "translation.toml":
continue
only_reference_file = False
@@ -286,9 +288,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Find missing keys in TOML translation files"
)
parser = argparse.ArgumentParser(description="Find missing keys in TOML translation files")
parser.add_argument(
"--actor",
required=False,
-1
View File
@@ -6,4 +6,3 @@ pillow
unoserver
opencv-python-headless
pre-commit
brotli @ git+https://github.com/google/brotli.git@028fb5a23661f123017c060daa546b55cf4bde29
+420 -338
View File
@@ -4,98 +4,201 @@
#
# pip-compile --allow-unsafe --generate-hashes --output-file='.github\scripts\requirements_dev.txt' --strip-extras '.github\scripts\requirements_dev.in'
#
# WARNING: pip install will require the following package to be hashed.
# Consider using a hashable URL like https://github.com/jazzband/pip-tools/archive/SOMECOMMIT.zip
# CVE-2025-6176 mitigation: pin brotli to a specific commit
brotli @ git+https://github.com/google/brotli.git@028fb5a23661f123017c060daa546b55cf4bde29
# via
# -r .github/scripts/requirements_dev.in
# fonttools
cffi==2.0.0 \
--hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \
--hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \
--hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \
--hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \
--hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \
--hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \
--hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \
--hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \
--hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \
--hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \
--hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \
--hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \
--hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \
--hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \
--hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \
--hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \
--hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \
--hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \
--hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \
--hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \
--hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \
--hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \
--hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \
--hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \
--hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \
--hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \
--hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \
--hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \
--hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \
--hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \
--hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \
--hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \
--hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \
--hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \
--hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \
--hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \
--hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \
--hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \
--hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \
--hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \
--hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \
--hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \
--hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \
--hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \
--hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \
--hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \
--hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \
--hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \
--hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \
--hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \
--hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \
--hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \
--hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \
--hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \
--hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \
--hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \
--hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \
--hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \
--hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \
--hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \
--hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \
--hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \
--hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \
--hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \
--hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \
--hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \
--hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \
--hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \
--hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \
--hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \
--hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \
--hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \
--hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \
--hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \
--hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \
--hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \
--hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \
--hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \
--hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \
--hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \
--hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \
--hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \
--hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \
--hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf
brotli==1.1.0 \
--hash=sha256:03d20af184290887bdea3f0f78c4f737d126c74dc2f3ccadf07e54ceca3bf208 \
--hash=sha256:0541e747cce78e24ea12d69176f6a7ddb690e62c425e01d31cc065e69ce55b48 \
--hash=sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354 \
--hash=sha256:0737ddb3068957cf1b054899b0883830bb1fec522ec76b1098f9b6e0f02d9419 \
--hash=sha256:0b63b949ff929fbc2d6d3ce0e924c9b93c9785d877a21a1b678877ffbbc4423a \
--hash=sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128 \
--hash=sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c \
--hash=sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088 \
--hash=sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9 \
--hash=sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a \
--hash=sha256:1ae56aca0402a0f9a3431cddda62ad71666ca9d4dc3a10a142b9dce2e3c0cda3 \
--hash=sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757 \
--hash=sha256:1e9a65b5736232e7a7f91ff3d02277f11d339bf34099a56cdab6a8b3410a02b2 \
--hash=sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438 \
--hash=sha256:22fc2a8549ffe699bfba2256ab2ed0421a7b8fadff114a3d201794e45a9ff578 \
--hash=sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b \
--hash=sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b \
--hash=sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68 \
--hash=sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0 \
--hash=sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d \
--hash=sha256:2feb1d960f760a575dbc5ab3b1c00504b24caaf6986e2dc2b01c09c87866a943 \
--hash=sha256:30924eb4c57903d5a7526b08ef4a584acc22ab1ffa085faceb521521d2de32dd \
--hash=sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409 \
--hash=sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28 \
--hash=sha256:38025d9f30cf4634f8309c6874ef871b841eb3c347e90b0851f63d1ded5212da \
--hash=sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50 \
--hash=sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f \
--hash=sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0 \
--hash=sha256:3ee8a80d67a4334482d9712b8e83ca6b1d9bc7e351931252ebef5d8f7335a547 \
--hash=sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180 \
--hash=sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0 \
--hash=sha256:43ce1b9935bfa1ede40028054d7f48b5469cd02733a365eec8a329ffd342915d \
--hash=sha256:4410f84b33374409552ac9b6903507cdb31cd30d2501fc5ca13d18f73548444a \
--hash=sha256:494994f807ba0b92092a163a0a283961369a65f6cbe01e8891132b7a320e61eb \
--hash=sha256:4d4a848d1837973bf0f4b5e54e3bec977d99be36a7895c61abb659301b02c112 \
--hash=sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc \
--hash=sha256:4f3607b129417e111e30637af1b56f24f7a49e64763253bbc275c75fa887d4b2 \
--hash=sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265 \
--hash=sha256:524f35912131cc2cabb00edfd8d573b07f2d9f21fa824bd3fb19725a9cf06327 \
--hash=sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95 \
--hash=sha256:58d4b711689366d4a03ac7957ab8c28890415e267f9b6589969e74b6e42225ec \
--hash=sha256:5b3cc074004d968722f51e550b41a27be656ec48f8afaeeb45ebf65b561481dd \
--hash=sha256:5dab0844f2cf82be357a0eb11a9087f70c5430b2c241493fc122bb6f2bb0917c \
--hash=sha256:5e55da2c8724191e5b557f8e18943b1b4839b8efc3ef60d65985bcf6f587dd38 \
--hash=sha256:5eeb539606f18a0b232d4ba45adccde4125592f3f636a6182b4a8a436548b914 \
--hash=sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0 \
--hash=sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a \
--hash=sha256:6172447e1b368dcbc458925e5ddaf9113477b0ed542df258d84fa28fc45ceea7 \
--hash=sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368 \
--hash=sha256:6974f52a02321b36847cd19d1b8e381bf39939c21efd6ee2fc13a28b0d99348c \
--hash=sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0 \
--hash=sha256:6c6e0c425f22c1c719c42670d561ad682f7bfeeef918edea971a79ac5252437f \
--hash=sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451 \
--hash=sha256:7905193081db9bfa73b1219140b3d315831cbff0d8941f22da695832f0dd188f \
--hash=sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8 \
--hash=sha256:7c4855522edb2e6ae7fdb58e07c3ba9111e7621a8956f481c68d5d979c93032e \
--hash=sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248 \
--hash=sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c \
--hash=sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91 \
--hash=sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724 \
--hash=sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7 \
--hash=sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966 \
--hash=sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9 \
--hash=sha256:890b5a14ce214389b2cc36ce82f3093f96f4cc730c1cffdbefff77a7c71f2a97 \
--hash=sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d \
--hash=sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5 \
--hash=sha256:8dadd1314583ec0bf2d1379f7008ad627cd6336625d6679cf2f8e67081b83acf \
--hash=sha256:901032ff242d479a0efa956d853d16875d42157f98951c0230f69e69f9c09bac \
--hash=sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b \
--hash=sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951 \
--hash=sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74 \
--hash=sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648 \
--hash=sha256:929811df5462e182b13920da56c6e0284af407d1de637d8e536c5cd00a7daf60 \
--hash=sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c \
--hash=sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1 \
--hash=sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8 \
--hash=sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d \
--hash=sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc \
--hash=sha256:a469274ad18dc0e4d316eefa616d1d0c2ff9da369af19fa6f3daa4f09671fd61 \
--hash=sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460 \
--hash=sha256:a743e5a28af5f70f9c080380a5f908d4d21d40e8f0e0c8901604d15cfa9ba751 \
--hash=sha256:a77def80806c421b4b0af06f45d65a136e7ac0bdca3c09d9e2ea4e515367c7e9 \
--hash=sha256:a7e53012d2853a07a4a79c00643832161a910674a893d296c9f1259859a289d2 \
--hash=sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0 \
--hash=sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1 \
--hash=sha256:ae15b066e5ad21366600ebec29a7ccbc86812ed267e4b28e860b8ca16a2bc474 \
--hash=sha256:aea440a510e14e818e67bfc4027880e2fb500c2ccb20ab21c7a7c8b5b4703d75 \
--hash=sha256:af6fa6817889314555aede9a919612b23739395ce767fe7fcbea9a80bf140fe5 \
--hash=sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f \
--hash=sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2 \
--hash=sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f \
--hash=sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb \
--hash=sha256:c8146669223164fc87a7e3de9f81e9423c67a79d6b3447994dfb9c95da16e2d6 \
--hash=sha256:c8fd5270e906eef71d4a8d19b7c6a43760c6abcfcc10c9101d14eb2357418de9 \
--hash=sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111 \
--hash=sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2 \
--hash=sha256:cb1dac1770878ade83f2ccdf7d25e494f05c9165f5246b46a621cc849341dc01 \
--hash=sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467 \
--hash=sha256:cdbc1fc1bc0bff1cef838eafe581b55bfbffaed4ed0318b724d0b71d4d377619 \
--hash=sha256:ceb64bbc6eac5a140ca649003756940f8d6a7c444a68af170b3187623b43bebf \
--hash=sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408 \
--hash=sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579 \
--hash=sha256:d192f0f30804e55db0d0e0a35d83a9fead0e9a359a9ed0285dbacea60cc10a84 \
--hash=sha256:d2b35ca2c7f81d173d2fadc2f4f31e88cc5f7a39ae5b6db5513cf3383b0e0ec7 \
--hash=sha256:d342778ef319e1026af243ed0a07c97acf3bad33b9f29e7ae6a1f68fd083e90c \
--hash=sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284 \
--hash=sha256:d7702622a8b40c49bffb46e1e3ba2e81268d5c04a34f460978c6b5517a34dd52 \
--hash=sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b \
--hash=sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59 \
--hash=sha256:e1140c64812cb9b06c922e77f1c26a75ec5e3f0fb2bf92cc8c58720dec276752 \
--hash=sha256:e4fe605b917c70283db7dfe5ada75e04561479075761a0b3866c081d035b01c1 \
--hash=sha256:e6a904cb26bfefc2f0a6f240bdf5233be78cd2488900a2f846f3c3ac8489ab80 \
--hash=sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839 \
--hash=sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0 \
--hash=sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2 \
--hash=sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3 \
--hash=sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64 \
--hash=sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089 \
--hash=sha256:f296c40e23065d0d6650c4aefe7470d2a25fffda489bcc3eb66083f3ac9f6643 \
--hash=sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b \
--hash=sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e \
--hash=sha256:f733d788519c7e3e71f0855c96618720f5d3d60c3cb829d8bbb722dddce37985 \
--hash=sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596 \
--hash=sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2 \
--hash=sha256:fdc3ff3bfccdc6b9cc7c342c03aa2400683f0cb891d46e94b64a197910dc4064
# via fonttools
cffi==1.17.1 \
--hash=sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8 \
--hash=sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2 \
--hash=sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1 \
--hash=sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15 \
--hash=sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36 \
--hash=sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824 \
--hash=sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8 \
--hash=sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36 \
--hash=sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17 \
--hash=sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf \
--hash=sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc \
--hash=sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3 \
--hash=sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed \
--hash=sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702 \
--hash=sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1 \
--hash=sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8 \
--hash=sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903 \
--hash=sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6 \
--hash=sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d \
--hash=sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b \
--hash=sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e \
--hash=sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be \
--hash=sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c \
--hash=sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683 \
--hash=sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9 \
--hash=sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c \
--hash=sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8 \
--hash=sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1 \
--hash=sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4 \
--hash=sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655 \
--hash=sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67 \
--hash=sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595 \
--hash=sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0 \
--hash=sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65 \
--hash=sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41 \
--hash=sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6 \
--hash=sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401 \
--hash=sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6 \
--hash=sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3 \
--hash=sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16 \
--hash=sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93 \
--hash=sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e \
--hash=sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4 \
--hash=sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964 \
--hash=sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c \
--hash=sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576 \
--hash=sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0 \
--hash=sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3 \
--hash=sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662 \
--hash=sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3 \
--hash=sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff \
--hash=sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5 \
--hash=sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd \
--hash=sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f \
--hash=sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5 \
--hash=sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14 \
--hash=sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d \
--hash=sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9 \
--hash=sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7 \
--hash=sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382 \
--hash=sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a \
--hash=sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e \
--hash=sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a \
--hash=sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4 \
--hash=sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99 \
--hash=sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87 \
--hash=sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b
# via weasyprint
cfgv==3.4.0 \
--hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \
@@ -109,73 +212,57 @@ distlib==0.4.0 \
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
# via virtualenv
filelock==3.20.0 \
--hash=sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2 \
--hash=sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4
filelock==3.18.0 \
--hash=sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2 \
--hash=sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de
# via virtualenv
fonttools==4.60.1 \
--hash=sha256:022beaea4b73a70295b688f817ddc24ed3e3418b5036ffcd5658141184ef0d0c \
--hash=sha256:026290e4ec76583881763fac284aca67365e0be9f13a7fb137257096114cb3bc \
--hash=sha256:0b0835ed15dd5b40d726bb61c846a688f5b4ce2208ec68779bc81860adb5851a \
--hash=sha256:0eae96373e4b7c9e45d099d7a523444e3554360927225c1cdae221a58a45b856 \
--hash=sha256:122e1a8ada290423c493491d002f622b1992b1ab0b488c68e31c413390dc7eb2 \
--hash=sha256:1410155d0e764a4615774e5c2c6fc516259fe3eca5882f034eb9bfdbee056259 \
--hash=sha256:145daa14bf24824b677b9357c5e44fd8895c2a8f53596e1b9ea3496081dc692c \
--hash=sha256:1525796c3ffe27bb6268ed2a1bb0dcf214d561dfaf04728abf01489eb5339dce \
--hash=sha256:154cb6ee417e417bf5f7c42fe25858c9140c26f647c7347c06f0cc2d47eff003 \
--hash=sha256:2299df884c11162617a66b7c316957d74a18e3758c0274762d2cc87df7bc0272 \
--hash=sha256:2409d5fb7b55fd70f715e6d34e7a6e4f7511b8ad29a49d6df225ee76da76dd77 \
--hash=sha256:268ecda8ca6cb5c4f044b1fb9b3b376e8cd1b361cef275082429dc4174907038 \
--hash=sha256:282dafa55f9659e8999110bd8ed422ebe1c8aecd0dc396550b038e6c9a08b8ea \
--hash=sha256:2ee06fc57512144d8b0445194c2da9f190f61ad51e230f14836286470c99f854 \
--hash=sha256:3630e86c484263eaac71d117085d509cbcf7b18f677906824e4bace598fb70d2 \
--hash=sha256:398447f3d8c0c786cbf1209711e79080a40761eb44b27cdafffb48f52bcec258 \
--hash=sha256:4ba4bd646e86de16160f0fb72e31c3b9b7d0721c3e5b26b9fa2fc931dfdb2652 \
--hash=sha256:5664fd1a9ea7f244487ac8f10340c4e37664675e8667d6fee420766e0fb3cf08 \
--hash=sha256:583b7f8e3c49486e4d489ad1deacfb8d5be54a8ef34d6df824f6a171f8511d99 \
--hash=sha256:596ecaca36367027d525b3b426d8a8208169d09edcf8c7506aceb3a38bfb55c7 \
--hash=sha256:5c1015318e4fec75dd4943ad5f6a206d9727adf97410d58b7e32ab644a807914 \
--hash=sha256:66929e2ea2810c6533a5184f938502cfdaea4bc3efb7130d8cc02e1c1b4108d6 \
--hash=sha256:6ec722ee589e89a89f5b7574f5c45604030aa6ae24cb2c751e2707193b466fed \
--hash=sha256:6f68576bb4bbf6060c7ab047b1574a1ebe5c50a17de62830079967b211059ebb \
--hash=sha256:7473a8ed9ed09aeaa191301244a5a9dbe46fe0bf54f9d6cd21d83044c3321217 \
--hash=sha256:7b0c6d57ab00dae9529f3faf187f2254ea0aa1e04215cf2f1a8ec277c96661bc \
--hash=sha256:7b4c32e232a71f63a5d00259ca3d88345ce2a43295bb049d21061f338124246f \
--hash=sha256:8177ec9676ea6e1793c8a084a90b65a9f778771998eb919d05db6d4b1c0b114c \
--hash=sha256:839565cbf14645952d933853e8ade66a463684ed6ed6c9345d0faf1f0e868877 \
--hash=sha256:875cb7764708b3132637f6c5fb385b16eeba0f7ac9fa45a69d35e09b47045801 \
--hash=sha256:8a44788d9d91df72d1a5eac49b31aeb887a5f4aab761b4cffc4196c74907ea85 \
--hash=sha256:8b4eb332f9501cb1cd3d4d099374a1e1306783ff95489a1026bde9eb02ccc34a \
--hash=sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb \
--hash=sha256:992775c9fbe2cf794786fa0ffca7f09f564ba3499b8fe9f2f80bd7197db60383 \
--hash=sha256:996a4d1834524adbb423385d5a629b868ef9d774670856c63c9a0408a3063401 \
--hash=sha256:9a52f254ce051e196b8fe2af4634c2d2f02c981756c6464dc192f1b6050b4e28 \
--hash=sha256:9d0ced62b59e0430b3690dbc5373df1c2aa7585e9a8ce38eff87f0fd993c5b01 \
--hash=sha256:a140761c4ff63d0cb9256ac752f230460ee225ccef4ad8f68affc723c88e2036 \
--hash=sha256:a184b2ea57b13680ab6d5fbde99ccef152c95c06746cb7718c583abd8f945ccc \
--hash=sha256:a3db56f153bd4c5c2b619ab02c5db5192e222150ce5a1bc10f16164714bc39ac \
--hash=sha256:a46b2f450bc79e06ef3b6394f0c68660529ed51692606ad7f953fc2e448bc903 \
--hash=sha256:a884aef09d45ba1206712c7dbda5829562d3fea7726935d3289d343232ecb0d3 \
--hash=sha256:b2cf105cee600d2de04ca3cfa1f74f1127f8455b71dbad02b9da6ec266e116d6 \
--hash=sha256:b33a7884fabd72bdf5f910d0cf46be50dce86a0362a65cfc746a4168c67eb96c \
--hash=sha256:b42d86938e8dda1cd9a1a87a6d82f1818eaf933348429653559a458d027446da \
--hash=sha256:b6379e7546ba4ae4b18f8ae2b9bc5960936007a1c0e30b342f662577e8bc3299 \
--hash=sha256:c7420a2696a44650120cdd269a5d2e56a477e2bfa9d95e86229059beb1c19e15 \
--hash=sha256:c8651e0d4b3bdeda6602b85fdc2abbefc1b41e573ecb37b6779c4ca50753a199 \
--hash=sha256:d066ea419f719ed87bc2c99a4a4bfd77c2e5949cb724588b9dd58f3fd90b92bf \
--hash=sha256:e6c58beb17380f7c2ea181ea11e7db8c0ceb474c9dd45f48e71e2cb577d146a1 \
--hash=sha256:e852d9dda9f93ad3651ae1e3bb770eac544ec93c3807888798eccddf84596537 \
--hash=sha256:ec3681a0cb34c255d76dd9d865a55f260164adb9fa02628415cdc2d43ee2c05d \
--hash=sha256:ee0c0b3b35b34f782afc673d503167157094a16f442ace7c6c5e0ca80b08f50c \
--hash=sha256:eedacb5c5d22b7097482fa834bda0dafa3d914a4e829ec83cdea2a01f8c813c4 \
--hash=sha256:ef00af0439ebfee806b25f24c8f92109157ff3fac5731dc7867957812e87b8d9 \
--hash=sha256:f0e8817c7d1a0c2eedebf57ef9a9896f3ea23324769a9a2061a80fe8852705ed \
--hash=sha256:f3d5be054c461d6a2268831f04091dc82753176f6ea06dc6047a5e168265a987 \
--hash=sha256:f4b5c37a5f40e4d733d3bbaaef082149bee5a5ea3156a785ff64d949bd1353fa
fonttools==4.59.0 \
--hash=sha256:052444a5d0151878e87e3e512a1aa1a0ab35ee4c28afde0a778e23b0ace4a7de \
--hash=sha256:169b99a2553a227f7b5fea8d9ecd673aa258617f466b2abc6091fe4512a0dcd0 \
--hash=sha256:209b75943d158f610b78320eacb5539aa9e920bee2c775445b2846c65d20e19d \
--hash=sha256:21e606b2d38fed938dde871c5736822dd6bda7a4631b92e509a1f5cd1b90c5df \
--hash=sha256:241313683afd3baacb32a6bd124d0bce7404bc5280e12e291bae1b9bba28711d \
--hash=sha256:26731739daa23b872643f0e4072d5939960237d540c35c14e6a06d47d71ca8fe \
--hash=sha256:2e7cf8044ce2598bb87e44ba1d2c6e45d7a8decf56055b92906dc53f67c76d64 \
--hash=sha256:31003b6a10f70742a63126b80863ab48175fb8272a18ca0846c0482968f0588e \
--hash=sha256:332bfe685d1ac58ca8d62b8d6c71c2e52a6c64bc218dc8f7825c9ea51385aa01 \
--hash=sha256:37c377f7cb2ab2eca8a0b319c68146d34a339792f9420fca6cd49cf28d370705 \
--hash=sha256:37e01c6ec0c98599778c2e688350d624fa4770fbd6144551bd5e032f1199171c \
--hash=sha256:401b1941ce37e78b8fd119b419b617277c65ae9417742a63282257434fd68ea2 \
--hash=sha256:4536f2695fe5c1ffb528d84a35a7d3967e5558d2af58b4775e7ab1449d65767b \
--hash=sha256:4c908a7036f0f3677f8afa577bcd973e3e20ddd2f7c42a33208d18bee95cdb6f \
--hash=sha256:51ab1ff33c19e336c02dee1e9fd1abd974a4ca3d8f7eef2a104d0816a241ce97 \
--hash=sha256:524133c1be38445c5c0575eacea42dbd44374b310b1ffc4b60ff01d881fabb96 \
--hash=sha256:57bb7e26928573ee7c6504f54c05860d867fd35e675769f3ce01b52af38d48e2 \
--hash=sha256:60f6665579e909b618282f3c14fa0b80570fbf1ee0e67678b9a9d43aa5d67a37 \
--hash=sha256:62224a9bb85b4b66d1b46d45cbe43d71cbf8f527d332b177e3b96191ffbc1e64 \
--hash=sha256:6770d7da00f358183d8fd5c4615436189e4f683bdb6affb02cad3d221d7bb757 \
--hash=sha256:6801aeddb6acb2c42eafa45bc1cb98ba236871ae6f33f31e984670b749a8e58e \
--hash=sha256:70d6b3ceaa9cc5a6ac52884f3b3d9544e8e231e95b23f138bdb78e6d4dc0eae3 \
--hash=sha256:78813b49d749e1bb4db1c57f2d4d7e6db22c253cb0a86ad819f5dc197710d4b2 \
--hash=sha256:841b2186adce48903c0fef235421ae21549020eca942c1da773ac380b056ab3c \
--hash=sha256:84fc186980231a287b28560d3123bd255d3c6b6659828c642b4cf961e2b923d0 \
--hash=sha256:885bde7d26e5b40e15c47bd5def48b38cbd50830a65f98122a8fb90962af7cd1 \
--hash=sha256:8b4309a2775e4feee7356e63b163969a215d663399cce1b3d3b65e7ec2d9680e \
--hash=sha256:8d77f92438daeaddc05682f0f3dac90c5b9829bcac75b57e8ce09cb67786073c \
--hash=sha256:902425f5afe28572d65d2bf9c33edd5265c612ff82c69e6f83ea13eafc0dcbea \
--hash=sha256:9bcc1e77fbd1609198966ded6b2a9897bd6c6bcbd2287a2fc7d75f1a254179c5 \
--hash=sha256:a408c3c51358c89b29cfa5317cf11518b7ce5de1717abb55c5ae2d2921027de6 \
--hash=sha256:a9bf8adc9e1f3012edc8f09b08336272aec0c55bc677422273e21280db748f7c \
--hash=sha256:b818db35879d2edf7f46c7e729c700a0bce03b61b9412f5a7118406687cb151d \
--hash=sha256:b8974b2a266b54c96709bd5e239979cddfd2dbceed331aa567ea1d7c4a2202db \
--hash=sha256:be392ec3529e2f57faa28709d60723a763904f71a2b63aabe14fee6648fe3b14 \
--hash=sha256:d3972b13148c1d1fbc092b27678a33b3080d1ac0ca305742b0119b75f9e87e38 \
--hash=sha256:d40dcf533ca481355aa7b682e9e079f766f35715defa4929aeb5597f9604272e \
--hash=sha256:e93df708c69a193fc7987192f94df250f83f3851fda49413f02ba5dded639482 \
--hash=sha256:efd7e6660674e234e29937bc1481dceb7e0336bfae75b856b4fb272b5093c5d4 \
--hash=sha256:f9b3a78f69dcbd803cf2fb3f972779875b244c1115481dfbdd567b2c22b31f6b \
--hash=sha256:fa39475eaccb98f9199eccfda4298abaf35ae0caec676ffc25b3a5e224044464 \
--hash=sha256:fbce6dae41b692a5973d0f2158f782b9ad05babc2c2019a970a1094a23909b1b
# via weasyprint
identify==2.6.15 \
--hash=sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757 \
--hash=sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf
identify==2.6.13 \
--hash=sha256:60381139b3ae39447482ecc406944190f690d4a2997f2584062089848361b33b \
--hash=sha256:da8d6c828e773620e13bfa86ea601c5a5310ba4bcd65edf378198b56a1f9fb32
# via pre-commit
nodeenv==1.9.1 \
--hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \
@@ -251,113 +338,128 @@ pdf2image==1.17.0 \
--hash=sha256:eaa959bc116b420dd7ec415fcae49b98100dda3dd18cd2fdfa86d09f112f6d57 \
--hash=sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2
# via -r .github/scripts/requirements_dev.in
pillow==12.0.0 \
--hash=sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643 \
--hash=sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e \
--hash=sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e \
--hash=sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc \
--hash=sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642 \
--hash=sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6 \
--hash=sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1 \
--hash=sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b \
--hash=sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399 \
--hash=sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba \
--hash=sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad \
--hash=sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47 \
--hash=sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739 \
--hash=sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b \
--hash=sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f \
--hash=sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10 \
--hash=sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52 \
--hash=sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d \
--hash=sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b \
--hash=sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a \
--hash=sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9 \
--hash=sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d \
--hash=sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098 \
--hash=sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905 \
--hash=sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b \
--hash=sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3 \
--hash=sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371 \
--hash=sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953 \
--hash=sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01 \
--hash=sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca \
--hash=sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e \
--hash=sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7 \
--hash=sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27 \
--hash=sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082 \
--hash=sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e \
--hash=sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d \
--hash=sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8 \
--hash=sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a \
--hash=sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad \
--hash=sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3 \
--hash=sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a \
--hash=sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d \
--hash=sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353 \
--hash=sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee \
--hash=sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b \
--hash=sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b \
--hash=sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a \
--hash=sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7 \
--hash=sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef \
--hash=sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a \
--hash=sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a \
--hash=sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257 \
--hash=sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07 \
--hash=sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4 \
--hash=sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c \
--hash=sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c \
--hash=sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4 \
--hash=sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe \
--hash=sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8 \
--hash=sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5 \
--hash=sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6 \
--hash=sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e \
--hash=sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8 \
--hash=sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e \
--hash=sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275 \
--hash=sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3 \
--hash=sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76 \
--hash=sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227 \
--hash=sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9 \
--hash=sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5 \
--hash=sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79 \
--hash=sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca \
--hash=sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa \
--hash=sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b \
--hash=sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e \
--hash=sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197 \
--hash=sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab \
--hash=sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79 \
--hash=sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2 \
--hash=sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363 \
--hash=sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0 \
--hash=sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e \
--hash=sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782 \
--hash=sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925 \
--hash=sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0 \
--hash=sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b \
--hash=sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced \
--hash=sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c \
--hash=sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344 \
--hash=sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9 \
--hash=sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1
pillow==11.3.0 \
--hash=sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2 \
--hash=sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214 \
--hash=sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e \
--hash=sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59 \
--hash=sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50 \
--hash=sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632 \
--hash=sha256:092c80c76635f5ecb10f3f83d76716165c96f5229addbd1ec2bdbbda7d496e06 \
--hash=sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a \
--hash=sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51 \
--hash=sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced \
--hash=sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f \
--hash=sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12 \
--hash=sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8 \
--hash=sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6 \
--hash=sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580 \
--hash=sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f \
--hash=sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac \
--hash=sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860 \
--hash=sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd \
--hash=sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722 \
--hash=sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8 \
--hash=sha256:23cff760a9049c502721bdb743a7cb3e03365fafcdfc2ef9784610714166e5a4 \
--hash=sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673 \
--hash=sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788 \
--hash=sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542 \
--hash=sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e \
--hash=sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd \
--hash=sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8 \
--hash=sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523 \
--hash=sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967 \
--hash=sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809 \
--hash=sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477 \
--hash=sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027 \
--hash=sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae \
--hash=sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b \
--hash=sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c \
--hash=sha256:48d254f8a4c776de343051023eb61ffe818299eeac478da55227d96e241de53f \
--hash=sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e \
--hash=sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b \
--hash=sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7 \
--hash=sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27 \
--hash=sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361 \
--hash=sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae \
--hash=sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d \
--hash=sha256:6359a3bc43f57d5b375d1ad54a0074318a0844d11b76abccf478c37c986d3cfc \
--hash=sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58 \
--hash=sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad \
--hash=sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6 \
--hash=sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024 \
--hash=sha256:6a418691000f2a418c9135a7cf0d797c1bb7d9a485e61fe8e7722845b95ef978 \
--hash=sha256:6abdbfd3aea42be05702a8dd98832329c167ee84400a1d1f61ab11437f1717eb \
--hash=sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d \
--hash=sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0 \
--hash=sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9 \
--hash=sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f \
--hash=sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874 \
--hash=sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa \
--hash=sha256:7aee118e30a4cf54fdd873bd3a29de51e29105ab11f9aad8c32123f58c8f8081 \
--hash=sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149 \
--hash=sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6 \
--hash=sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d \
--hash=sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd \
--hash=sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f \
--hash=sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c \
--hash=sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31 \
--hash=sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e \
--hash=sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db \
--hash=sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6 \
--hash=sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f \
--hash=sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494 \
--hash=sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69 \
--hash=sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94 \
--hash=sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77 \
--hash=sha256:97afb3a00b65cc0804d1c7abddbf090a81eaac02768af58cbdcaaa0a931e0b6d \
--hash=sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7 \
--hash=sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a \
--hash=sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438 \
--hash=sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288 \
--hash=sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b \
--hash=sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635 \
--hash=sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3 \
--hash=sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d \
--hash=sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe \
--hash=sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0 \
--hash=sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe \
--hash=sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a \
--hash=sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805 \
--hash=sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8 \
--hash=sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36 \
--hash=sha256:cadc9e0ea0a2431124cde7e1697106471fc4c1da01530e679b2391c37d3fbb3a \
--hash=sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b \
--hash=sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e \
--hash=sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25 \
--hash=sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12 \
--hash=sha256:e5c5858ad8ec655450a7c7df532e9842cf8df7cc349df7225c60d5d348c8aada \
--hash=sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c \
--hash=sha256:ea944117a7974ae78059fcc1800e5d3295172bb97035c0c1d9345fca1419da71 \
--hash=sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d \
--hash=sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c \
--hash=sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6 \
--hash=sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1 \
--hash=sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50 \
--hash=sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653 \
--hash=sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c \
--hash=sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4 \
--hash=sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3
# via
# -r .github/scripts/requirements_dev.in
# pdf2image
# weasyprint
platformdirs==4.5.0 \
--hash=sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312 \
--hash=sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3
platformdirs==4.3.8 \
--hash=sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc \
--hash=sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4
# via virtualenv
pre-commit==4.3.0 \
--hash=sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8 \
--hash=sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16
# via -r .github/scripts/requirements_dev.in
pycparser==2.23 \
--hash=sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2 \
--hash=sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934
pycparser==2.22 \
--hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \
--hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc
# via cffi
pydyf==0.11.0 \
--hash=sha256:0aaf9e2ebbe786ec7a78ec3fbffa4cdcecde53fd6f563221d53c6bc1328848a3 \
@@ -367,80 +469,60 @@ pyphen==0.17.2 \
--hash=sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd \
--hash=sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3
# via weasyprint
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
--hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
--hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
--hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
--hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
--hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
--hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
--hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
--hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
--hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
--hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
--hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
--hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
--hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
--hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
--hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
--hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
--hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
--hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
--hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
--hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
--hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
--hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
--hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
--hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
--hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
pyyaml==6.0.2 \
--hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \
--hash=sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48 \
--hash=sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086 \
--hash=sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e \
--hash=sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133 \
--hash=sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5 \
--hash=sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484 \
--hash=sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee \
--hash=sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5 \
--hash=sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68 \
--hash=sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a \
--hash=sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf \
--hash=sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99 \
--hash=sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8 \
--hash=sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85 \
--hash=sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19 \
--hash=sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc \
--hash=sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a \
--hash=sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1 \
--hash=sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317 \
--hash=sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c \
--hash=sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631 \
--hash=sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d \
--hash=sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652 \
--hash=sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5 \
--hash=sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e \
--hash=sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b \
--hash=sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8 \
--hash=sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476 \
--hash=sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706 \
--hash=sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563 \
--hash=sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237 \
--hash=sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b \
--hash=sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083 \
--hash=sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180 \
--hash=sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425 \
--hash=sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e \
--hash=sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f \
--hash=sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725 \
--hash=sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183 \
--hash=sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab \
--hash=sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774 \
--hash=sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725 \
--hash=sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e \
--hash=sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5 \
--hash=sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d \
--hash=sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290 \
--hash=sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44 \
--hash=sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed \
--hash=sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4 \
--hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba \
--hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \
--hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4
# via pre-commit
tinycss2==1.4.0 \
--hash=sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7 \
@@ -452,13 +534,13 @@ tinyhtml5==2.0.0 \
--hash=sha256:086f998833da24c300c414d9fe81d9b368fd04cb9d2596a008421cbc705fcfcc \
--hash=sha256:13683277c5b176d070f82d099d977194b7a1e26815b016114f581a74bbfbf47e
# via weasyprint
unoserver==3.4 \
--hash=sha256:3dcf2204013def1d1ddd3671f38b11346bdf349fef9728277462666a8a634419 \
--hash=sha256:64c24d33d4f65d680a2d9f676518cb28e7fd6c1f9d9a745c33e4a4cb59afdfcd
unoserver==3.3.2 \
--hash=sha256:1eeb7467cf6b56b8eff3b576e2d1b2b2ff4e0eb2052e995ac80a1456de300639 \
--hash=sha256:87e144f903ee21951b2e06a97549450c13ed7eca5bcebad942d3352d4e882616
# via -r .github/scripts/requirements_dev.in
virtualenv==20.35.4 \
--hash=sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c \
--hash=sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b
virtualenv==20.33.1 \
--hash=sha256:07c19bc66c11acab6a5958b815cbcee30891cd1c2ccf53785a28651a0d8d8a67 \
--hash=sha256:1b44478d9e261b3fb8baa5e74a0ca3bc0e05f21aa36167bf9cbf850e542765b8
# via pre-commit
weasyprint==66.0 \
--hash=sha256:82b0783b726fcd318e2c977dcdddca76515b30044bc7a830cc4fbe717582a6d0 \
@@ -546,9 +628,9 @@ zopfli==0.2.3.post1 \
# via fonttools
# The following packages are considered to be unsafe in a requirements file:
pip==25.3 \
--hash=sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343 \
--hash=sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd
pip==25.2 \
--hash=sha256:578283f006390f85bb6282dffb876454593d637f5d1be494b5202ce4877e71f2 \
--hash=sha256:6d67a2b4e7f14d8b31b8b52648866fa717f45a1eb70e83002f4331d07e953717
# via -r .github/scripts/requirements_dev.in
setuptools==80.9.0 \
--hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 \
+15 -15
View File
@@ -4,17 +4,17 @@
#
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' --strip-extras '.github\scripts\requirements_pre_commit.in'
#
cfgv==3.5.0 \
--hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \
--hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132
cfgv==3.4.0 \
--hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \
--hash=sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560
# via pre-commit
distlib==0.4.0 \
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
# via virtualenv
filelock==3.20.0 \
--hash=sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2 \
--hash=sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4
filelock==3.19.1 \
--hash=sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58 \
--hash=sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d
# via virtualenv
identify==2.6.15 \
--hash=sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757 \
@@ -24,13 +24,13 @@ nodeenv==1.9.1 \
--hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \
--hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9
# via pre-commit
platformdirs==4.5.0 \
--hash=sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312 \
--hash=sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3
platformdirs==4.4.0 \
--hash=sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85 \
--hash=sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf
# via virtualenv
pre-commit==4.5.0 \
--hash=sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1 \
--hash=sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b
pre-commit==4.3.0 \
--hash=sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8 \
--hash=sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16
# via -r .github/scripts/requirements_pre_commit.in
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
@@ -107,7 +107,7 @@ pyyaml==6.0.3 \
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
# via pre-commit
virtualenv==20.35.4 \
--hash=sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c \
--hash=sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b
virtualenv==20.34.0 \
--hash=sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026 \
--hash=sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a
# via pre-commit
@@ -1,2 +1 @@
tomlkit
tomli-w
@@ -4,10 +4,6 @@
#
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_sync_readme.txt' --strip-extras '.github\scripts\requirements_sync_readme.in'
#
tomli-w==1.2.0 \
--hash=sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90 \
--hash=sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021
# via -r .github/scripts/requirements_sync_readme.in
tomlkit==0.13.3 \
--hash=sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1 \
--hash=sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0
+17 -17
View File
@@ -37,7 +37,7 @@ jobs:
- name: Resolve PR info
id: resolve
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const { owner, repo } = context.repo;
@@ -128,13 +128,13 @@ jobs:
- name: Add deployment started comment
id: deployment-started
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = ${{ needs.check-pr.outputs.pr_number }};
// Delete previous V2 deployment comments to avoid clutter
const { data: comments } = await github.rest.issues.listComments({
owner,
@@ -157,7 +157,7 @@ jobs:
comment_id: comment.id
});
}
// Create new deployment started comment
const { data: newComment } = await github.rest.issues.createComment({
owner,
@@ -173,7 +173,7 @@ jobs:
repository: ${{ needs.check-pr.outputs.pr_repository }}
ref: ${{ needs.check-pr.outputs.pr_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 # Fetch full history for commit hash detection
fetch-depth: 0 # Fetch full history for commit hash detection
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
@@ -204,20 +204,20 @@ jobs:
if [ -z "$BACKEND_HASH" ]; then
BACKEND_HASH="no-backend-changes"
fi
echo "Frontend hash: $FRONTEND_HASH"
echo "Backend hash: $BACKEND_HASH"
echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
# Short hashes for tags
if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
else
echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
fi
if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
echo "backend_short=no-backend" >> $GITHUB_OUTPUT
else
@@ -280,7 +280,7 @@ jobs:
# Use same port strategy as regular PRs - just the PR number
V2_PORT=${{ needs.check-pr.outputs.pr_number }}
BACKEND_PORT=$((V2_PORT + 10000)) # Backend on higher port to avoid conflicts
# Create docker-compose for V2 with separate frontend and backend
cat > docker-compose.yml << EOF
version: '3.3'
@@ -352,14 +352,14 @@ jobs:
- name: Post V2 deployment URL to PR
if: success()
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = ${{ needs.check-pr.outputs.pr_number }};
const v2Port = ${{ steps.deploy.outputs.v2_port }};
// Delete the "deploying..." comment since we're posting the final result
const deploymentStartedId = ${{ steps.deployment-started.outputs.result }};
if (deploymentStartedId) {
@@ -374,7 +374,7 @@ jobs:
console.log(`Could not delete deployment started comment: ${error.message}`);
}
}
const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${v2Port}`;
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
@@ -419,25 +419,25 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Clean up V2 deployment comments
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = ${{ github.event.pull_request.number }};
// Find and delete V2 deployment comments
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber
});
const v2Comments = comments.filter(c =>
c.body?.includes("## 🚀 V2 Auto-Deployment Complete!") &&
c.user?.type === "Bot"
);
for (const comment of v2Comments) {
await github.rest.issues.deleteComment({
owner,
@@ -41,12 +41,12 @@ jobs:
enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Checkout PR
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
@@ -129,12 +129,12 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Checkout PR
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
@@ -146,12 +146,12 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Checkout PR
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge
token: ${{ steps.setup-bot.outputs.token }}
- name: Set up JDK 21
- name: Set up JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "17"
@@ -357,149 +357,3 @@ jobs:
rm -f ../private.key docker-compose.yml
echo "Cleanup complete."
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@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with:
egress-policy: audit
- name: Check out the repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.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: 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.');
+3 -2
View File
@@ -21,12 +21,12 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Checkout PR
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup GitHub App Bot
if: github.actor != 'dependabot[bot]'
@@ -96,6 +96,7 @@ jobs:
const hasDeploymentComment = deploymentComments.length > 0;
core.setOutput('present', (hasLabel || hasDeploymentComment) ? 'true' : 'false');
- name: Set up SSH
if: steps.remove-label-comment.outputs.present == 'true'
run: |
+4 -4
View File
@@ -5,7 +5,7 @@ on:
types: [opened, edited]
branches: [main]
permissions: # required for secure-repo hardening
permissions: # required for secure-repo hardening
contents: read
jobs:
@@ -19,11 +19,11 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
@@ -87,7 +87,7 @@ jobs:
- name: AI PR Title Analysis
if: steps.actor.outputs.is_repo_dev == 'true'
id: ai-title-analysis
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1.2.8
with:
model: openai/gpt-4o
system-prompt-file: ".github/config/system-prompt.txt"
+2 -5
View File
@@ -2,9 +2,6 @@ name: "Auto Pull Request Labeler V2"
on:
pull_request_target:
types: [opened, synchronize]
branches:
- main
- V3
permissions:
contents: read
@@ -16,11 +13,11 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup GitHub App Bot
id: setup-bot
+27 -25
View File
@@ -2,7 +2,7 @@ name: Build and Test Workflow
on:
pull_request:
branches: ["main"]
branches: ["main", "V2", "V2-gha"]
workflow_dispatch:
# cancel in-progress jobs if a new job is triggered
@@ -27,16 +27,15 @@ jobs:
timeout-minutes: 3
outputs:
build: ${{ steps.changes.outputs.build }}
app: ${{ steps.changes.outputs.app }}
project: ${{ steps.changes.outputs.project }}
openapi: ${{ steps.changes.outputs.openapi }}
frontend: ${{ steps.changes.outputs.frontend }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
@@ -67,12 +66,14 @@ jobs:
with:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
run: ./gradlew clean build -PnoSpotless
env:
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.spring-security }}
- name: Check Test Reports Exist
if: always()
run: |
@@ -90,7 +91,6 @@ jobs:
exit 1
fi
done
- name: Upload Test Reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
@@ -117,17 +117,18 @@ jobs:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
java-version: "17"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Generate OpenAPI documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
env:
DISABLE_ADDITIONAL_FEATURES: true
- name: Upload OpenAPI Documentation
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
@@ -135,8 +136,6 @@ jobs:
path: ./SwaggerDoc.json
frontend-validation:
if: needs.files-changed.outputs.frontend == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:
- name: Harden Runner
@@ -148,8 +147,8 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: "22"
cache: "npm"
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: cd frontend && npm ci
@@ -177,14 +176,12 @@ jobs:
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
java-version: "17"
distribution: "temurin"
- name: check the licenses for compatibility
@@ -226,10 +223,10 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21
- name: Set up Java 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
java-version: "17"
distribution: "temurin"
- name: Set up Docker Buildx
@@ -241,10 +238,10 @@ jobs:
sudo chmod +x /usr/local/bin/docker-compose
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
cache: 'pip' # caching pip dependencies
cache-dependency-path: ./testing/cucumber/requirements.txt
- name: Pip requirements
@@ -288,12 +285,17 @@ jobs:
docker system prune -af || true
echo "Disk space after cleanup:" && df -h
- name: Set up JDK 21
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
java-version: "17"
distribution: "temurin"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
- name: Build application
run: ./gradlew clean build
env:
+5 -4
View File
@@ -25,12 +25,12 @@ jobs:
pull-requests: write # Allow writing to pull requests
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Checkout main branch first
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup GitHub App Bot
id: setup-bot
@@ -194,12 +194,13 @@ jobs:
core.exportVariable("REFERENCE_FILE", referenceFilePath);
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: "3.12"
- name: Install Python dependencies
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
run: |
pip install tomli-w
- name: Run Python script to check files
id: run-check
+79
View File
@@ -0,0 +1,79 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
#disable for now
#on:
# push:
# branches: ["main"]
# pull_request:
# The branches below must be a subset of the branches above
# branches: ["main"]
# schedule:
# - cron: "0 0 * * 1"
permissions:
contents: read
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: ["java"]
# CodeQL supports [ $supported-codeql-languages ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Harden Runner
uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
with:
category: "/language:${{matrix.language}}"
+4 -4
View File
@@ -17,13 +17,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: "Checkout Repository"
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: "Dependency Review"
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
with:
config-file: "./.github/config/dependency-review-config.yml"
config-file: './.github/config/dependency-review-config.yml'
+19 -15
View File
@@ -23,10 +23,10 @@ jobs:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@v3
- name: Get commit hashes for frontend and backend
id: commit-hashes
@@ -36,26 +36,26 @@ jobs:
if [ -z "$FRONTEND_HASH" ]; then
FRONTEND_HASH="no-frontend-changes"
fi
# Get last commit that touched backend code, docker/backend, or docker/compose
BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
if [ -z "$BACKEND_HASH" ]; then
BACKEND_HASH="no-backend-changes"
fi
echo "Frontend hash: $FRONTEND_HASH"
echo "Backend hash: $BACKEND_HASH"
echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
# Short hashes for tags
if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
else
echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
fi
if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
echo "backend_short=no-backend" >> $GITHUB_OUTPUT
else
@@ -84,15 +84,16 @@ jobs:
echo "Backend image needs to be built"
fi
- name: Login to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Build and push frontend image
if: steps.check-frontend.outputs.exists == 'false'
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/frontend/Dockerfile
@@ -102,10 +103,10 @@ jobs:
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Build and push backend image
if: steps.check-backend.outputs.exists == 'false'
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/backend/Dockerfile
@@ -116,16 +117,18 @@ jobs:
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
chmod 600 ../private.key
- name: Deploy to VPS on port 3000
run: |
export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml
cat > $UNIQUE_NAME << EOF
version: '3.3'
services:
@@ -151,7 +154,7 @@ jobs:
SWAGGER_SERVER_URL: "https://demo.stirlingpdf.cloud"
baseUrl: "https://demo.stirlingpdf.cloud"
restart: on-failure:5
frontend:
container_name: stirling-v2-frontend
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
@@ -163,10 +166,10 @@ jobs:
- backend
restart: on-failure:5
EOF
# Copy to remote with unique name
scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/$UNIQUE_NAME
# SSH and rename/move atomically to avoid interference
ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
mkdir -p /stirling/V2/{data,config,logs}
@@ -183,3 +186,4 @@ jobs:
if: always()
run: |
rm -f ../private.key
@@ -1,510 +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@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
with:
filters: .github/config/.files.yaml
generate-frontend-license-report:
if: needs.files-changed.outputs.licenses-frontend == 'true'
name: Generate Frontend License Report
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with:
egress-policy: audit
- name: Checkout PR head (default)
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Setup GitHub App Bot
if: (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)) && github.actor != 'dependabot[bot]'
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Checkout BASE branch (safe script)
if: github.event_name == 'pull_request'
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
ref: ${{ github.event.pull_request.base.sha }}
path: base
fetch-depth: 1
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
working-directory: frontend
env:
NPM_CONFIG_IGNORE_SCRIPTS: "true"
run: npm ci --ignore-scripts --audit=false --fund=false
- name: Generate frontend license report (internal PR)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
working-directory: frontend
env:
PR_IS_FORK: "false"
run: npm run generate-licenses
- name: Generate frontend license report (fork PRs, pinned)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
env:
NPM_CONFIG_IGNORE_SCRIPTS: "true"
working-directory: frontend
run: |
mkdir -p src/assets
npx --yes license-report --only=prod --output=json > src/assets/3rdPartyLicenses.json
- name: Postprocess with project script (BASE version)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
env:
PR_IS_FORK: "true"
run: |
node base/frontend/scripts/generate-licenses.js \
--input frontend/src/assets/3rdPartyLicenses.json
- name: Copy postprocessed artifacts back (fork PRs)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
run: |
mkdir -p frontend/src/assets
if [ -f "base/frontend/src/assets/3rdPartyLicenses.json" ]; then
cp base/frontend/src/assets/3rdPartyLicenses.json frontend/src/assets/3rdPartyLicenses.json
fi
if [ -f "base/frontend/src/assets/license-warnings.json" ]; then
cp base/frontend/src/assets/license-warnings.json frontend/src/assets/license-warnings.json
fi
- name: Check for license warnings
run: |
if [ -f "frontend/src/assets/license-warnings.json" ]; then
echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
else
echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
fi
# PR Event: Check licenses and comment on PR
- name: Delete previous license check comments
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = context.issue.number;
// Get all comments on the PR
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
per_page: 100
});
// Filter for license check comments
const licenseComments = comments.filter(comment =>
comment.body.includes('## ✅ Frontend License Check Passed') ||
comment.body.includes('## ❌ Frontend License Check Failed')
);
// Delete old license check comments
for (const comment of licenseComments) {
console.log(`Deleting old license check comment: ${comment.id}`);
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id
});
}
- name: Summarize results (fork PRs)
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) || github.actor == 'dependabot[bot]'
run: |
{
echo "## Frontend License Check"
echo ""
if [ "${LICENSE_WARNINGS_EXIST}" = "true" ]; then
echo "❌ **Failed** incompatible or unknown licenses found."
if [ -f "frontend/src/assets/license-warnings.json" ]; then
echo ""
echo "### Warnings"
jq -r '.warnings[] | "- \(.message)"' frontend/src/assets/license-warnings.json || true
fi
else
echo "✅ **Passed** no license warnings detected."
fi
echo ""
echo "_Note: This is a fork PR. PR comments are disabled; use this summary._"
} >> "$GITHUB_STEP_SUMMARY"
- name: Comment on PR - License Check Results
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = context.issue.number;
const hasWarnings = process.env.LICENSE_WARNINGS_EXIST === 'true';
let commentBody;
if (hasWarnings) {
// Read warnings file to get specific issues
const fs = require('fs');
let warningDetails = '';
try {
const warnings = JSON.parse(fs.readFileSync('frontend/src/assets/license-warnings.json', 'utf8'));
warningDetails = warnings.warnings.map(w => `- ${w.message}`).join('\n');
} catch (e) {
warningDetails = 'Unable to read warning details';
}
commentBody = `## ❌ Frontend License Check Failed
The frontend license check has detected compatibility warnings that require review:
${warningDetails}
**Action Required:** Please review these licenses to ensure they are acceptable for your use case before merging.
_This check will fail the PR until license issues are resolved._`;
} else {
commentBody = `## ✅ Frontend License Check Passed
All frontend licenses have been validated and no compatibility warnings were detected.
The frontend license report has been updated successfully.`;
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: commentBody
});
- name: Fail workflow if license warnings exist (PR only)
if: github.event_name == 'pull_request' && env.LICENSE_WARNINGS_EXIST == 'true'
run: |
echo "❌ License warnings detected. Failing the workflow."
exit 1
# Push Event: Commit license files and create PR
- name: Commit changes (Push only)
if: github.event_name == 'push'
run: |
git add frontend/src/assets/3rdPartyLicenses.json
# Note: Do NOT commit license-warnings.json - it's only for PR review
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
- name: Prepare PR body (Push only)
if: github.event_name == 'push'
run: |
PR_BODY="Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
This PR updates the frontend license report based on changes to package.json dependencies."
if [ "${{ env.LICENSE_WARNINGS_EXIST }}" = "true" ]; then
PR_BODY="$PR_BODY
## ⚠️ License Compatibility Warnings
The following licenses may require review for corporate compatibility:
$(cat frontend/src/assets/license-warnings.json | jq -r '.warnings[].message')
Please review these licenses to ensure they are acceptable for your use case."
fi
echo "PR_BODY<<EOF" >> $GITHUB_ENV
echo "$PR_BODY" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Create Pull Request (Push only)
id: cpr
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: "Update Frontend 3rd Party Licenses"
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: update-frontend-3rd-party-licenses
base: main
title: "Update Frontend 3rd Party Licenses"
body: ${{ env.PR_BODY }}
labels: Licenses,github-actions,frontend
draft: false
delete-branch: true
sign-commits: true
- name: Enable Pull Request Automerge (Push only)
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'false'
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
env:
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
- name: Add review required label (Push only)
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'true'
run: gh pr edit "${{ steps.cpr.outputs.pull-request-number }}" --add-label "license-review-required"
env:
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
generate-backend-license-report:
if: needs.files-changed.outputs.licenses-backend == 'true'
needs: files-changed
name: Generate Backend License Report
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Setup GitHub App Bot
if: (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)) && github.actor != 'dependabot[bot]'
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
distribution: "temurin"
- name: Check licenses and generate report
id: license-check
run: |
./gradlew clean checkLicense generateLicenseReport || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
env:
DISABLE_ADDITIONAL_FEATURES: false
STIRLING_PDF_DESKTOP_UI: true
- name: Check for license compatibility issues
run: |
if [ -f build/reports/dependency-license/dependencies-without-allowed-license.json ] && \
jq '.dependenciesWithoutAllowedLicenses | length > 0' build/reports/dependency-license/dependencies-without-allowed-license.json | grep -q true; then
echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
else
echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
fi
if: always()
- name: Upload artifact on license issues
if: env.LICENSE_WARNINGS_EXIST == 'true'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: backend-dependencies-without-allowed-license.json
path: build/reports/dependency-license/dependencies-without-allowed-license.json
- name: Move license file
if: env.LICENSE_CHECK_FAILED != 'true' && env.LICENSE_WARNINGS_EXIST == 'false'
run: |
mkdir -p app/core/src/main/resources/static
cp build/reports/dependency-license/index.json app/core/src/main/resources/static/3rdPartyLicenses.json
- name: Delete previous backend license check comments
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = context.issue.number;
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
per_page: 100
});
const backendLicenseComments = comments.filter(comment =>
comment.body.includes('## ✅ Backend License Check Passed') ||
comment.body.includes('## ❌ Backend License Check Failed')
);
for (const comment of backendLicenseComments) {
console.log(`Deleting old backend license comment: ${comment.id}`);
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id
});
}
- name: Comment on PR - Backend License Check Results
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
const hasWarnings = process.env.LICENSE_WARNINGS_EXIST === 'true';
const fs = require('fs');
let warningDetails = '';
if (hasWarnings) {
try {
const warningsFile = 'build/reports/dependency-license/dependencies-without-allowed-license.json';
if (fs.existsSync(warningsFile)) {
const data = JSON.parse(fs.readFileSync(warningsFile, 'utf8'));
if (data.length > 0) {
warningDetails = data.map(dep => `- **${dep.moduleName}@${dep.moduleVersion}** ${dep.moduleLicenses.map(l => l.licenseName).join(', ')}`).join('\n');
}
}
} catch (e) {
warningDetails = 'Unable to parse warning details.';
}
}
let commentBody;
if (hasWarnings) {
commentBody = `## ❌ Backend License Check Failed
The backend license check has detected dependencies with incompatible or unallowed licenses:
${warningDetails || 'See uploaded artifact for details.'}
**Action Required:** Please review these licenses and resolve before merging.
_This check will fail the PR until license issues are resolved._`;
} else {
commentBody = `## ✅ Backend License Check Passed
All backend dependencies have valid and allowed licenses.
The backend license report has been updated successfully.`;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: commentBody
});
- name: Fail workflow if license warnings exist (PR only)
if: github.event_name == 'pull_request' && env.LICENSE_WARNINGS_EXIST == 'true'
run: |
echo "❌ Backend license warnings detected. Failing the workflow."
exit 1
- name: Commit changes (push only)
if: github.event_name == 'push' && env.LICENSE_WARNINGS_EXIST == 'false'
run: |
git config user.name "${{ steps.setup-bot.outputs.committer }}"
git config user.email "${{ steps.setup-bot.outputs.committer-email || 'bot@github.com' }}"
git add app/core/src/main/resources/static/3rdPartyLicenses.json
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
- name: Prepare PR body (push only)
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
run: |
PR_BODY="Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
This PR updates the backend license report based on dependency changes."
if [ "${{ env.LICENSE_WARNINGS_EXIST }}" = "true" ]; then
PR_BODY="$PR_BODY
## ⚠️ License Compatibility Warnings
Incompatible licenses detected manual review required before merge."
fi
echo "PR_BODY<<EOF" >> $GITHUB_ENV
echo "$PR_BODY" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Create Pull Request (push only)
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
id: cpr
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: "Update Backend 3rd Party Licenses"
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: update-backend-3rd-party-licenses
base: main
title: "Update Backend 3rd Party Licenses"
body: ${{ env.PR_BODY }}
labels: Licenses,github-actions,backend
delete-branch: true
sign-commits: true
- name: Enable Pull Request Automerge (push only, no warnings)
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'false'
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
env:
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
- name: Add review required label (push only, with warnings)
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'true'
run: gh pr edit "${{ steps.cpr.outputs.pull-request-number }}" --add-label "license-review-required"
env:
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
@@ -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
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Check out the repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Run Labeler
uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 # v5.3.0
+20 -20
View File
@@ -22,7 +22,7 @@ on:
- macos
- linux
push:
branches: [main, V2-master]
branches: [main, V2, V2-demo, V2-master]
release:
types: [created]
@@ -38,14 +38,14 @@ jobs:
version: ${{ steps.versionNumber.outputs.versionNumber }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "21"
distribution: "temurin"
@@ -106,14 +106,14 @@ jobs:
file_suffix: "-server"
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "21"
distribution: "temurin"
@@ -126,8 +126,8 @@ jobs:
if: matrix.variant.build_frontend == true
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
node-version: 20
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Build JAR
@@ -145,7 +145,7 @@ jobs:
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
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: jar${{ matrix.variant.file_suffix }}
path: ./jar-dist/*.jar
@@ -178,8 +178,8 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
node-version: 20
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Setup Rust
@@ -189,7 +189,7 @@ jobs:
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "21"
distribution: "temurin"
@@ -516,7 +516,7 @@ jobs:
fi
- name: Upload build artifacts
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: Stirling-PDF-${{ matrix.name }}
path: ./dist/*
@@ -530,30 +530,30 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Download all Tauri artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.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@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: jar
path: ./artifacts/jars
- name: Download JAR artifact (with login)
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: jar-with-login
path: ./artifacts/jars
- name: Download JAR artifact (server only)
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: jar-server
path: ./artifacts/jars
@@ -562,7 +562,7 @@ jobs:
run: ls -R ./artifacts
- name: Upload binaries to Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
with:
tag_name: v${{ needs.determine-matrix.outputs.version }}
generate_release_notes: true
+7 -7
View File
@@ -21,12 +21,12 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
@@ -38,10 +38,10 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: 3.12
cache: "pip" # caching pip dependencies
cache: 'pip' # caching pip dependencies
cache-dependency-path: ./.github/scripts/requirements_pre_commit.txt
- name: Run Pre-Commit Hooks
@@ -51,10 +51,10 @@ jobs:
- run: pre-commit run --all-files -c .pre-commit-config.yaml
continue-on-error: true
- name: Set up JDK 21
- name: Set up JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: 21
java-version: 17
distribution: "temurin"
- name: Build with Gradle
@@ -67,7 +67,7 @@ jobs:
- name: Create Pull Request
if: env.CHANGES_DETECTED == 'true'
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: ":file_folder: pre-commit"
+13 -12
View File
@@ -5,6 +5,7 @@ on:
push:
branches:
- V2-master
- alljavadocker
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
@@ -82,7 +83,7 @@ jobs:
- name: Generate tags for latest (V2-master branch - production)
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-master'
with:
images: |
@@ -94,10 +95,10 @@ jobs:
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
type=raw,value=latest
- name: Generate tags for latest (V1_V2_merge branch - test)
- name: Generate tags for latest (alljavadocker branch - test)
id: meta-test
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
if: github.ref == 'refs/heads/V1_V2_merge'
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/alljavadocker'
with:
images: |
ghcr.io/stirling-tools/stirling-pdf-test
@@ -138,7 +139,7 @@ jobs:
- name: Generate tags for latest-fat (V2-master branch - production)
id: meta-fat
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-master'
with:
images: |
@@ -150,10 +151,10 @@ jobs:
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
type=raw,value=latest-fat
- name: Generate tags for latest-fat (V1_V2_merge branch - test)
- name: Generate tags for latest-fat (alljavadocker branch - test)
id: meta-fat-test
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
if: github.ref == 'refs/heads/V1_V2_merge'
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/alljavadocker'
with:
images: |
ghcr.io/stirling-tools/stirling-pdf-test
@@ -192,7 +193,7 @@ jobs:
- name: Generate tags for ultra-lite (V2-master branch - production)
id: meta-lite
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-master'
with:
images: |
@@ -204,10 +205,10 @@ jobs:
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
type=raw,value=latest-ultra-lite
- name: Generate tags for ultra-lite (V1_V2_merge branch - test)
- name: Generate tags for ultra-lite (alljavadocker branch - test)
id: meta-lite-test
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
if: github.ref == 'refs/heads/V1_V2_merge'
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/alljavadocker'
with:
images: |
ghcr.io/stirling-tools/stirling-pdf-test
+9 -9
View File
@@ -31,16 +31,16 @@ jobs:
id-token: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
@@ -55,7 +55,7 @@ jobs:
- name: Install cosign
if: github.ref == 'refs/heads/master'
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
with:
cosign-release: "v2.4.1"
@@ -81,7 +81,7 @@ jobs:
password: ${{ github.token }}
- 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
id: repoowner
@@ -89,7 +89,7 @@ jobs:
- name: Generate tags
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:
images: |
@@ -135,7 +135,7 @@ jobs:
- name: Generate tags ultra-lite
id: meta2
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref != 'refs/heads/main'
with:
images: |
@@ -166,7 +166,7 @@ jobs:
- name: Generate tags fat
id: meta3
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
+535
View File
@@ -0,0 +1,535 @@
name: Release Artifacts
on:
workflow_dispatch:
inputs:
platform:
description: "Platform to build (windows, macos, linux, or all)"
required: true
default: "all"
type: choice
options:
- all
- windows
- macos
- linux
push:
branches: [main, V2, V2-demo]
permissions:
contents: read
jobs:
determine-matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
version: ${{ steps.versionNumber.outputs.versionNumber }}
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Get version number
id: versionNumber
run: |
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Determine build matrix
id: set-matrix
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
case "${{ github.event.inputs.platform }}" in
"windows")
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
;;
"macos")
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
;;
"linux")
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
*)
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
esac
else
# For push events, build all platforms
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
fi
build:
needs: determine-matrix
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
runs-on: ${{ matrix.platform }}
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Set up JDK 21
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "21"
distribution: "temurin"
- name: Build Java backend with JLink
working-directory: ./
shell: bash
run: |
chmod +x ./gradlew
echo "🔧 Building Stirling-PDF JAR..."
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Find the built JAR
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
echo "✅ Built JAR: $STIRLING_JAR"
# Create Tauri directories
mkdir -p ./frontend/src-tauri/libs
mkdir -p ./frontend/src-tauri/runtime
# Copy JAR to Tauri libs
cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
echo "✅ JAR copied to Tauri libs"
# Analyze JAR dependencies for jlink modules
echo "🔍 Analyzing JAR dependencies..."
if command -v jdeps &> /dev/null; then
DETECTED_MODULES=$(jdeps --print-module-deps --ignore-missing-deps "$STIRLING_JAR" 2>/dev/null || echo "")
if [ -n "$DETECTED_MODULES" ]; then
echo "📋 jdeps detected modules: $DETECTED_MODULES"
MODULES="$DETECTED_MODULES,java.compiler,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
else
echo "⚠️ jdeps analysis failed, using predefined modules"
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
fi
else
echo "⚠️ jdeps not available, using predefined modules"
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
fi
# Create custom JRE with jlink
echo "🔧 Creating custom JRE with jlink..."
echo "📋 Using modules: $MODULES"
# Remove any existing JRE
rm -rf ./frontend/src-tauri/runtime/jre
# Create the custom JRE
jlink \
--add-modules "$MODULES" \
--strip-debug \
--compress=2 \
--no-header-files \
--no-man-pages \
--output ./frontend/src-tauri/runtime/jre
if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
echo "❌ Failed to create JLink runtime"
exit 1
fi
# Test the bundled runtime
if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then
RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
echo "✅ Custom JRE created successfully: $RUNTIME_VERSION"
else
echo "❌ Custom JRE executable not found"
exit 1
fi
# Calculate runtime size
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
echo "📊 Custom JRE size: $RUNTIME_SIZE"
env:
DISABLE_ADDITIONAL_FEATURES: true
- name: Install frontend dependencies
working-directory: ./frontend
run: npm install
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
uses: digicert/ssm-code-signing@v1.1.0
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
SM_HOST: ${{ secrets.SM_HOST }}
- name: Setup DigiCert KeyLocker Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: pwsh
run: |
Write-Host "Setting up DigiCert KeyLocker environment..."
# Decode client certificate
$certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}")
$certPath = "D:\Certificate_pkcs12.p12"
[IO.File]::WriteAllBytes($certPath, $certBytes)
# Set environment variables
echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV
echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV
echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV
echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV
# Get PKCS11 config path from DigiCert action
$pkcs11Config = $env:PKCS11_CONFIG
if ($pkcs11Config) {
Write-Host "Found PKCS11_CONFIG: $pkcs11Config"
echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV
} else {
Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path"
$defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg"
if (Test-Path $defaultPath) {
Write-Host "Found config at default path: $defaultPath"
echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV
} else {
Write-Host "Warning: Could not find PKCS11 config file"
}
}
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
- name: Import Windows Code Signing Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
shell: powershell
run: |
if ($env:WINDOWS_CERTIFICATE) {
Write-Host "Importing Windows Code Signing Certificate..."
# Decode base64 certificate and save to file
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
[IO.File]::WriteAllBytes($certPath, $certBytes)
# Import certificate to CurrentUser\My store
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
# Extract and set thumbprint as environment variable
$thumbprint = $cert.Thumbprint
Write-Host "Certificate imported with thumbprint: $thumbprint"
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
# Clean up certificate file
Remove-Item $certPath
Write-Host "Windows certificate import completed."
} else {
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
}
- name: Import Apple Developer Certificate
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
echo "Importing Apple Developer Certificate..."
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
# Create temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Import certificate
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Clean up
rm certificate.p12
- name: Verify Certificate
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application")
echo "Certificate Info: $CERT_INFO"
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
echo "Certificate ID: $CERT_ID"
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported successfully."
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
# Only enable Windows signing in Tauri when on main
SIGN: ${{ github.ref == 'refs/heads/main' && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
CI: true
with:
projectPath: ./frontend
tauriScript: npx tauri
args: ${{ matrix.args }}
# Sign with DigiCert KeyLocker (post-build)
- name: Sign Windows binaries with DigiCert KeyLocker
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: pwsh
run: |
Write-Host "=== DigiCert KeyLocker Signing ==="
# Test smctl connectivity first
Write-Host "Testing smctl connection..."
$healthCheck = & smctl healthcheck 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "[SUCCESS] Connected to DigiCert KeyLocker"
} else {
Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker"
Write-Host $healthCheck
exit 1
}
Write-Host ""
# Sync certificates to Windows certificate store
Write-Host "Syncing certificates to Windows certificate store..."
$syncOutput = & smctl windows certsync 2>&1
Write-Host "Cert sync result: $syncOutput"
Write-Host ""
# Find only the files we need to sign (not build scripts)
$filesToSign = @()
# Main application executable
$mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue
if ($mainExe) { $filesToSign += $mainExe }
# MSI installer
$msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
$filesToSign += $msiFiles
if ($filesToSign.Count -eq 0) {
Write-Host "[ERROR] No files found to sign"
exit 1
}
Write-Host "Found $($filesToSign.Count) files to sign:"
foreach ($f in $filesToSign) { Write-Host " - $($f.Name)" }
Write-Host ""
$signedCount = 0
foreach ($file in $filesToSign) {
Write-Host "Signing: $($file.Name)"
# Get PKCS11 config file path (set by DigiCert action)
$pkcs11Config = $env:PKCS11_CONFIG
if (-not $pkcs11Config) {
Write-Host "[ERROR] PKCS11_CONFIG environment variable not set"
Write-Host "DigiCert KeyLocker action may not have run correctly"
exit 1
}
Write-Host "Using PKCS11 config: $pkcs11Config"
# Try signing with certificate fingerprint first (if available)
$fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}"
if ($fingerprint -and $fingerprint -ne "") {
Write-Host "Attempting to sign with certificate fingerprint..."
$output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
$exitCode = $LASTEXITCODE
} else {
Write-Host "No fingerprint provided, using keypair alias..."
# Use smctl to sign with keypair alias
$output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
$exitCode = $LASTEXITCODE
}
Write-Host "Exit code: $exitCode"
Write-Host "Output: $output"
# Check if output contains "FAILED" even with exit code 0
if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") {
Write-Host ""
Write-Host "[ERROR] Signing failed for $($file.Name)"
Write-Host "[ERROR] smctl returned success but output indicates failure"
exit 1
}
if ($exitCode -ne 0) {
Write-Host "[ERROR] Failed to sign $($file.Name)"
Write-Host "Full error output:"
Write-Host $output
exit 1
}
$signedCount++
Write-Host "[SUCCESS] Signed: $($file.Name)"
Write-Host ""
}
Write-Host "=== Summary ==="
Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully"
- name: Rename artifacts
shell: bash
run: |
mkdir -p ./dist
cd ./frontend/src-tauri/target
# Find and rename artifacts based on platform
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \;
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
else
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
fi
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: Stirling-PDF-${{ matrix.name }}
path: ./dist/*
retention-days: 30
sign_verify:
needs: build
runs-on: ubuntu-latest
if: success()
strategy:
fail-fast: false
matrix:
name: [windows-x86_64, macos-aarch64, macos-x86_64, linux-x86_64]
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Download build artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: Stirling-PDF-${{ matrix.name }}
- name: Display structure of downloaded files
run: ls -R
- name: Install Cosign
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
- name: Generate key pair
run: cosign generate-key-pair
- name: Sign and generate attestations
shell: bash
run: |
# Sign all artifacts for this platform
for file in *; do
if [ -f "$file" ] && [[ ! "$file" =~ \.(sig|intoto\.jsonl)$ ]]; then
echo "Signing: $file"
# Sign the artifact
cosign sign-blob \
--key ./cosign.key \
--yes \
--output-signature "${file}.sig" \
"$file"
# Generate attestation
cosign attest-blob \
--predicate - \
--key ./cosign.key \
--yes \
--output-attestation "${file}.intoto.jsonl" \
"$file"
# Verify the signature
cosign verify-blob \
--key ./cosign.pub \
--signature "${file}.sig" \
"$file"
echo "✅ Signed and verified: $file"
fi
done
- name: Upload signed artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: Stirling-PDF-${{ matrix.name }}-signed
path: |
*
!cosign.key
!cosign.pub
retention-days: 30
release:
needs: [determine-matrix, build, sign_verify]
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
permissions:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Download all signed artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
pattern: Stirling-PDF-*-signed
path: ./artifacts
- name: Display structure of downloaded files
run: ls -R ./artifacts
- name: Create GitHub Release
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
with:
tag_name: v${{ needs.determine-matrix.outputs.version }}
generate_release_notes: true
files: ./artifacts/**/*
draft: false
prerelease: false
+4 -4
View File
@@ -35,12 +35,12 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
@@ -67,7 +67,7 @@ jobs:
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: SARIF file
path: results.sarif
@@ -75,6 +75,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@fdbfb4d2750291e159f0156def62b853c2798ca2 # v3.29.5
uses: github/codeql-action/upload-sarif@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.29.5
with:
sarif_file: results.sarif
+76
View File
@@ -0,0 +1,76 @@
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:
if: ${{ vars.CI_PROFILE != 'lite' }}
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 -2
View File
@@ -17,12 +17,12 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: 30 days stale issues
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 30
+4 -4
View File
@@ -27,16 +27,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
+11 -6
View File
@@ -5,6 +5,7 @@ on:
push:
branches:
- main
- syncLangTest
paths:
- "build.gradle"
- "README.md"
@@ -32,11 +33,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Setup GitHub App Bot
id: setup-bot
@@ -46,13 +47,14 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
- name: Install Python dependencies
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
run: |
pip install tomli-w
- name: Sync translation TOML files
run: |
@@ -63,6 +65,9 @@ jobs:
git add frontend/public/locales/*/translation.toml
git diff --staged --quiet || git commit -m ":memo: Sync translation files (TOML)" || echo "No changes detected"
- name: Install README dependencies
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
- name: Sync README.md
run: |
python scripts/counter_translation_v3.py
@@ -74,7 +79,7 @@ jobs:
- name: Create Pull Request
if: always()
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: Update files
@@ -116,4 +121,4 @@ jobs:
add-paths: |
README.md
frontend/public/locales/*/translation.toml
scripts/ignore_translation.toml
scripts/ignore_translation.toml
+18 -18
View File
@@ -14,14 +14,14 @@ on:
- macos
- linux
pull_request:
branches: [main, V2-tauri-windows]
branches: [main, V2, V2-tauri-windows]
paths:
- "frontend/src-tauri/**"
- "frontend/src/desktop/**"
- "frontend/tsconfig.desktop.json"
- ".github/workflows/tauri-build.yml"
- 'frontend/src-tauri/**'
- 'frontend/src/desktop/**'
- 'frontend/tsconfig.desktop.json'
- '.github/workflows/tauri-build.yml'
push:
branches: [main]
branches: [main, V2]
permissions:
contents: read
@@ -83,8 +83,8 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
node-version: 20
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Setup Rust
@@ -111,15 +111,15 @@ jobs:
# Find the built JAR
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
echo "✅ Built JAR: $STIRLING_JAR"
# Create Tauri directories
mkdir -p ./frontend/src-tauri/libs
mkdir -p ./frontend/src-tauri/runtime
# Copy JAR to Tauri libs
cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
echo "✅ JAR copied to Tauri libs"
# Analyze JAR dependencies for jlink modules
echo "🔍 Analyzing JAR dependencies..."
if command -v jdeps &> /dev/null; then
@@ -135,14 +135,14 @@ jobs:
echo "⚠️ jdeps not available, using predefined modules"
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
fi
# Create custom JRE with jlink (always rebuild)
echo "🔧 Creating custom JRE with jlink..."
echo "📋 Using modules: $MODULES"
# Remove any existing JRE
rm -rf ./frontend/src-tauri/runtime/jre
# Create the custom JRE
jlink \
--add-modules "$MODULES" \
@@ -151,12 +151,12 @@ jobs:
--no-header-files \
--no-man-pages \
--output ./frontend/src-tauri/runtime/jre
if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
echo "❌ Failed to create JLink runtime"
exit 1
fi
# Test the bundled runtime
if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then
RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
@@ -165,7 +165,7 @@ jobs:
echo "❌ Custom JRE executable not found"
exit 1
fi
# Calculate runtime size
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
echo "📊 Custom JRE size: $RUNTIME_SIZE"
@@ -579,7 +579,7 @@ jobs:
name: Stirling-PDF-${{ matrix.name }}
path: ./dist/*
retention-days: 7
- name: Verify build artifacts
shell: bash
run: |
+11 -11
View File
@@ -25,18 +25,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21
- name: Set up JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
distribution: "temurin"
java-version: '17'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
@@ -125,7 +125,7 @@ jobs:
outputs:
frontend: ${{ steps.changes.outputs.frontend }}
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
@@ -140,16 +140,16 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
cache: "npm"
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Run TestDriver.ai
@@ -176,7 +176,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
-2
View File
@@ -43,8 +43,6 @@ app/core/src/main/resources/static/og_images/
app/core/src/main/resources/static/samples/
app/core/src/main/resources/static/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
+3 -3
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.8
rev: v0.12.7
hooks:
- id: ruff
args:
@@ -22,11 +22,11 @@ repos:
files: \.(html|css|js|py|md)$
exclude: (.vscode|.devcontainer|app/core/src/main/resources|app/proprietary/src/main/resources|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.0
rev: v8.28.0
hooks:
- id: gitleaks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
rev: v5.0.0
hooks:
- id: end-of-file-fixer
files: ^.*(\.js|\.java|\.py|\.yml)$
-16
View File
@@ -96,22 +96,6 @@
"moduleName": ".*",
"moduleLicense": "MPL 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Mozilla Public License, Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)"
},
{
"moduleName": ".*",
"moduleLicense": "CDDL+GPL License"
},
{
"moduleName": ".*",
"moduleLicense": "BSD"
},
{
"moduleName": ".*",
"moduleLicense": "UnboundID SCIM2 SDK Free Use License"
+3 -3
View File
@@ -33,16 +33,16 @@ dependencies {
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1'
api 'com.fathzer:javaluator:3.0.6'
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.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
api "org.apache.pdfbox:xmpbox:$pdfboxVersion"
api "org.apache.pdfbox:preflight:$pdfboxVersion"
api 'com.github.junrar:junrar:7.5.7' // RAR archive support for CBR files
api 'com.github.junrar:junrar:7.5.5' // RAR archive support for CBR files
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
api 'org.snakeyaml:snakeyaml-engine:2.10'
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.14"
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13"
api 'jakarta.mail:jakarta.mail-api:2.1.5'
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
}
@@ -156,19 +156,19 @@ public class EndpointConfiguration {
return false;
}
// Rule 2: For tool groups, they're enabled unless explicitly disabled (handled above)
if (isToolGroup(group)) {
log.debug("isGroupEnabled('{}') -> true (tool group not disabled)", group);
return true;
}
// Rule 3: For functional groups, check if all endpoints are enabled
Set<String> endpoints = endpointGroups.get(group);
if (endpoints == null || endpoints.isEmpty()) {
log.debug("isGroupEnabled('{}') -> false (no endpoints)", group);
return false;
}
// Rule 2: For functional groups, check if all endpoints are enabled
// Rule 3: For tool groups, they're enabled unless explicitly disabled (handled above)
if (isToolGroup(group)) {
log.debug("isGroupEnabled('{}') -> true (tool group not disabled)", group);
return true;
}
// For functional groups, check each endpoint individually
for (String endpoint : endpoints) {
if (!isEndpointEnabledDirectly(endpoint)) {
@@ -334,9 +334,6 @@ public class EndpointConfiguration {
addEndpointToGroup("Convert", "pdf-to-csv");
addEndpointToGroup("Convert", "pdf-to-markdown");
addEndpointToGroup("Convert", "eml-to-pdf");
addEndpointToGroup("Convert", "pdf-to-vector");
addEndpointToGroup("Convert", "vector-to-pdf");
addEndpointToGroup("Convert", "pdf-to-video");
addEndpointToGroup("Convert", "cbz-to-pdf");
addEndpointToGroup("Convert", "pdf-to-cbz");
addEndpointToGroup("Convert", "pdf-to-json");
@@ -353,7 +350,6 @@ public class EndpointConfiguration {
addEndpointToGroup("Security", "auto-redact");
addEndpointToGroup("Security", "redact");
addEndpointToGroup("Security", "validate-signature");
addEndpointToGroup("Security", "verify-pdf");
addEndpointToGroup("Security", "stamp");
addEndpointToGroup("Security", "sign");
@@ -480,8 +476,6 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "pdf-to-json");
addEndpointToGroup("Java", "json-to-pdf");
addEndpointToGroup("rar", "pdf-to-cbr");
addEndpointToGroup("Java", "pdf-to-video");
addEndpointToGroup("Java", "verify-pdf");
// Javascript
addEndpointToGroup("Javascript", "pdf-organizer");
@@ -496,10 +490,6 @@ public class EndpointConfiguration {
/* Ghostscript */
addEndpointToGroup("Ghostscript", "repair");
addEndpointToGroup("Ghostscript", "compress-pdf");
addEndpointToGroup("Ghostscript", "crop");
addEndpointToGroup("Ghostscript", "replace-invert-pdf");
addEndpointToGroup("Ghostscript", "pdf-to-vector");
addEndpointToGroup("Ghostscript", "vector-to-pdf");
/* ImageMagick */
addEndpointToGroup("ImageMagick", "compress-pdf");
@@ -538,9 +528,6 @@ public class EndpointConfiguration {
addEndpointToGroup("Weasyprint", "markdown-to-pdf");
addEndpointToGroup("Weasyprint", "eml-to-pdf");
// veraPDF dependent endpoints
addEndpointToGroup("veraPDF", "verify-pdf");
// Pdftohtml dependent endpoints
addEndpointToGroup("Pdftohtml", "pdf-to-html");
addEndpointToGroup("Pdftohtml", "pdf-to-markdown");
@@ -567,7 +554,7 @@ public class EndpointConfiguration {
disableGroup("enterprise");
}
if (!applicationProperties.getSystem().isEnableUrlToPDF()) {
if (!applicationProperties.getSystem().getEnableUrlToPDF()) {
disableEndpoint("url-to-pdf");
}
}
@@ -591,10 +578,7 @@ public class EndpointConfiguration {
|| "Weasyprint".equals(group)
|| "Pdftohtml".equals(group)
|| "ImageMagick".equals(group)
|| "rar".equals(group)
|| "Calibre".equals(group)
|| "FFmpeg".equals(group)
|| "veraPDF".equals(group);
|| "rar".equals(group);
}
private boolean isEndpointEnabledDirectly(String endpoint) {
@@ -37,7 +37,7 @@ public class AutoJobAspect {
@Around("@annotation(autoJobPostMapping)")
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)
// Extract parameters from the request and annotation
boolean async = Boolean.parseBoolean(request.getParameter("async"));
@@ -81,12 +81,6 @@ public class AutoJobAspect {
"AutoJobAspect caught exception during job execution: {}",
ex.getMessage(),
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);
}
},
@@ -115,8 +109,7 @@ public class AutoJobAspect {
int maxRetries,
boolean trackProgress,
boolean queueable,
int resourceWeight)
throws Exception {
int resourceWeight) {
// Keep jobId reference for progress tracking in TaskManager
AtomicReference<String> jobIdRef = new AtomicReference<>();
@@ -214,12 +207,6 @@ public class AutoJobAspect {
// If we get here, all retries failed
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(
"Job failed after "
+ maxRetries
@@ -76,7 +76,7 @@ public class AppConfig {
@Bean(name = "loginEnabled")
public boolean loginEnabled() {
return applicationProperties.getSecurity().isEnableLogin();
return applicationProperties.getSecurity().getEnableLogin();
}
@Bean(name = "appName")
@@ -120,7 +120,9 @@ public class AppConfig {
@Bean(name = "enableAlphaFunctionality")
public boolean enableAlphaFunctionality() {
return applicationProperties.getSystem().isEnableAlphaFunctionality();
return applicationProperties.getSystem().getEnableAlphaFunctionality() != null
? applicationProperties.getSystem().getEnableAlphaFunctionality()
: false;
}
@Bean(name = "rateLimit")
@@ -263,14 +265,9 @@ public class AppConfig {
return "NORMAL";
}
@Bean(name = "scarfEnabled")
public boolean scarfEnabled() {
return applicationProperties.getSystem().isScarfEnabled();
}
@Bean(name = "posthogEnabled")
public boolean posthogEnabled() {
return applicationProperties.getSystem().isPosthogEnabled();
@Bean(name = "disablePixel")
public boolean disablePixel() {
return Boolean.parseBoolean(env.getProperty("DISABLE_PIXEL", "false"));
}
@Bean(name = "machineType")
@@ -2,7 +2,6 @@ package stirling.software.common.configuration;
import java.io.File;
import java.nio.file.Paths;
import java.util.Locale;
import lombok.extern.slf4j.Slf4j;
@@ -62,7 +61,7 @@ public class InstallationPathConfig {
private static String initializeBasePath() {
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_DESKTOP_UI", "false"))) {
String os = System.getProperty("os.name").toLowerCase(Locale.ROOT);
String os = System.getProperty("os.name").toLowerCase();
if (os.contains("win")) {
return Paths.get(
System.getenv("APPDATA"), // parent path
@@ -10,10 +10,8 @@ import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
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.Pipeline;
import stirling.software.common.model.ApplicationProperties.System;
@Slf4j
@Configuration
@@ -21,16 +19,8 @@ import stirling.software.common.model.ApplicationProperties.System;
public class RuntimePathConfig {
private final ApplicationProperties properties;
private final String basePath;
// Operation paths
private final String weasyPrintPath;
private final String unoConvertPath;
private final String calibrePath;
private final String ocrMyPdfPath;
private final String sOfficePath;
// Tesseract data path
private final String tessDataPath;
// Pipeline paths
private final String pipelineWatchedFoldersPath;
@@ -47,10 +37,7 @@ public class RuntimePathConfig {
String defaultFinishedFolders = Path.of(this.pipelinePath, "finishedFolders").toString();
String defaultWebUIConfigs = Path.of(this.pipelinePath, "defaultWebUIConfigs").toString();
System system = properties.getSystem();
CustomPaths customPaths = system.getCustomPaths();
Pipeline pipeline = customPaths.getPipeline();
Pipeline pipeline = properties.getSystem().getCustomPaths().getPipeline();
this.pipelineWatchedFoldersPath =
resolvePath(
@@ -70,11 +57,8 @@ public class RuntimePathConfig {
// Initialize Operation paths
String defaultWeasyPrintPath = isDocker ? "/opt/venv/bin/weasyprint" : "weasyprint";
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 =
resolvePath(
defaultWeasyPrintPath,
@@ -83,28 +67,6 @@ public class RuntimePathConfig {
resolvePath(
defaultUnoConvertPath,
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
String defaultTessDataPath =
isDocker ? "/usr/share/tesseract-ocr/5/tessdata" : "/usr/share/tessdata";
String tessPath = system.getTessdataDir();
String tessdataDir = java.lang.System.getenv("TESSDATA_PREFIX");
this.tessDataPath =
resolvePath(
defaultTessDataPath,
(tessPath != null && !tessPath.isEmpty()) ? tessPath : tessdataDir);
log.info("Using Tesseract data path: {}", this.tessDataPath);
}
private String resolvePath(String defaultPath, String customPath) {
@@ -12,7 +12,6 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
@@ -153,7 +152,7 @@ public class ApplicationProperties {
@Data
public static class Security {
private boolean enableLogin;
private Boolean enableLogin;
private InitialLogin initialLogin = new InitialLogin();
private OAUTH2 oauth2 = new OAUTH2();
private SAML2 saml2 = new SAML2();
@@ -328,7 +327,7 @@ public class ApplicationProperties {
private KeycloakProvider keycloak = new KeycloakProvider();
public Provider get(String registrationId) throws UnsupportedProviderException {
return switch (registrationId.toLowerCase(Locale.ROOT)) {
return switch (registrationId.toLowerCase()) {
case "google" -> getGoogle();
case "github" -> getGithub();
case "keycloak" -> getKeycloak();
@@ -336,8 +335,8 @@ public class ApplicationProperties {
throw new UnsupportedProviderException(
"Logout from the provider "
+ registrationId
+ " is not supported. Report it at"
+ " https://github.com/Stirling-Tools/Stirling-PDF/issues");
+ " is not supported. "
+ "Report it at https://github.com/Stirling-Tools/Stirling-PDF/issues");
};
}
}
@@ -390,21 +389,20 @@ public class ApplicationProperties {
@Data
public static class System {
private String defaultLocale;
private boolean googlevisibility;
private Boolean googlevisibility;
private boolean showUpdate;
private boolean showUpdateOnlyAdmin;
private boolean showSettingsWhenNoLogin = true;
private Boolean showUpdateOnlyAdmin;
private boolean customHTMLFiles;
private String tessdataDir;
private boolean enableAlphaFunctionality;
private Boolean enableAlphaFunctionality;
private Boolean enableAnalytics;
private Boolean enablePosthog;
private Boolean enableScarf;
private Boolean enableDesktopInstallSlide;
private Datasource datasource;
private boolean disableSanitize;
private Boolean disableSanitize;
private int maxDPI;
private boolean enableUrlToPDF;
private Boolean enableUrlToPDF;
private Html html = new Html();
private CustomPaths customPaths = new CustomPaths();
private String fileUploadLimit;
@@ -417,7 +415,6 @@ public class ApplicationProperties {
private String frontendUrl; // Frontend URL for invite email links (e.g.
// 'https://app.example.com'). If not set, falls back to backendUrl.
private boolean enableMobileScanner = false; // Enable mobile phone QR code upload feature
public boolean isAnalyticsEnabled() {
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
@@ -457,9 +454,6 @@ public class ApplicationProperties {
public static class Operations {
private String weasyprint;
private String unoconvert;
private String calibre;
private String ocrmypdf;
private String soffice;
}
}
@@ -542,10 +536,10 @@ public class ApplicationProperties {
@Override
public String toString() {
return """
Driver {
driverName='%s'
}
"""
Driver {
driverName='%s'
}
"""
.formatted(driverName);
}
}
@@ -577,7 +571,7 @@ public class ApplicationProperties {
@Data
public static class Metrics {
private boolean enabled;
private Boolean enabled;
}
@Data
@@ -674,25 +668,6 @@ public class ApplicationProperties {
public static class EnterpriseFeatures {
private PersistentMetrics persistentMetrics = new PersistentMetrics();
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
public static class Audit {
@@ -727,7 +702,6 @@ public class ApplicationProperties {
private int tesseractSessionLimit;
private int ghostscriptSessionLimit;
private int ocrMyPdfSessionLimit;
private int ffmpegSessionLimit;
public int getQpdfSessionLimit() {
return qpdfSessionLimit > 0 ? qpdfSessionLimit : 2;
@@ -772,10 +746,6 @@ public class ApplicationProperties {
public int getOcrMyPdfSessionLimit() {
return ocrMyPdfSessionLimit > 0 ? ocrMyPdfSessionLimit : 2;
}
public int getFfmpegSessionLimit() {
return ffmpegSessionLimit > 0 ? ffmpegSessionLimit : 2;
}
}
@Data
@@ -804,7 +774,6 @@ public class ApplicationProperties {
private long qpdfTimeoutMinutes;
private long ghostscriptTimeoutMinutes;
private long ocrMyPdfTimeoutMinutes;
private long ffmpegTimeoutMinutes;
public long getTesseractTimeoutMinutes() {
return tesseractTimeoutMinutes > 0 ? tesseractTimeoutMinutes : 30;
@@ -849,10 +818,6 @@ public class ApplicationProperties {
public long getOcrMyPdfTimeoutMinutes() {
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.
public String getFormattedFileSize() {
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) {
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) {
return String.format(Locale.ROOT, "%.2f KB", fileSize / 1024.0);
return String.format(Locale.US, "%.2f KB", fileSize / 1024.0);
} else {
return String.format("%d Bytes", fileSize);
}
@@ -7,7 +7,6 @@ import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Locale;
import java.util.concurrent.atomic.AtomicLong;
import org.apache.pdfbox.Loader;
@@ -250,7 +249,7 @@ public class CustomPDFDocumentFactory {
log.debug(
"Memory status - Free: {}MB ({}%), Used: {}MB, Max: {}MB",
actualFreeMemory / (1024 * 1024),
String.format(Locale.ROOT, "%.2f", freeMemoryPercent),
String.format("%.2f", freeMemoryPercent),
usedMemory / (1024 * 1024),
maxMemory / (1024 * 1024));
@@ -259,7 +258,7 @@ public class CustomPDFDocumentFactory {
|| actualFreeMemory < MIN_FREE_MEMORY_BYTES) {
log.debug(
"Low memory detected ({}%), forcing file-based cache",
String.format(Locale.ROOT, "%.2f", freeMemoryPercent));
String.format("%.2f", freeMemoryPercent));
return createScratchFileCacheFunction(MemoryUsageSetting.setupTempFileOnly());
} else if (contentSize < SMALL_FILE_THRESHOLD) {
log.debug("Using memory-only cache for small document ({}KB)", contentSize / 1024);
@@ -478,6 +477,11 @@ public class CustomPDFDocumentFactory {
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 */
public byte[] createNewBytesBasedOnOldDocument(byte[] oldDocument) throws IOException {
try (PDDocument document = load(oldDocument)) {
@@ -253,22 +253,6 @@ public class JobExecutorService {
log.error("Synchronous job timed out after {} ms", timeoutToUse);
return ResponseEntity.internalServerError()
.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) {
log.error("Error executing synchronous job: {}", e.getMessage(), e);
// Construct a JSON error response
@@ -1,438 +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 org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import lombok.extern.slf4j.Slf4j;
/**
* Service for handling mobile scanner file uploads and temporary storage. Files are stored
* temporarily and automatically cleaned up after 10 minutes or upon retrieval.
*/
@Service
@Slf4j
public class MobileScannerService {
private static final long SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
private final Map<String, SessionData> activeSessions = new ConcurrentHashMap<>();
private final Path tempDirectory;
public MobileScannerService() throws IOException {
// Create temp directory for mobile scanner uploads
this.tempDirectory =
Paths.get(System.getProperty("java.io.tmpdir"), "stirling-mobile-scanner");
Files.createDirectories(tempDirectory);
log.info("Mobile scanner temp directory: {}", tempDirectory);
}
/**
* Register a new session (called by desktop when QR code is generated)
*
* @param sessionId Unique session identifier
* @return SessionInfo with creation time and expiry
*/
public SessionInfo createSession(String sessionId) {
validateSessionId(sessionId);
SessionData session = new SessionData(sessionId);
activeSessions.put(sessionId, session);
log.info("Created mobile scanner session: {}", sessionId);
return new SessionInfo(
sessionId,
session.createdAt,
session.createdAt + SESSION_TIMEOUT_MS,
SESSION_TIMEOUT_MS);
}
/**
* Validate if a session exists and is not expired
*
* @param sessionId Session identifier to validate
* @return SessionInfo if valid, null if invalid/expired
*/
public SessionInfo validateSession(String sessionId) {
SessionData session = activeSessions.get(sessionId);
if (session == null) {
return null;
}
long now = System.currentTimeMillis();
long expiryTime = session.getLastAccessTime() + SESSION_TIMEOUT_MS;
// Check if expired
if (now > expiryTime) {
deleteSession(sessionId);
return null;
}
session.updateLastAccess();
return new SessionInfo(sessionId, session.createdAt, expiryTime, SESSION_TIMEOUT_MS);
}
/**
* Stores uploaded files for a session
*
* @param sessionId Unique session identifier
* @param files Files to upload
* @throws IOException If file storage fails
*/
public void uploadFiles(String sessionId, List<MultipartFile> files) throws IOException {
validateSessionId(sessionId);
SessionData session =
activeSessions.computeIfAbsent(sessionId, id -> new SessionData(sessionId));
// Create session directory
Path sessionDir = getSafeSessionDirectory(sessionId);
Files.createDirectories(sessionDir);
// Save each file
for (MultipartFile file : files) {
if (file.isEmpty()) {
continue;
}
String originalFilename = file.getOriginalFilename();
if (originalFilename == null || originalFilename.isBlank()) {
originalFilename = "upload-" + System.currentTimeMillis();
}
// Sanitize filename
String safeFilename = sanitizeFilename(originalFilename);
Path filePath = sessionDir.resolve(safeFilename).normalize().toAbsolutePath();
// Ensure resulting path stays within the session directory
if (!filePath.startsWith(sessionDir)) {
throw new IOException("Invalid filename");
}
// Handle duplicate filenames
int counter = 1;
while (Files.exists(filePath)) {
String nameWithoutExt = safeFilename.replaceFirst("[.][^.]+$", "");
String ext =
safeFilename.contains(".")
? safeFilename.substring(safeFilename.lastIndexOf("."))
: "";
safeFilename = nameWithoutExt + "-" + counter + ext;
filePath = sessionDir.resolve(safeFilename).normalize().toAbsolutePath();
if (!filePath.startsWith(sessionDir)) {
throw new IOException("Invalid filename");
}
counter++;
}
file.transferTo(filePath);
session.addFile(new FileMetadata(safeFilename, file.getSize(), file.getContentType()));
log.info(
"Uploaded file for session {}: {} ({} bytes)",
sessionId,
safeFilename,
file.getSize());
}
session.updateLastAccess();
}
/**
* Retrieves file metadata for a session
*
* @param sessionId Session identifier
* @return List of file metadata, or empty list if session doesn't exist
*/
public List<FileMetadata> getSessionFiles(String sessionId) {
SessionData session = activeSessions.get(sessionId);
if (session == null) {
return List.of();
}
session.updateLastAccess();
return new ArrayList<>(session.getFiles());
}
/**
* Retrieves actual file data for download
*
* @param sessionId Session identifier
* @param filename Filename to retrieve
* @return File path
* @throws IOException If file not found or session doesn't exist
*/
public Path getFile(String sessionId, String filename) throws IOException {
SessionData session = activeSessions.get(sessionId);
if (session == null) {
throw new IOException("Session not found: " + sessionId);
}
Path filePath = getSafeFilePath(sessionId, filename);
if (!Files.exists(filePath)) {
throw new IOException("File not found: " + filename);
}
session.updateLastAccess();
session.markFileAsDownloaded(filename);
return filePath;
}
/**
* Deletes a file after it has been served to the client. Should be called after successful
* download.
*
* @param sessionId Session identifier
* @param filename Filename to delete
*/
public void deleteFileAfterDownload(String sessionId, String filename) {
try {
Path filePath = getSafeFilePath(sessionId, filename);
Files.deleteIfExists(filePath);
log.info("Deleted file after download: {}/{}", sessionId, filename);
// Check if all files have been downloaded - if so, delete the entire session
SessionData session = activeSessions.get(sessionId);
if (session != null && session.allFilesDownloaded()) {
deleteSession(sessionId);
log.info("All files downloaded - deleted session: {}", sessionId);
}
} catch (IOException | IllegalArgumentException e) {
log.warn("Failed to delete file after download: {}/{}", sessionId, filename, e);
}
}
/**
* Deletes a session and all its files
*
* @param sessionId Session to delete
*/
public void deleteSession(String sessionId) {
SessionData session = activeSessions.remove(sessionId);
if (session != null) {
try {
Path sessionDir = getSafeSessionDirectory(sessionId);
if (Files.exists(sessionDir)) {
// Delete all files in session directory
Files.walk(sessionDir)
.sorted(
(a, b) ->
-a.compareTo(b)) // Reverse order to delete files before
// directory
.forEach(
path -> {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.warn("Failed to delete file: {}", path, e);
}
});
}
log.info("Deleted session: {}", sessionId);
} catch (IllegalArgumentException e) {
log.warn(
"Refused to delete session with invalid sessionId '{}': {}",
sessionId,
e.getMessage());
} catch (IOException e) {
log.error("Error deleting session directory: {}", sessionId, e);
}
}
}
/** Scheduled cleanup of expired sessions (runs every 5 minutes) */
@Scheduled(fixedRate = 5 * 60 * 1000)
public void cleanupExpiredSessions() {
long now = System.currentTimeMillis();
List<String> expiredSessions = new ArrayList<>();
activeSessions.forEach(
(sessionId, session) -> {
if (now - session.getLastAccessTime() > SESSION_TIMEOUT_MS) {
expiredSessions.add(sessionId);
}
});
if (!expiredSessions.isEmpty()) {
log.info("Cleaning up {} expired mobile scanner sessions", expiredSessions.size());
expiredSessions.forEach(this::deleteSession);
}
}
private void validateSessionId(String sessionId) {
if (sessionId == null || sessionId.isBlank()) {
throw new IllegalArgumentException("Session ID cannot be empty");
}
// Basic validation: alphanumeric and hyphens only
if (!sessionId.matches("[a-zA-Z0-9-]+")) {
throw new IllegalArgumentException("Invalid session ID format");
}
}
private String sanitizeFilename(String filename) {
// Remove path traversal attempts and dangerous characters
String sanitized = filename.replaceAll("[^a-zA-Z0-9._-]", "_");
// Ensure we have a non-empty, safe filename
if (sanitized.isBlank()) {
sanitized = "upload-" + System.currentTimeMillis();
}
return sanitized;
}
/**
* Safely resolves and validates a session directory path to prevent directory traversal
* attacks.
*
* @param sessionId Session identifier
* @return Normalized absolute path to the session directory
* @throws IllegalArgumentException if the resolved path escapes the temp directory
*/
private Path getSafeSessionDirectory(String sessionId) {
validateSessionId(sessionId);
Path baseDir = tempDirectory.normalize().toAbsolutePath();
Path sessionDir = baseDir.resolve(sessionId).normalize().toAbsolutePath();
// Verify the resolved path is still within the temp directory
if (!sessionDir.startsWith(baseDir)) {
throw new IllegalArgumentException("Invalid session ID: path traversal detected");
}
return sessionDir;
}
/**
* Safely resolves and validates a file path within a session directory to prevent directory
* traversal attacks.
*
* @param sessionId Session identifier
* @param filename Filename within the session
* @return Normalized absolute path to the file
* @throws IOException if the resolved path escapes the session directory
*/
private Path getSafeFilePath(String sessionId, String filename) throws IOException {
if (filename == null || filename.isBlank()) {
throw new IOException("Filename cannot be empty");
}
// Additional validation: reject filenames with path separators or parent references
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
throw new IOException(
"Invalid filename: contains path separators or parent references");
}
Path sessionDir = getSafeSessionDirectory(sessionId);
Path filePath = sessionDir.resolve(filename).normalize().toAbsolutePath();
// Verify the resolved path is still within the session directory
if (!filePath.startsWith(sessionDir)) {
throw new IOException("Invalid filename: path traversal detected");
}
return filePath;
}
/** Session information for client */
public static class SessionInfo {
private final String sessionId;
private final long createdAt;
private final long expiresAt;
private final long timeoutMs;
public SessionInfo(String sessionId, long createdAt, long expiresAt, long timeoutMs) {
this.sessionId = sessionId;
this.createdAt = createdAt;
this.expiresAt = expiresAt;
this.timeoutMs = timeoutMs;
}
public String getSessionId() {
return sessionId;
}
public long getCreatedAt() {
return createdAt;
}
public long getExpiresAt() {
return expiresAt;
}
public long getTimeoutMs() {
return timeoutMs;
}
}
/** File metadata for client */
public static class FileMetadata {
private final String filename;
private final long size;
private final String contentType;
public FileMetadata(String filename, long size, String contentType) {
this.filename = filename;
this.size = size;
this.contentType = contentType;
}
public String getFilename() {
return filename;
}
public long getSize() {
return size;
}
public String getContentType() {
return contentType;
}
}
/** Session data tracking */
private static class SessionData {
private final String sessionId;
private final List<FileMetadata> files = new ArrayList<>();
private final Map<String, Boolean> downloadedFiles = new HashMap<>();
private final long createdAt;
private long lastAccessTime;
public SessionData(String sessionId) {
this.sessionId = sessionId;
this.createdAt = System.currentTimeMillis();
this.lastAccessTime = createdAt;
}
public void addFile(FileMetadata file) {
files.add(file);
downloadedFiles.put(file.getFilename(), false);
}
public List<FileMetadata> getFiles() {
return files;
}
public void markFileAsDownloaded(String filename) {
downloadedFiles.put(filename, true);
}
public boolean allFilesDownloaded() {
return !downloadedFiles.isEmpty()
&& downloadedFiles.values().stream().allMatch(downloaded -> downloaded);
}
public void updateLastAccess() {
this.lastAccessTime = System.currentTimeMillis();
}
public long getLastAccessTime() {
return lastAccessTime;
}
}
}
@@ -253,7 +253,7 @@ public class PostHogService {
addIfNotEmpty(
properties,
"security_enableLogin",
applicationProperties.getSecurity().isEnableLogin());
applicationProperties.getSecurity().getEnableLogin());
addIfNotEmpty(properties, "security_csrfDisabled", true);
addIfNotEmpty(
properties,
@@ -299,13 +299,13 @@ public class PostHogService {
addIfNotEmpty(
properties,
"system_googlevisibility",
applicationProperties.getSystem().isGooglevisibility());
applicationProperties.getSystem().getGooglevisibility());
addIfNotEmpty(
properties, "system_showUpdate", applicationProperties.getSystem().isShowUpdate());
addIfNotEmpty(
properties,
"system_showUpdateOnlyAdmin",
applicationProperties.getSystem().isShowUpdateOnlyAdmin());
applicationProperties.getSystem().getShowUpdateOnlyAdmin());
addIfNotEmpty(
properties,
"system_customHTMLFiles",
@@ -317,7 +317,7 @@ public class PostHogService {
addIfNotEmpty(
properties,
"system_enableAlphaFunctionality",
applicationProperties.getSystem().isEnableAlphaFunctionality());
applicationProperties.getSystem().getEnableAlphaFunctionality());
addIfNotEmpty(
properties,
"system_enableAnalytics",
@@ -337,7 +337,7 @@ public class PostHogService {
// Capture Metrics properties
addIfNotEmpty(
properties, "metrics_enabled", applicationProperties.getMetrics().isEnabled());
properties, "metrics_enabled", applicationProperties.getMetrics().getEnabled());
// Capture EnterpriseEdition properties
addIfNotEmpty(
@@ -5,7 +5,6 @@ import java.lang.management.MemoryMXBean;
import java.lang.management.OperatingSystemMXBean;
import java.time.Duration;
import java.time.Instant;
import java.util.Locale;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@@ -174,8 +173,8 @@ public class ResourceMonitor {
log.info("System resource status changed from {} to {}", oldStatus, newStatus);
log.info(
"Current metrics - CPU: {}%, Memory: {}%, Free Memory: {} MB",
String.format(Locale.ROOT, "%.1f", cpuUsage * 100),
String.format(Locale.ROOT, "%.1f", memoryUsage * 100),
String.format("%.1f", cpuUsage * 100),
String.format("%.1f", memoryUsage * 100),
freeMemory / (1024 * 1024));
}
} catch (Exception e) {
@@ -5,7 +5,6 @@ import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.util.Locale;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
@@ -84,7 +83,7 @@ public class SsrfProtectionService {
return false;
}
return config.getAllowedDomains().contains(host.toLowerCase(Locale.ROOT));
return config.getAllowedDomains().contains(host.toLowerCase());
} catch (Exception e) {
log.debug("Failed to parse URL for MAX security check: {}", url, e);
@@ -102,7 +101,7 @@ public class SsrfProtectionService {
return false;
}
String hostLower = host.toLowerCase(Locale.ROOT);
String hostLower = host.toLowerCase();
// Check explicit blocked domains
if (config.getBlockedDomains().contains(hostLower)) {
@@ -112,7 +111,7 @@ public class SsrfProtectionService {
// Check internal TLD patterns
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);
return false;
}
@@ -124,11 +123,9 @@ public class SsrfProtectionService {
config.getAllowedDomains().stream()
.anyMatch(
domain ->
hostLower.equals(domain.toLowerCase(Locale.ROOT))
hostLower.equals(domain.toLowerCase())
|| hostLower.endsWith(
"."
+ domain.toLowerCase(
Locale.ROOT)));
"." + domain.toLowerCase()));
if (!isAllowed) {
log.debug("URL not in allowed domains list: {}", url);
@@ -7,7 +7,6 @@ import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
@@ -102,16 +101,14 @@ public class TaskManager {
if (!extractedFiles.isEmpty()) {
jobResult.completeWithFiles(extractedFiles);
log.debug(
"Set multiple file results for job ID: {} with {} files extracted from"
+ " ZIP",
"Set multiple file results for job ID: {} with {} files extracted from ZIP",
jobId,
extractedFiles.size());
return;
}
} catch (Exception e) {
log.warn(
"Failed to extract ZIP file for job {}: {}. Falling back to single file"
+ " result.",
"Failed to extract ZIP file for job {}: {}. Falling back to single file result.",
jobId,
e.getMessage());
}
@@ -345,12 +342,12 @@ public class TaskManager {
/** Check if a file is a ZIP file based on content type and filename */
private boolean isZipFile(String contentType, String fileName) {
if (contentType != null
&& ("application/zip".equals(contentType)
|| "application/x-zip-compressed".equals(contentType))) {
&& (contentType.equals("application/zip")
|| contentType.equals("application/x-zip-compressed"))) {
return true;
}
if (fileName != null && fileName.toLowerCase(Locale.ROOT).endsWith(".zip")) {
if (fileName != null && fileName.toLowerCase().endsWith(".zip")) {
return true;
}
@@ -417,7 +414,7 @@ public class TaskManager {
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
String lowerName = fileName.toLowerCase(Locale.ROOT);
String lowerName = fileName.toLowerCase();
if (lowerName.endsWith(".pdf")) {
return MediaType.APPLICATION_PDF_VALUE;
} else if (lowerName.endsWith(".txt")) {
@@ -6,7 +6,6 @@ import java.io.InputStream;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import org.apache.commons.io.FilenameUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
@@ -59,24 +58,31 @@ public class CbrUtils {
log.warn(
"Failed to open CBR/RAR archive due to corrupt header: {}",
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) {
log.warn("Failed to open CBR/RAR archive: {}", e.getMessage());
String errorMessage;
String exMessage = e.getMessage() != null ? e.getMessage() : "";
if (exMessage.contains("encrypted")) {
throw ExceptionUtils.createCbrEncryptedException();
errorMessage = "Encrypted CBR/RAR archives are not supported.";
} else if (exMessage.isEmpty()) {
throw ExceptionUtils.createCbrInvalidFormatException(
"Invalid CBR/RAR archive. The file may be encrypted, corrupted, or"
+ " use an unsupported format.");
errorMessage =
"Invalid CBR/RAR archive. "
+ "The file may be encrypted, corrupted, or use an unsupported format.";
} else {
throw ExceptionUtils.createCbrInvalidFormatException(
errorMessage =
"Invalid CBR/RAR archive: "
+ exMessage
+ ". The file may be encrypted, corrupted, or use an"
+ " unsupported format.");
+ ". The file may be encrypted, corrupted, or use an unsupported format.";
}
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat", errorMessage);
} catch (IOException e) {
log.warn("IO error reading CBR/RAR archive: {}", e.getMessage());
throw ExceptionUtils.createFileProcessingException("CBR extraction", e);
@@ -115,8 +121,7 @@ public class CbrUtils {
if (imageEntries.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileProcessing",
"No valid images found in the CBR file. The archive may be empty or"
+ " contain no supported image formats.");
"No valid images found in the CBR file. The archive may be empty or contain no supported image formats.");
}
for (ImageEntryData imageEntry : imageEntries) {
@@ -141,8 +146,7 @@ public class CbrUtils {
if (document.getNumberOfPages() == 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileProcessing",
"No images could be processed from the CBR file. All images may be"
+ " corrupted or in unsupported formats.");
"No images could be processed from the CBR file. All images may be corrupted or in unsupported formats.");
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -155,6 +159,7 @@ public class CbrUtils {
return GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
} catch (IOException e) {
log.warn("Ghostscript optimization failed, returning unoptimized PDF", e);
return pdfBytes;
}
}
@@ -165,17 +170,17 @@ public class CbrUtils {
private void validateCbrFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw ExceptionUtils.createFileNullOrEmptyException();
throw new IllegalArgumentException("File cannot be null or empty");
}
String filename = file.getOriginalFilename();
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)) {
throw ExceptionUtils.createNotCbrFileException();
throw new IllegalArgumentException("File must be a CBR or RAR archive");
}
}
@@ -185,7 +190,7 @@ public class CbrUtils {
return false;
}
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
String extension = FilenameUtils.getExtension(filename).toLowerCase();
return "cbr".equals(extension) || "rar".equals(extension);
}
@@ -8,7 +8,6 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.Enumeration;
import java.util.List;
import java.util.Locale;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipInputStream;
@@ -56,10 +55,10 @@ public class CbzUtils {
new java.io.FileInputStream(tempFile.getFile()));
ZipInputStream zis = new ZipInputStream(bis)) {
if (zis.getNextEntry() == null) {
throw ExceptionUtils.createCbzEmptyException();
throw new IllegalArgumentException("Archive is empty or invalid ZIP");
}
} catch (IOException e) {
throw ExceptionUtils.createCbzInvalidFormatException(e);
throw new IllegalArgumentException("Invalid CBZ/ZIP archive", e);
}
try (PDDocument document = pdfDocumentFactory.createNewDocument();
@@ -84,7 +83,7 @@ public class CbzUtils {
Comparator.comparing(ImageEntryData::name, new NaturalOrderComparator()));
if (imageEntries.isEmpty()) {
throw ExceptionUtils.createCbzNoImagesException();
throw new IllegalArgumentException("No valid images found in the CBZ file");
}
for (ImageEntryData imageEntry : imageEntries) {
@@ -107,7 +106,8 @@ public class CbzUtils {
}
if (document.getNumberOfPages() == 0) {
throw ExceptionUtils.createCbzCorruptedImagesException();
throw new IllegalArgumentException(
"No images could be processed from the CBZ file");
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
@@ -119,6 +119,7 @@ public class CbzUtils {
return GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
} catch (IOException e) {
log.warn("Ghostscript optimization failed, returning unoptimized PDF", e);
return pdfBytes;
}
}
@@ -129,17 +130,17 @@ public class CbzUtils {
private void validateCbzFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw ExceptionUtils.createFileNullOrEmptyException();
throw new IllegalArgumentException("File cannot be null or empty");
}
String filename = file.getOriginalFilename();
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)) {
throw ExceptionUtils.createNotCbzFileException();
throw new IllegalArgumentException("File must be a CBZ or ZIP archive");
}
}
@@ -149,7 +150,7 @@ public class CbzUtils {
return false;
}
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
String extension = FilenameUtils.getExtension(filename).toLowerCase();
return "cbz".equals(extension) || "zip".equals(extension);
}
@@ -159,7 +160,7 @@ public class CbzUtils {
return false;
}
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
String extension = FilenameUtils.getExtension(filename).toLowerCase();
return "cbz".equals(extension)
|| "zip".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 boolean pythonAvailableChecked = false;
private static String availablePythonCommand = null;
private static boolean ffmpegAvailableChecked = false;
private static boolean ffmpegAvailable = false;
/**
* Checks which Python command is available and returns it.
@@ -58,25 +56,4 @@ public class CheckProgramInstall {
public static boolean isPythonAvailable() {
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
*/
public static String checksum(InputStream is, String algorithm) throws IOException {
return switch (algorithm.toUpperCase(Locale.ROOT)) {
case "CRC32" -> checksumChecksum(is, new CRC32());
case "ADLER32" -> checksumChecksum(is, new Adler32());
default -> toHex(checksumBytes(is, algorithm));
};
switch (algorithm.toUpperCase(Locale.ROOT)) {
case "CRC32":
return checksumChecksum(is, new CRC32());
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
*/
public static String checksumBase64(InputStream is, String algorithm) throws IOException {
return switch (algorithm.toUpperCase(Locale.ROOT)) {
case "CRC32" ->
Base64.getEncoder().encodeToString(checksumChecksumBytes(is, new CRC32()));
case "ADLER32" ->
Base64.getEncoder().encodeToString(checksumChecksumBytes(is, new Adler32()));
default -> Base64.getEncoder().encodeToString(checksumBytes(is, algorithm));
};
switch (algorithm.toUpperCase(Locale.ROOT)) {
case "CRC32":
return Base64.getEncoder().encodeToString(checksumChecksumBytes(is, new CRC32()));
case "ADLER32":
return Base64.getEncoder().encodeToString(checksumChecksumBytes(is, new Adler32()));
default:
return Base64.getEncoder().encodeToString(checksumBytes(is, algorithm));
}
}
/**
@@ -175,7 +179,7 @@ public class ChecksumUtils {
for (Map.Entry<String, Checksum> entry : checksums.entrySet()) {
// Keep value as long and mask to ensure unsigned hex formatting.
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;
}
@@ -254,7 +258,7 @@ public class ChecksumUtils {
}
// Keep as long and mask to ensure correct unsigned representation.
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) {
StringBuilder sb = new StringBuilder(hash.length * 2);
for (byte b : hash) {
sb.append(String.format(Locale.ROOT, "%02x", b));
sb.append(String.format("%02x", b));
}
return sb.toString();
}
@@ -62,7 +62,8 @@ public class CustomHtmlSanitizer {
.and(new HtmlPolicyBuilder().disallowElements("noscript").toFactory());
public String sanitize(String html) {
boolean disableSanitize = applicationProperties.getSystem().isDisableSanitize();
boolean disableSanitize =
Boolean.TRUE.equals(applicationProperties.getSystem().getDisableSanitize());
return disableSanitize ? html : POLICY.sanitize(html);
}
}
@@ -11,7 +11,6 @@ import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
import java.util.regex.Pattern;
@@ -230,8 +229,7 @@ public class EmlParser {
Method getContentType = message.getClass().getMethod("getContentType");
String contentType = (String) getContentType.invoke(message);
if (contentType != null
&& contentType.toLowerCase(Locale.ROOT).contains(TEXT_HTML)) {
if (contentType != null && contentType.toLowerCase().contains(TEXT_HTML)) {
content.setHtmlBody(stringContent);
} else {
content.setTextBody(stringContent);
@@ -298,7 +296,7 @@ public class EmlParser {
String contentType = (String) getContentType.invoke(part);
String normalizedDisposition =
disposition != null ? ((String) disposition).toLowerCase(Locale.ROOT) : null;
disposition != null ? ((String) disposition).toLowerCase() : null;
if ((Boolean) isMimeType.invoke(part, TEXT_PLAIN) && normalizedDisposition == null) {
Object partContent = getContent.invoke(part);
@@ -424,7 +422,7 @@ public class EmlParser {
RegexPatternUtils.getInstance().getNewlineSplitPattern().split(emlContent);
for (int i = 0; i < lines.length; i++) {
String line = lines[i];
if (line.toLowerCase(Locale.ROOT).startsWith(headerName.toLowerCase(Locale.ROOT))) {
if (line.toLowerCase().startsWith(headerName.toLowerCase())) {
StringBuilder value =
new StringBuilder(line.substring(headerName.length()).trim());
for (int j = i + 1; j < lines.length; j++) {
@@ -446,7 +444,7 @@ public class EmlParser {
private static String extractHtmlBody(String emlContent) {
try {
String lowerContent = emlContent.toLowerCase(Locale.ROOT);
String lowerContent = emlContent.toLowerCase();
int htmlStart = lowerContent.indexOf(HEADER_CONTENT_TYPE + " " + TEXT_HTML);
if (htmlStart == -1) return null;
@@ -465,7 +463,7 @@ public class EmlParser {
private static String extractTextBody(String emlContent) {
try {
String lowerContent = emlContent.toLowerCase(Locale.ROOT);
String lowerContent = emlContent.toLowerCase();
int textStart = lowerContent.indexOf(HEADER_CONTENT_TYPE + " " + TEXT_PLAIN);
if (textStart == -1) {
int bodyStart = emlContent.indexOf("\r\n\r\n");
@@ -518,7 +516,7 @@ public class EmlParser {
String currentEncoding = "";
for (String line : lines) {
String lowerLine = line.toLowerCase(Locale.ROOT).trim();
String lowerLine = line.toLowerCase().trim();
if (line.trim().isEmpty()) {
inHeaders = false;
@@ -556,12 +554,9 @@ public class EmlParser {
}
private static boolean isAttachment(String disposition, String filename, String contentType) {
return (disposition.toLowerCase(Locale.ROOT).contains(DISPOSITION_ATTACHMENT)
&& !filename.isEmpty())
|| (!filename.isEmpty()
&& !contentType.toLowerCase(Locale.ROOT).startsWith("text/"))
|| (contentType.toLowerCase(Locale.ROOT).contains("application/")
&& !filename.isEmpty());
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) {
@@ -570,8 +565,8 @@ public class EmlParser {
}
// Handle filename*= (RFC 2231 encoded filename)
if (disposition.toLowerCase(Locale.ROOT).contains("filename*=")) {
int filenameStarStart = disposition.toLowerCase(Locale.ROOT).indexOf("filename*=") + 10;
if (disposition.toLowerCase().contains("filename*=")) {
int filenameStarStart = disposition.toLowerCase().indexOf("filename*=") + 10;
int filenameStarEnd = disposition.indexOf(";", filenameStarStart);
if (filenameStarEnd == -1) filenameStarEnd = disposition.length();
String extendedFilename =
@@ -591,7 +586,7 @@ public class EmlParser {
}
// Handle regular filename=
int filenameStart = disposition.toLowerCase(Locale.ROOT).indexOf("filename=") + 9;
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();
@@ -48,11 +48,11 @@ public class EmlProcessingUtils {
public static void validateEmlInput(byte[] emlBytes) {
if (emlBytes == null || emlBytes.length == 0) {
throw ExceptionUtils.createEmlEmptyException();
throw new IllegalArgumentException("EML file is empty or null");
}
if (isInvalidEmlFormat(emlBytes)) {
throw ExceptionUtils.createEmlInvalidFormatException();
throw new IllegalArgumentException("Invalid EML file format");
}
}
@@ -109,13 +109,12 @@ public class EmlProcessingUtils {
html.append(
String.format(
Locale.ROOT,
"""
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<title>%s</title>
<style>
""",
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<title>%s</title>
<style>
""",
sanitizeText(content.getSubject(), customHtmlSanitizer)));
appendEnhancedStyles(html);
@@ -128,15 +127,14 @@ public class EmlProcessingUtils {
html.append(
String.format(
Locale.ROOT,
"""
<div class="email-container">
<div class="email-header">
<h1>%s</h1>
<div class="email-meta">
<div><strong>From:</strong> %s</div>
<div><strong>To:</strong> %s</div>
""",
<div class="email-container">
<div class="email-header">
<h1>%s</h1>
<div class="email-meta">
<div><strong>From:</strong> %s</div>
<div><strong>To:</strong> %s</div>
""",
sanitizeText(content.getSubject(), customHtmlSanitizer),
sanitizeText(content.getFrom(), customHtmlSanitizer),
sanitizeText(content.getTo(), customHtmlSanitizer)));
@@ -144,7 +142,6 @@ public class EmlProcessingUtils {
if (content.getCc() != null && !content.getCc().trim().isEmpty()) {
html.append(
String.format(
Locale.ROOT,
"<div><strong>CC:</strong> %s</div>\n",
sanitizeText(content.getCc(), customHtmlSanitizer)));
}
@@ -152,7 +149,6 @@ public class EmlProcessingUtils {
if (content.getBcc() != null && !content.getBcc().trim().isEmpty()) {
html.append(
String.format(
Locale.ROOT,
"<div><strong>BCC:</strong> %s</div>\n",
sanitizeText(content.getBcc(), customHtmlSanitizer)));
}
@@ -160,13 +156,11 @@ public class EmlProcessingUtils {
if (content.getDate() != null) {
html.append(
String.format(
Locale.ROOT,
"<div><strong>Date:</strong> %s</div>\n",
PdfAttachmentHandler.formatEmailDate(content.getDate())));
} else if (content.getDateString() != null && !content.getDateString().trim().isEmpty()) {
html.append(
String.format(
Locale.ROOT,
"<div><strong>Date:</strong> %s</div>\n",
sanitizeText(content.getDateString(), customHtmlSanitizer)));
}
@@ -181,7 +175,6 @@ public class EmlProcessingUtils {
} else if (content.getTextBody() != null && !content.getTextBody().trim().isEmpty()) {
html.append(
String.format(
Locale.ROOT,
"<div class=\"text-body\">%s</div>",
convertTextToHtml(content.getTextBody(), customHtmlSanitizer)));
} else {
@@ -241,16 +234,14 @@ public class EmlProcessingUtils {
.getUrlLinkPattern()
.matcher(html)
.replaceAll(
"<a href=\"$1\" style=\"color: #1a73e8; text-decoration:"
+ " underline;\">$1</a>");
"<a href=\"$1\" style=\"color: #1a73e8; text-decoration: underline;\">$1</a>");
html =
RegexPatternUtils.getInstance()
.getEmailLinkPattern()
.matcher(html)
.replaceAll(
"<a href=\"mailto:$1\" style=\"color: #1a73e8; text-decoration:"
+ " underline;\">$1</a>");
"<a href=\"mailto:$1\" style=\"color: #1a73e8; text-decoration: underline;\">$1</a>");
return html;
}
@@ -258,128 +249,127 @@ public class EmlProcessingUtils {
private static void appendEnhancedStyles(StringBuilder html) {
String css =
String.format(
Locale.ROOT,
"""
body {
font-family: %s;
font-size: %dpx;
line-height: %s;
color: %s;
margin: 0;
padding: 16px;
background-color: %s;
}
body {
font-family: %s;
font-size: %dpx;
line-height: %s;
color: %s;
margin: 0;
padding: 16px;
background-color: %s;
}
.email-container {
width: 100%%;
max-width: 100%%;
margin: 0 auto;
}
.email-container {
width: 100%%;
max-width: 100%%;
margin: 0 auto;
}
.email-header {
padding-bottom: 10px;
border-bottom: 1px solid %s;
margin-bottom: 10px;
}
.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-header h1 {
margin: 0 0 10px 0;
font-size: %dpx;
font-weight: bold;
}
.email-meta div {
margin-bottom: 2px;
font-size: %dpx;
}
.email-meta div {
margin-bottom: 2px;
font-size: %dpx;
}
.email-body {
word-wrap: break-word;
}
.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 {
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-section h3 {
margin: 0 0 8px 0;
font-size: %dpx;
}
.attachment-item {
padding: 5px 0;
}
.attachment-item {
padding: 5px 0;
}
.attachment-icon {
margin-right: 5px;
}
.attachment-icon {
margin-right: 5px;
}
.attachment-details, .attachment-type {
font-size: %dpx;
color: #555555;
}
.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, .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-inclusion-note {
background-color: #e6ffed;
border: 1px solid #d4f7dc;
color: #006420;
}
.attachment-info-note {
background-color: #fff9e6;
border: 1px solid #fff0c2;
color: #664d00;
}
.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 {
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-link-container:hover {
background-color: #e9ecef;
}
.attachment-note {
font-size: %dpx;
color: #6c757d;
font-style: italic;
margin-left: 8px;
}
.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;
}
.no-content {
padding: 20px;
text-align: center;
color: #666;
font-style: italic;
}
.text-body {
white-space: pre-wrap;
}
.text-body {
white-space: pre-wrap;
}
img {
max-width: 100%%;
height: auto;
display: block;
}
""",
img {
max-width: 100%%;
height: auto;
display: block;
}
""",
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_LINE_HEIGHT,
@@ -430,14 +420,13 @@ public class EmlProcessingUtils {
String attachmentId = "attachment_" + i;
html.append(
String.format(
Locale.ROOT,
"""
<div class="attachment-item" id="%s">
<span class="attachment-icon" data-filename="%s">@</span>
<span class="attachment-name">%s</span>
<span class="attachment-details">(%s%s)</span>
</div>
""",
<div class="attachment-item" id="%s">
<span class="attachment-icon" data-filename="%s">@</span>
<span class="attachment-name">%s</span>
<span class="attachment-details">(%s%s)</span>
</div>
""",
attachmentId,
escapeHtml(embeddedFilename),
escapeHtml(EmlParser.safeMimeDecode(attachment.getFilename())),
@@ -481,7 +470,7 @@ public class EmlProcessingUtils {
}
if (filename != null) {
String lowerFilename = filename.toLowerCase(Locale.ROOT);
String lowerFilename = filename.toLowerCase();
for (Map.Entry<String, String> entry : EXTENSION_TO_MIME_TYPE.entrySet()) {
if (lowerFilename.endsWith(entry.getKey())) {
return entry.getValue();
@@ -527,7 +516,7 @@ public class EmlProcessingUtils {
result.append(processedText, lastEnd, matcher.start());
String charset = matcher.group(1);
String encoding = matcher.group(2).toUpperCase(Locale.ROOT);
String encoding = matcher.group(2).toUpperCase();
String encodedValue = matcher.group(3);
try {
File diff suppressed because it is too large Load Diff
@@ -5,11 +5,13 @@ import java.io.FileOutputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
@@ -35,11 +37,9 @@ public class FileToPdf {
try (TempFile tempInputFile =
new TempFile(
tempFileManager,
fileName.toLowerCase(Locale.ROOT).endsWith(".html")
? ".html"
: ".zip")) {
fileName.toLowerCase().endsWith(".html") ? ".html" : ".zip")) {
if (fileName.toLowerCase(Locale.ROOT).endsWith(".html")) {
if (fileName.toLowerCase().endsWith(".html")) {
String sanitizedHtml =
sanitizeHtmlContent(
new String(fileBytes, StandardCharsets.UTF_8),
@@ -47,7 +47,7 @@ public class FileToPdf {
Files.write(
tempInputFile.getPath(),
sanitizedHtml.getBytes(StandardCharsets.UTF_8));
} else if (fileName.toLowerCase(Locale.ROOT).endsWith(".zip")) {
} else if (fileName.toLowerCase().endsWith(".zip")) {
Files.write(tempInputFile.getPath(), fileBytes);
sanitizeHtmlFilesInZip(
tempInputFile.getPath(), tempFileManager, customHtmlSanitizer);
@@ -102,8 +102,8 @@ public class FileToPdf {
tempUnzippedDir.getPath().resolve(sanitizeZipFilename(entry.getName()));
if (!entry.isDirectory()) {
Files.createDirectories(filePath.getParent());
if (entry.getName().toLowerCase(Locale.ROOT).endsWith(".html")
|| entry.getName().toLowerCase(Locale.ROOT).endsWith(".htm")) {
if (entry.getName().toLowerCase().endsWith(".html")
|| entry.getName().toLowerCase().endsWith(".htm")) {
String content =
new String(zipIn.readAllBytes(), StandardCharsets.UTF_8);
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) {
if (entryName == null || entryName.trim().isEmpty()) {
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
}
}
@@ -33,11 +33,6 @@ import stirling.software.common.configuration.InstallationPathConfig;
@UtilityClass
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;
private final Set<String> DEFAULT_VALID_SCRIPTS = Set.of("png_to_webp.py", "split_photos.py");
private final Set<String> DEFAULT_VALID_PIPELINE =
Set.of(
@@ -91,18 +86,36 @@ public class GeneralUtils {
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
} catch (IOException e) {
if (tempFile.exists()) {
try {
Files.delete(tempFile.toPath());
} catch (IOException ignored) {
}
}
throw e;
}
return tempFile;
}
/*
* 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
*
@@ -141,7 +154,7 @@ public class GeneralUtils {
return matcher.find() ? matcher.replaceFirst("") : filename;
}
/**
/*
* Append suffix to base name with null safety.
*
* @param baseName the base filename, null becomes "default"
@@ -152,7 +165,7 @@ public class GeneralUtils {
return (baseName == null ? "default" : baseName) + (suffix != null ? 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
@@ -167,7 +180,7 @@ public class GeneralUtils {
return appendSuffix(baseName, suffix);
}
/**
/*
* Process a list of filenames by removing extensions and adding suffix.
*
* <p>Efficiently processes multiple filenames using streaming operations and bulk operations
@@ -188,7 +201,7 @@ public class GeneralUtils {
.forEach(processor);
}
/**
/*
* Extract title from filename by removing extension, with fallback handling.
*
* <p>Returns "Untitled" for null or empty filenames, otherwise removes the extension using the
@@ -256,12 +269,6 @@ public class GeneralUtils {
.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) {
try {
Urls.create(
@@ -272,7 +279,7 @@ public class GeneralUtils {
}
}
/**
/*
* Checks if a URL is reachable with proper timeout configuration and error handling.
*
* @param urlStr the URL string to check
@@ -282,17 +289,15 @@ public class GeneralUtils {
return isURLReachable(urlStr, 5000, 5000);
}
/**
* Checks whether a URL is reachable using configurable timeouts. Only {@code http} and {@code
* https} protocols are permitted, and local/private/multicast ranges are blocked.
/*
* Checks if a URL is reachable with configurable timeouts.
*
* @param urlStr the URL to probe
* @param urlStr the URL string to check
* @param connectTimeout connection 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) {
HttpURLConnection connection = null;
try {
// Parse the URL
URL url = URI.create(urlStr).toURL();
@@ -303,17 +308,14 @@ public class GeneralUtils {
return false; // Disallow other protocols
}
// Check if the host is a local address
String host = url.getHost();
if (host == null || host.isBlank()) {
return false;
}
if (isDisallowedNetworkLocation(host)) {
return false; // Exclude local, private or otherwise sensitive addresses
if (isLocalAddress(host)) {
return false; // Exclude local addresses
}
// Check if the URL is reachable
connection = (HttpURLConnection) url.openConnection();
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("HEAD");
connection.setConnectTimeout(connectTimeout);
connection.setReadTimeout(readTimeout);
@@ -324,173 +326,29 @@ public class GeneralUtils {
} catch (Exception e) {
log.debug("URL {} is not reachable: {}", urlStr, e.getMessage());
return false; // Return false in case of any exception
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
/**
* 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.
private boolean isLocalAddress(String host) {
try {
InetAddress[] addresses = InetAddress.getAllByName(host);
if (addresses.length > MAX_DNS_ADDRESSES) {
log.debug(
"Blocking URL to host {} due to excessive DNS records (>{})",
host,
MAX_DNS_ADDRESSES);
return true;
}
for (InetAddress address : addresses) {
if (address == null || isSensitiveAddress(address)) {
log.debug("Blocking URL to host {} resolved to {}", host, address);
return true;
}
}
return false;
// Resolve DNS to IP address
InetAddress address = InetAddress.getByName(host);
// Check for local addresses
return address.isAnyLocalAddress()
|| // Matches 0.0.0.0 or similar
address.isLoopbackAddress()
|| // Matches 127.0.0.1 or ::1
address.isSiteLocalAddress()
|| // Matches private IPv4 ranges: 192.168.x.x, 10.x.x.x, 172.16.x.x to
// 172.31.x.x
address.getHostAddress()
.startsWith("fe80:"); // Matches link-local IPv6 addresses
} catch (Exception e) {
log.debug("Unable to resolve host {}: {}", host, e.getMessage());
return true; // Treat resolution issues as unsafe to avoid SSRF
return false; // Return false for invalid or unresolved addresses
}
}
/**
* 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.
*
@@ -507,12 +365,6 @@ public class GeneralUtils {
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
} catch (IOException e) {
try {
Files.deleteIfExists(tempFile);
} catch (IOException ignored) {
}
throw e;
}
return tempFile.toFile();
}
@@ -534,7 +386,7 @@ public class GeneralUtils {
throw new IllegalArgumentException("Invalid default unit: " + defaultUnit);
}
sizeStr = sizeStr.trim().toUpperCase(Locale.ROOT);
sizeStr = sizeStr.trim().toUpperCase();
sizeStr = sizeStr.replace(",", ".").replace(" ", "");
try {
@@ -563,7 +415,7 @@ public class GeneralUtils {
return Long.parseLong(sizeStr.substring(0, sizeStr.length() - 1));
} else {
// 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";
double value = Double.parseDouble(sizeStr);
return switch (unit) {
case "TB" -> (long) (value * 1024L * 1024L * 1024L * 1024L);
@@ -605,14 +457,13 @@ public class GeneralUtils {
if (bytes < 1024) {
return bytes + " B";
} 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) {
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) {
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 {
return String.format(
Locale.ROOT, "%.2f TB", bytes / (1024.0 * 1024.0 * 1024.0 * 1024.0));
return String.format(Locale.US, "%.2f TB", bytes / (1024.0 * 1024.0 * 1024.0 * 1024.0));
}
}
@@ -872,7 +723,7 @@ public class GeneralUtils {
byte[] mac = net.getHardwareAddress();
if (mac != null && mac.length > 0) {
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
}
@@ -882,7 +733,7 @@ public class GeneralUtils {
byte[] mac = network.getHardwareAddress();
if (mac != null) {
for (byte b : mac) {
sb.append(String.format(Locale.ROOT, "%02X", b));
sb.append(String.format("%02X", b));
}
}
}
@@ -1089,29 +940,17 @@ public class GeneralUtils {
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
.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) {
log.warn(
"Ghostscript ebook optimization failed with return code: {}",
result.getRc());
throw ExceptionUtils.createGhostscriptCompressionException(result.getMessages());
throw ExceptionUtils.createGhostscriptCompressionException();
}
return Files.readAllBytes(tempOutput);
} catch (Exception e) {
log.warn("Ghostscript ebook optimization failed", e);
if (e instanceof ExceptionUtils.GhostscriptException ghostscriptException) {
throw ghostscriptException;
}
throw ExceptionUtils.createGhostscriptCompressionException(e);
} finally {
if (tempInput != null) {
@@ -6,7 +6,6 @@ import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.util.Iterator;
import java.util.Locale;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
@@ -63,16 +62,15 @@ public class ImageProcessingUtils {
} else {
int width = image.getWidth();
int height = image.getHeight();
int[] pixels = new int[width * height];
image.getRGB(0, 0, width, height, pixels, 0, width);
byte[] data = new byte[width * height * 3];
int index = 0;
for (int rgb : pixels) {
data[index++] = (byte) ((rgb >> 16) & 0xFF); // Red
data[index++] = (byte) ((rgb >> 8) & 0xFF); // Green
data[index++] = (byte) (rgb & 0xFF); // Blue
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int rgb = image.getRGB(x, y);
data[index++] = (byte) ((rgb >> 16) & 0xFF); // Red
data[index++] = (byte) ((rgb >> 8) & 0xFF); // Green
data[index++] = (byte) (rgb & 0xFF); // Blue
}
}
return data;
}
@@ -120,7 +118,7 @@ public class ImageProcessingUtils {
BufferedImage image = null;
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
Iterator<ImageReader> readers = ImageIO.getImageReadersByFormatName("PSD");
if (readers.hasNext()) {
@@ -136,8 +134,7 @@ public class ImageProcessingUtils {
throw new IOException(
"Unable to read image from file: "
+ filename
+ ". Supported PSD formats: RGB/CMYK/Gray 8-32 bit, RLE/ZIP"
+ " compression");
+ ". Supported PSD formats: RGB/CMYK/Gray 8-32 bit, RLE/ZIP compression");
}
} else {
// For non-PSD files, use standard ImageIO
@@ -145,7 +142,7 @@ public class ImageProcessingUtils {
}
if (image == null) {
throw ExceptionUtils.createImageReadException(filename);
throw new IOException("Unable to read image from file: " + filename);
}
double orientation = extractImageOrientation(file.getInputStream());
@@ -27,22 +27,15 @@ import io.github.pixee.security.Filenames;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
@Slf4j
public class PDFToFile {
private final TempFileManager tempFileManager;
private final RuntimePathConfig runtimePathConfig;
public PDFToFile(TempFileManager tempFileManager) {
this(tempFileManager, null);
}
public PDFToFile(TempFileManager tempFileManager, RuntimePathConfig runtimePathConfig) {
this.tempFileManager = tempFileManager;
this.runtimePathConfig = runtimePathConfig;
}
public ResponseEntity<byte[]> processPdfToMarkdown(MultipartFile inputFile)
@@ -248,65 +241,31 @@ public class PDFToFile {
byte[] fileBytes;
String fileName;
Path libreOfficeProfile = null;
try (TempFile inputFileTemp = new TempFile(tempFileManager, ".pdf");
TempDirectory outputDirTemp = new TempDirectory(tempFileManager)) {
Path tempInputFile = inputFileTemp.getPath();
Path tempOutputDir = outputDirTemp.getPath();
Path unoOutputFile =
tempOutputDir.resolve(
pdfBaseName + "." + resolvePrimaryExtension(outputFormat));
// Save the uploaded file to a temporary location
inputFile.transferTo(tempInputFile);
// Run the LibreOffice command
ProcessExecutorResult returnCode = null;
IOException unoconvertException = null;
if (isUnoConvertEnabled()) {
try {
List<String> unoCommand =
buildUnoConvertCommand(
tempInputFile, unoOutputFile, outputFormat, libreOfficeFilter);
returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
.runCommandWithOutputHandling(unoCommand);
} catch (IOException e) {
unoconvertException = e;
log.warn(
"Unoconvert command failed ({}). Falling back to soffice command.",
e.getMessage());
}
}
if (returnCode == null) {
// Run the LibreOffice command as a fallback
libreOfficeProfile = Files.createTempDirectory("libreoffice_profile_");
List<String> command = new ArrayList<>();
command.add(runtimePathConfig.getSOfficePath());
command.add("-env:UserInstallation=" + libreOfficeProfile.toUri().toString());
command.add("--headless");
command.add("--nologo");
command.add("--infilter=" + libreOfficeFilter);
command.add("--convert-to");
command.add(outputFormat);
command.add("--outdir");
command.add(tempOutputDir.toString());
command.add(tempInputFile.toString());
try {
returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
.runCommandWithOutputHandling(command);
} catch (IOException e) {
if (unoconvertException != null) {
e.addSuppressed(unoconvertException);
}
throw e;
}
}
List<String> command =
new ArrayList<>(
Arrays.asList(
"soffice",
"--headless",
"--nologo",
"--infilter=" + libreOfficeFilter,
"--convert-to",
outputFormat,
"--outdir",
tempOutputDir.toString(),
tempInputFile.toString()));
ProcessExecutorResult returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
.runCommandWithOutputHandling(command);
// Get output files
List<File> outputFiles = Arrays.asList(tempOutputDir.toFile().listFiles());
@@ -341,42 +300,8 @@ public class PDFToFile {
fileBytes = byteArrayOutputStream.toByteArray();
}
} finally {
if (libreOfficeProfile != null) {
FileUtils.deleteQuietly(libreOfficeProfile.toFile());
}
}
return WebResponseUtils.bytesToWebResponse(
fileBytes, fileName, MediaType.APPLICATION_OCTET_STREAM);
}
private boolean isUnoConvertEnabled() {
return runtimePathConfig != null
&& runtimePathConfig.getUnoConvertPath() != null
&& !runtimePathConfig.getUnoConvertPath().isBlank();
}
private List<String> buildUnoConvertCommand(
Path inputFile, Path outputFile, String outputFormat, String libreOfficeFilter) {
List<String> command = new ArrayList<>();
command.add(runtimePathConfig.getUnoConvertPath());
command.add("--port");
command.add("2003");
command.add("--convert-to");
command.add(outputFormat);
if (libreOfficeFilter != null && !libreOfficeFilter.isBlank()) {
command.add("--input-filter=" + libreOfficeFilter);
}
command.add(inputFile.toString());
command.add(outputFile.toString());
return command;
}
private String resolvePrimaryExtension(String outputFormat) {
if (outputFormat == null) {
return "";
}
int colonIndex = outputFormat.indexOf(':');
return colonIndex > 0 ? outputFormat.substring(0, colonIndex) : outputFormat;
}
}
@@ -318,7 +318,7 @@ public class PdfAttachmentHandler {
private static String normalizeFilename(String filename) {
if (filename == null) return "";
String normalized = filename.toLowerCase(Locale.ROOT).trim();
String normalized = filename.toLowerCase().trim();
normalized =
RegexPatternUtils.getInstance()
.getWhitespacePattern()
@@ -560,7 +560,7 @@ public class PdfAttachmentHandler {
@Override
protected void writeString(String string, List<TextPosition> textPositions)
throws IOException {
String lowerString = string.toLowerCase(Locale.ROOT);
String lowerString = string.toLowerCase();
if (ATTACHMENT_SECTION_PATTERN.matcher(lowerString).find()) {
isInAttachmentSection = true;
@@ -9,7 +9,6 @@ import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import javax.imageio.ImageIO;
@@ -35,7 +34,7 @@ public class PdfToCbrUtils {
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
if (document.getNumberOfPages() == 0) {
throw ExceptionUtils.createPdfNoPages();
throw new IllegalArgumentException("PDF file contains no pages");
}
return createCbrFromPdf(document, dpi);
@@ -44,17 +43,17 @@ public class PdfToCbrUtils {
private static void validatePdfFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw ExceptionUtils.createFileNullOrEmptyException();
throw new IllegalArgumentException("File cannot be null or empty");
}
String filename = file.getOriginalFilename();
if (filename == null) {
throw ExceptionUtils.createFileNoNameException();
throw new IllegalArgumentException("File must have a name");
}
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
String extension = FilenameUtils.getExtension(filename).toLowerCase();
if (!"pdf".equals(extension)) {
throw ExceptionUtils.createPdfFileRequiredException();
throw new IllegalArgumentException("File must be a PDF");
}
}
@@ -67,36 +66,27 @@ public class PdfToCbrUtils {
int totalPages = document.getNumberOfPages();
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
final int currentPage = pageIndex;
try {
BufferedImage image =
ExceptionUtils.handleOomRendering(
currentPage + 1,
dpi,
() ->
pdfRenderer.renderImageWithDPI(
currentPage, dpi, ImageType.RGB));
pdfRenderer.renderImageWithDPI(pageIndex, dpi, ImageType.RGB);
String imageFilename =
String.format(Locale.ROOT, "page_%03d.png", currentPage + 1);
String imageFilename = String.format("page_%03d.png", pageIndex + 1);
Path imagePath = tempDir.resolve(imageFilename);
ImageIO.write(image, "PNG", imagePath.toFile());
generatedImages.add(imagePath);
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
// Re-throw OOM exceptions without wrapping
throw e;
} catch (IOException e) {
// Wrap other IOExceptions with context
throw ExceptionUtils.createFileProcessingException(
"CBR creation for page " + (currentPage + 1), e);
log.warn("Error processing page {}: {}", pageIndex + 1, e.getMessage());
} catch (OutOfMemoryError e) {
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
} catch (NegativeArraySizeException e) {
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
}
}
if (generatedImages.isEmpty()) {
throw ExceptionUtils.createFileProcessingException(
"CBR conversion", new IOException("No pages were successfully rendered"));
throw new IOException("Failed to render any pages to images for CBR conversion");
}
return createRarArchive(tempDir, generatedImages);
@@ -125,18 +115,15 @@ public class PdfToCbrUtils {
ProcessExecutorResult result =
executor.runCommandWithOutputHandling(command, tempDir.toFile());
if (result.getRc() != 0) {
throw ExceptionUtils.createFileProcessingException(
"RAR archive creation",
new IOException("RAR command failed with code " + result.getRc()));
throw new IOException("RAR command failed: " + result.getMessages());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw ExceptionUtils.createProcessingInterruptedException("RAR creation", e);
throw new IOException("RAR command interrupted", e);
}
if (!Files.exists(rarFile)) {
throw ExceptionUtils.createFileProcessingException(
"RAR archive creation", new IOException("RAR file was not created"));
throw new IOException("RAR file was not created");
}
try (FileInputStream fis = new FileInputStream(rarFile.toFile());
@@ -180,7 +167,7 @@ public class PdfToCbrUtils {
return false;
}
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
String extension = FilenameUtils.getExtension(filename).toLowerCase();
return "pdf".equals(extension);
}
}
@@ -3,7 +3,6 @@ package stirling.software.common.util;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Locale;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -30,7 +29,7 @@ public class PdfToCbzUtils {
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
if (document.getNumberOfPages() == 0) {
throw ExceptionUtils.createPdfNoPages();
throw new IllegalArgumentException("PDF file contains no pages");
}
return createCbzFromPdf(document, dpi);
@@ -39,17 +38,17 @@ public class PdfToCbzUtils {
private static void validatePdfFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw ExceptionUtils.createFileNullOrEmptyException();
throw new IllegalArgumentException("File cannot be null or empty");
}
String filename = file.getOriginalFilename();
if (filename == null) {
throw ExceptionUtils.createFileNoNameException();
throw new IllegalArgumentException("File must have a name");
}
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
String extension = FilenameUtils.getExtension(filename).toLowerCase();
if (!"pdf".equals(extension)) {
throw ExceptionUtils.createPdfFileRequiredException();
throw new IllegalArgumentException("File must be a PDF");
}
}
@@ -62,31 +61,24 @@ public class PdfToCbzUtils {
int totalPages = document.getNumberOfPages();
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
final int currentPage = pageIndex;
try {
BufferedImage image =
ExceptionUtils.handleOomRendering(
currentPage + 1,
dpi,
() ->
pdfRenderer.renderImageWithDPI(
currentPage, dpi, ImageType.RGB));
pdfRenderer.renderImageWithDPI(pageIndex, dpi, ImageType.RGB);
String imageFilename = String.format("page_%03d.png", pageIndex + 1);
String imageFilename =
String.format(Locale.ROOT, "page_%03d.png", currentPage + 1);
ZipEntry zipEntry = new ZipEntry(imageFilename);
zipOut.putNextEntry(zipEntry);
ImageIO.write(image, "PNG", zipOut);
zipOut.closeEntry();
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
// Re-throw OOM exceptions without wrapping
throw e;
} catch (IOException e) {
// Wrap other IOExceptions with context
throw ExceptionUtils.createFileProcessingException(
"CBZ creation for page " + (currentPage + 1), e);
log.warn("Error processing page {}: {}", pageIndex + 1, e.getMessage());
} catch (OutOfMemoryError e) {
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
} catch (NegativeArraySizeException e) {
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
}
}
@@ -101,7 +93,7 @@ public class PdfToCbzUtils {
return false;
}
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
String extension = FilenameUtils.getExtension(filename).toLowerCase();
return "pdf".equals(extension);
}
}
@@ -8,7 +8,6 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
@@ -51,18 +50,36 @@ public class PdfUtils {
public PDRectangle textToPageSize(String size) {
return switch (size.toUpperCase(Locale.ROOT)) {
case "A0" -> PDRectangle.A0;
case "A1" -> PDRectangle.A1;
case "A2" -> PDRectangle.A2;
case "A3" -> PDRectangle.A3;
case "A4" -> PDRectangle.A4;
case "A5" -> PDRectangle.A5;
case "A6" -> PDRectangle.A6;
case "LETTER" -> PDRectangle.LETTER;
case "LEGAL" -> PDRectangle.LEGAL;
switch (size.toUpperCase()) {
case "A0" -> {
return PDRectangle.A0;
}
case "A1" -> {
return PDRectangle.A1;
}
case "A2" -> {
return PDRectangle.A2;
}
case "A3" -> {
return PDRectangle.A3;
}
case "A4" -> {
return PDRectangle.A4;
}
case "A5" -> {
return PDRectangle.A5;
}
case "A6" -> {
return PDRectangle.A6;
}
case "LETTER" -> {
return PDRectangle.LETTER;
}
case "LEGAL" -> {
return PDRectangle.LEGAL;
}
default -> throw ExceptionUtils.createInvalidPageSizeException(size);
};
}
}
public List<RenderedImage> getAllImages(PDResources resources) throws IOException {
@@ -165,8 +182,8 @@ public class PdfUtils {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
if (singleImage) {
if ("tiff".equals(imageType.toLowerCase(Locale.ROOT))
|| "tif".equals(imageType.toLowerCase(Locale.ROOT))) {
if ("tiff".equals(imageType.toLowerCase())
|| "tif".equals(imageType.toLowerCase())) {
// Write the images to the output stream as a TIFF with multiple frames
ImageWriter writer = ImageIO.getImageWritersByFormatName("tiff").next();
ImageWriteParam param = writer.getDefaultWriteParam();
@@ -179,20 +196,9 @@ public class PdfUtils {
writer.prepareWriteSequence(null);
for (int i = 0; i < pageCount; ++i) {
final int pageIndex = i;
BufferedImage image;
try {
// Validate dimensions before rendering
ExceptionUtils.validateRenderingDimensions(
document.getPage(pageIndex), pageIndex + 1, DPI);
image =
ExceptionUtils.handleOomRendering(
pageIndex + 1,
DPI,
() ->
pdfRenderer.renderImageWithDPI(
pageIndex, DPI, colorType));
image = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage()
@@ -206,6 +212,10 @@ public class PdfUtils {
DPI);
}
throw e;
} catch (OutOfMemoryError e) {
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
} catch (NegativeArraySizeException e) {
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
}
writer.writeToSequence(new IIOImage(image, null, null), param);
}
@@ -229,7 +239,6 @@ public class PdfUtils {
HashMap<PdfRenderSettingsKey, PdfImageDimensionValue> pageSizes =
new HashMap<>();
for (int i = 0; i < pageCount; ++i) {
final int pageIndex = i;
PDPage page = document.getPage(i);
PDRectangle mediaBox = page.getMediaBox();
int rotation = page.getRotation();
@@ -240,17 +249,7 @@ public class PdfUtils {
if (dimension == null) {
// Render the image to get the dimensions
try {
// Validate dimensions before rendering
ExceptionUtils.validateRenderingDimensions(
page, pageIndex + 1, DPI);
pdfSizeImage =
ExceptionUtils.handleOomRendering(
pageIndex + 1,
DPI,
() ->
pdfRenderer.renderImageWithDPI(
pageIndex, DPI, colorType));
pdfSizeImage = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage()
@@ -265,6 +264,10 @@ public class PdfUtils {
DPI);
}
throw e;
} catch (OutOfMemoryError e) {
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
} catch (NegativeArraySizeException e) {
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
}
pdfSizeImageIndex = i;
dimension =
@@ -290,22 +293,11 @@ public class PdfUtils {
boolean firstImageAlreadyRendered = pdfSizeImageIndex == 0;
for (int i = 0; i < pageCount; ++i) {
final int pageIndex = i;
if (firstImageAlreadyRendered && i == 0) {
pageImage = pdfSizeImage;
} else {
try {
// Validate dimensions before rendering
ExceptionUtils.validateRenderingDimensions(
document.getPage(pageIndex), pageIndex + 1, DPI);
pageImage =
ExceptionUtils.handleOomRendering(
pageIndex + 1,
DPI,
() ->
pdfRenderer.renderImageWithDPI(
pageIndex, DPI, colorType));
pageImage = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage()
@@ -319,6 +311,10 @@ public class PdfUtils {
DPI);
}
throw e;
} catch (OutOfMemoryError e) {
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
} catch (NegativeArraySizeException e) {
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
}
}
@@ -339,20 +335,9 @@ public class PdfUtils {
// Zip the images and return as byte array
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
for (int i = 0; i < pageCount; ++i) {
final int pageIndex = i;
BufferedImage image;
try {
// Validate dimensions before rendering
ExceptionUtils.validateRenderingDimensions(
document.getPage(pageIndex), pageIndex + 1, DPI);
image =
ExceptionUtils.handleOomRendering(
pageIndex + 1,
DPI,
() ->
pdfRenderer.renderImageWithDPI(
pageIndex, DPI, colorType));
image = pdfRenderer.renderImageWithDPI(i, DPI, colorType);
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage().contains("Maximum size of image exceeded")) {
@@ -364,6 +349,10 @@ public class PdfUtils {
DPI);
}
throw e;
} catch (OutOfMemoryError e) {
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
} catch (NegativeArraySizeException e) {
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
}
try (ByteArrayOutputStream baosImage = new ByteArrayOutputStream()) {
ImageIO.write(image, imageType, baosImage);
@@ -372,10 +361,9 @@ public class PdfUtils {
zos.putNextEntry(
new ZipEntry(
String.format(
Locale.ROOT,
filename + "_%d.%s",
i + 1,
imageType.toLowerCase(Locale.ROOT))));
imageType.toLowerCase())));
zos.write(baosImage.toByteArray());
}
}
@@ -403,7 +391,6 @@ public class PdfUtils {
PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true);
for (int page = 0; page < document.getNumberOfPages(); ++page) {
final int pageIndex = page;
BufferedImage bim;
// Use global maximum DPI setting, fallback to 300 if not set
@@ -413,16 +400,9 @@ public class PdfUtils {
if (properties != null && properties.getSystem() != null) {
renderDpi = properties.getSystem().getMaxDPI();
}
final int dpi = renderDpi;
try {
bim =
ExceptionUtils.handleOomRendering(
pageIndex + 1,
dpi,
() ->
pdfRenderer.renderImageWithDPI(
pageIndex, dpi, ImageType.RGB));
bim = pdfRenderer.renderImageWithDPI(page, renderDpi, ImageType.RGB);
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage().contains("Maximum size of image exceeded")) {
@@ -431,9 +411,13 @@ public class PdfUtils {
"PDF page {0} is too large to render at 300 DPI. The resulting image"
+ " would exceed Java's maximum array size. Please use a lower DPI"
+ " value for PDF-to-image conversion.",
pageIndex + 1);
page + 1);
}
throw e;
} catch (OutOfMemoryError e) {
throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, 300, e);
} catch (NegativeArraySizeException e) {
throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, 300, e);
}
PDPage originalPage = document.getPage(page);
@@ -479,8 +463,8 @@ public class PdfUtils {
String contentType = file.getContentType();
String originalFilename = Filenames.toSimpleFileName(file.getOriginalFilename());
if (originalFilename != null
&& (originalFilename.toLowerCase(Locale.ROOT).endsWith(".tiff")
|| originalFilename.toLowerCase(Locale.ROOT).endsWith(".tif"))) {
&& (originalFilename.toLowerCase().endsWith(".tiff")
|| originalFilename.toLowerCase().endsWith(".tif"))) {
ImageReader reader = ImageIO.getImageReadersByFormatName("tiff").next();
reader.setInput(ImageIO.createImageInputStream(file.getInputStream()));
int numPages = reader.getNumImages(true);
@@ -583,7 +567,7 @@ public class PdfUtils {
PDImageXObject image = PDImageXObject.createFromByteArray(document, imageBytes, "");
// Draw the image onto the page at the specified x and y coordinates
contentStream.drawImage(image, x, y);
log.info("Image successfully overlaid onto PDF");
log.info("Image successfully overlayed onto PDF");
if (!everyPage && i == 0) {
break;
}
@@ -647,7 +631,7 @@ public class PdfUtils {
int actualPageCount = pdfDocument.getNumberOfPages();
pdfDocument.close();
return switch (comparator.toLowerCase(Locale.ROOT)) {
return switch (comparator.toLowerCase()) {
case "greater" -> actualPageCount > pageCount;
case "equal" -> actualPageCount == pageCount;
case "less" -> actualPageCount < pageCount;
@@ -102,11 +102,6 @@ public class ProcessExecutor {
.getSessionLimit()
.getOcrMyPdfSessionLimit();
case CFF_CONVERTER -> 1;
case FFMPEG ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getFfmpegSessionLimit();
};
long timeoutMinutes =
@@ -167,11 +162,6 @@ public class ProcessExecutor {
.getTimeoutMinutes()
.getOcrMyPdfTimeoutMinutes();
case CFF_CONVERTER -> 5L;
case FFMPEG ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getFfmpegTimeoutMinutes();
};
return new ProcessExecutor(semaphoreLimit, liveUpdates, timeoutMinutes);
});
@@ -326,8 +316,7 @@ public class ProcessExecutor {
QPDF,
GHOSTSCRIPT,
OCR_MY_PDF,
CFF_CONVERTER,
FFMPEG
CFF_CONVERTER
}
@Setter
@@ -325,11 +325,6 @@ public final class RegexPatternUtils {
return getPattern("\\+");
}
/** Pattern for splitting on pipe delimiter (used for hint lists in i18n messages) */
public Pattern getPipeDelimiterPattern() {
return getPattern("\\|");
}
/** Pattern for username validation */
public Pattern getUsernameValidationPattern() {
return getPattern("^[a-zA-Z0-9](?!.*[-@._+]{2,})[a-zA-Z0-9@._+-]{1,48}[a-zA-Z0-9]$");
@@ -572,16 +567,6 @@ public final class RegexPatternUtils {
return getPattern("^[a-zA-Z0-9]{2,4}$", Pattern.CASE_INSENSITIVE);
}
/** Pattern for splitting on line breaks (Unicode line separator) */
public Pattern getLineSeparatorPattern() {
return getPattern("\\R");
}
/** Pattern for removing leading asterisks and whitespace */
public Pattern getLeadingAsterisksWhitespacePattern() {
return getPattern("^[*\\s]+");
}
private record PatternKey(String regex, int flags) {
// Record automatically provides equals, hashCode, and toString
}
@@ -52,11 +52,6 @@ public class RequestUriUtils {
return true;
}
// Mobile scanner page for QR code-based file uploads (peer-to-peer, no backend auth needed)
if (normalizedUri.startsWith("/mobile-scanner")) {
return true;
}
// Treat common static file extensions as static resources
return normalizedUri.endsWith(".svg")
|| normalizedUri.endsWith(".png")
@@ -173,8 +168,6 @@ public class RequestUriUtils {
"/api/v1/ui-data/footer-info") // Public footer configuration
|| trimmedUri.startsWith("/api/v1/invite/validate")
|| trimmedUri.startsWith("/api/v1/invite/accept")
|| trimmedUri.startsWith(
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|| trimmedUri.startsWith("/v1/api-docs");
}
@@ -28,17 +28,6 @@ public class TempFileManager {
private final TempFileRegistry registry;
private final ApplicationProperties applicationProperties;
/**
* Create a managed temporary file that will be tracked by the TempFileManager.
*
* @param suffix The suffix for the temporary file
* @return The created temporary file wrapper
* @throws IOException If an I/O error occurs
*/
public TempFile createManagedTempFile(String suffix) throws IOException {
return new TempFile(this, suffix);
}
/**
* Create a temporary file with the Stirling-PDF prefix. The file is automatically registered
* with the registry.
@@ -141,6 +130,7 @@ public class TempFileManager {
return deleted;
} catch (IOException e) {
log.warn("Failed to delete temp file: {}", path.toString(), e);
return false;
}
}
return false;
@@ -0,0 +1,14 @@
package stirling.software.common.util;
import java.util.Collection;
public class ValidationUtil {
public static boolean isStringEmpty(String input) {
return input == null || input.isBlank();
}
public static boolean isCollectionEmpty(Collection<String> input) {
return input == null || input.isEmpty();
}
}
@@ -32,76 +32,85 @@ public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
@Override
public InputStreamResource replace() throws IOException {
try (TempFile tempFile =
new TempFile(
Files.createTempFile("temp", getFileInput().getOriginalFilename())
.toFile())) {
File file = null;
try {
// Create a temporary file, with the original filename from the multipart file
file = Files.createTempFile("temp", getFileInput().getOriginalFilename()).toFile();
// Transfer the content of the multipart file to the file
getFileInput().transferTo(tempFile.getFile());
getFileInput().transferTo(file);
// Load the uploaded PDF
try (PDDocument document = Loader.loadPDF(tempFile.getFile())) {
// Render each page and invert colors
PDFRenderer pdfRenderer = new PDFRenderer(document);
for (int page = 0; page < document.getNumberOfPages(); page++) {
BufferedImage image;
PDDocument document = Loader.loadPDF(file);
// Use global maximum DPI setting, fallback to 300 if not set
int renderDpi = 300; // Default fallback
ApplicationProperties properties =
ApplicationContextProvider.getBean(ApplicationProperties.class);
if (properties != null && properties.getSystem() != null) {
renderDpi = properties.getSystem().getMaxDPI();
}
final int dpi = renderDpi;
final int pageNum = page;
// Render each page and invert colors
PDFRenderer pdfRenderer = new PDFRenderer(document);
for (int page = 0; page < document.getNumberOfPages(); page++) {
BufferedImage image;
image =
ExceptionUtils.handleOomRendering(
pageNum + 1,
dpi,
() -> pdfRenderer.renderImageWithDPI(pageNum, dpi));
// Invert the colors
invertImageColors(image);
// Create a new PDPage from the inverted image
PDPage pdPage = document.getPage(page);
File tempImageFile = null;
try {
tempImageFile = convertToBufferedImageTpFile(image);
PDImageXObject pdImage =
PDImageXObject.createFromFileByContent(tempImageFile, document);
try (PDPageContentStream contentStream =
new PDPageContentStream(
document,
pdPage,
PDPageContentStream.AppendMode.OVERWRITE,
true)) {
contentStream.drawImage(
pdImage,
0,
0,
pdPage.getMediaBox().getWidth(),
pdPage.getMediaBox().getHeight());
}
} finally {
if (tempImageFile != null && tempImageFile.exists()) {
Files.delete(tempImageFile.toPath());
}
}
// Use global maximum DPI setting, fallback to 300 if not set
int renderDpi = 300; // Default fallback
ApplicationProperties properties =
ApplicationContextProvider.getBean(ApplicationProperties.class);
if (properties != null && properties.getSystem() != null) {
renderDpi = properties.getSystem().getMaxDPI();
}
// Save the modified PDF to a ByteArrayOutputStream
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
document.save(byteArrayOutputStream);
try {
image =
pdfRenderer.renderImageWithDPI(
page, renderDpi); // Render page with global DPI setting
} catch (OutOfMemoryError e) {
throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, renderDpi, e);
} catch (NegativeArraySizeException e) {
throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, renderDpi, e);
}
// Prepare the modified PDF for download
ByteArrayInputStream inputStream =
new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
InputStreamResource resource = new InputStreamResource(inputStream);
return resource;
// Invert the colors
invertImageColors(image);
// Create a new PDPage from the inverted image
PDPage pdPage = document.getPage(page);
File tempImageFile = null;
try {
tempImageFile = convertToBufferedImageTpFile(image);
PDImageXObject pdImage =
PDImageXObject.createFromFileByContent(tempImageFile, document);
PDPageContentStream contentStream =
new PDPageContentStream(
document,
pdPage,
PDPageContentStream.AppendMode.OVERWRITE,
true);
contentStream.drawImage(
pdImage,
0,
0,
pdPage.getMediaBox().getWidth(),
pdPage.getMediaBox().getHeight());
contentStream.close();
} finally {
if (tempImageFile != null && tempImageFile.exists()) {
Files.delete(tempImageFile.toPath());
}
}
}
// Save the modified PDF to a ByteArrayOutputStream
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
document.save(byteArrayOutputStream);
document.close();
// Prepare the modified PDF for download
ByteArrayInputStream inputStream =
new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
InputStreamResource resource = new InputStreamResource(inputStream);
return resource;
} finally {
if (file != null && file.exists()) {
Files.delete(file.toPath());
}
}
}
@@ -110,20 +119,18 @@ public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
private void invertImageColors(BufferedImage image) {
int width = image.getWidth();
int height = image.getHeight();
int[] pixels = new int[width * height];
image.getRGB(0, 0, width, height, pixels, 0, width);
for (int i = 0; i < pixels.length; i++) {
int pixel = pixels[i];
int a = 0xff;
int r = (pixel >> 16) & 0xff;
int g = (pixel >> 8) & 0xff;
int b = pixel & 0xff;
pixels[i] = (a << 24) | ((255 - r) << 16) | ((255 - g) << 8) | (255 - b);
for (int x = 0; x < width; x++) {
for (int y = 0; y < height; y++) {
int rgba = image.getRGB(x, y);
Color color = new Color(rgba, true);
Color invertedColor =
new Color(
255 - color.getRed(),
255 - color.getGreen(),
255 - color.getBlue());
image.setRGB(x, y, invertedColor.getRGB());
}
}
image.setRGB(0, 0, width, height, pixels, 0, width);
}
// Helper method to convert BufferedImage to InputStream
@@ -132,23 +139,4 @@ public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
ImageIO.write(image, "png", file);
return file;
}
private static class TempFile implements AutoCloseable {
private final File file;
public TempFile(File file) {
this.file = file;
}
public File getFile() {
return file;
}
@Override
public void close() throws IOException {
if (file != null && file.exists()) {
Files.delete(file.toPath());
}
}
}
}
@@ -1,6 +1,8 @@
package stirling.software.common.annotations;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
@@ -29,6 +31,8 @@ import stirling.software.common.aop.AutoJobAspect;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.JobExecutorService;
import stirling.software.common.service.JobQueue;
import stirling.software.common.service.ResourceMonitor;
@ExtendWith(MockitoExtension.class)
class AutoJobPostMappingIntegrationTest {
@@ -41,6 +45,10 @@ class AutoJobPostMappingIntegrationTest {
@Mock private FileStorage fileStorage;
@Mock private ResourceMonitor resourceMonitor;
@Mock private JobQueue jobQueue;
@BeforeEach
void setUp() {
autoJobAspect = new AutoJobAspect(jobExecutorService, request, fileStorage);
@@ -65,7 +73,7 @@ class AutoJobPostMappingIntegrationTest {
// Given
PDFFile pdfFile = new PDFFile();
pdfFile.setFileId("test-file-id");
Object[] args = {pdfFile};
Object[] args = new Object[] {pdfFile};
when(joinPoint.getArgs()).thenReturn(args);
when(request.getParameter("async")).thenReturn("true");
@@ -145,7 +153,7 @@ class AutoJobPostMappingIntegrationTest {
// Given
PDFFile pdfFile = new PDFFile();
pdfFile.setFileInput(mock(MultipartFile.class));
Object[] args = {pdfFile};
Object[] args = new Object[] {pdfFile};
when(joinPoint.getArgs()).thenReturn(args);
when(request.getParameter("async")).thenReturn("true");
@@ -1,17 +0,0 @@
package stirling.software.common.configuration.interfaces;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class ShowAdminInterfaceTest {
// Create a simple implementation for testing
static class TestImpl implements ShowAdminInterface {}
@Test
void getShowUpdateOnlyAdmins_returnsTrueByDefault() {
ShowAdminInterface instance = new TestImpl();
assertTrue(instance.getShowUpdateOnlyAdmins(), "Default should return true");
}
}
@@ -18,17 +18,17 @@ class ApplicationPropertiesDynamicYamlPropertySourceTest {
@Test
void loads_yaml_into_environment() throws Exception {
// YAML-Config in Temp-Datei schreiben
String yaml =
"""
\
ui:
appName: "My App"
system:
enableAnalytics: true
""";
""
+ "ui:\n"
+ " appName: \"My App\"\n"
+ "system:\n"
+ " enableAnalytics: true\n";
Path tmp = Files.createTempFile("spdf-settings-", ".yml");
Files.writeString(tmp, yaml);
// Pfad per statischem Mock liefern
try (MockedStatic<InstallationPathConfig> mocked =
Mockito.mockStatic(InstallationPathConfig.class)) {
mocked.when(InstallationPathConfig::getSettingsPath).thenReturn(tmp.toString());
@@ -36,7 +36,7 @@ class ApplicationPropertiesDynamicYamlPropertySourceTest {
ConfigurableEnvironment env = new StandardEnvironment();
ApplicationProperties props = new ApplicationProperties();
props.dynamicYamlPropertySource(env);
props.dynamicYamlPropertySource(env); // fügt PropertySource an erster Stelle ein
assertEquals("My App", env.getProperty("ui.appName"));
assertEquals("true", env.getProperty("system.enableAnalytics"));
@@ -44,7 +44,7 @@ class ApplicationPropertiesDynamicYamlPropertySourceTest {
}
@Test
void throws_when_settings_file_missing() {
void throws_when_settings_file_missing() throws Exception {
String missing = "/path/does/not/exist/spdf.yml";
try (MockedStatic<InstallationPathConfig> mocked =
Mockito.mockStatic(InstallationPathConfig.class)) {
@@ -61,7 +61,7 @@ class ApplicationPropertiesSaml2HttpTest {
Resource r = s.getSpCert();
assertNotNull(r);
assertInstanceOf(FileSystemResource.class, r, "Expected FileSystemResource for FS path");
assertTrue(r instanceof FileSystemResource, "Expected FileSystemResource for FS path");
assertTrue(r.exists(), "Temp file should exist");
}
@@ -75,7 +75,7 @@ class ApplicationPropertiesSaml2HttpTest {
Resource r = s.getIdpCert();
assertNotNull(r);
assertInstanceOf(FileSystemResource.class, r, "Expected FileSystemResource for FS path");
assertTrue(r instanceof FileSystemResource, "Expected FileSystemResource for FS path");
assertFalse(r.exists(), "Resource should not exist for missing file");
}
}
@@ -1,20 +1,16 @@
package stirling.software.common.model;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.file.Path;
import java.io.File;
import java.time.LocalDateTime;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
public class FileInfoTest {
private static final LocalDateTime FIXED_NOW = LocalDateTime.of(2025, 11, 1, 12, 0, 0);
@ParameterizedTest(name = "{index}: fileSize={0}")
@CsvSource({
"0, '0 Bytes'",
@@ -29,146 +25,87 @@ public class FileInfoTest {
FileInfo fileInfo =
new FileInfo(
"example.txt",
"/path/to/example.txt",
FIXED_NOW,
File.separator
+ "path"
+ File.separator
+ "to"
+ File.separator
+ "example.txt",
LocalDateTime.now(),
fileSize,
FIXED_NOW.minusDays(1));
LocalDateTime.now().minusDays(1));
assertEquals(expectedFormattedSize, fileInfo.getFormattedFileSize());
}
@Nested
@DisplayName("getFilePathAsPath")
class GetFilePathAsPathTests {
@Test
@DisplayName("Should convert filePath string into a Path instance")
void shouldConvertStringToPath() {
FileInfo fi =
new FileInfo(
"example.txt",
"/path/to/example.txt",
FIXED_NOW,
123,
FIXED_NOW.minusDays(1));
Path path = fi.getFilePathAsPath();
// Basic sanity checks
assertNotNull(path, "Path should not be null");
assertEquals(
Path.of("/path/to/example.txt"),
path,
"Converted Path should match input string");
}
@Test
void testGetFilePathAsPath() {
FileInfo fileInfo =
new FileInfo(
"test.pdf",
File.separator + "tmp" + File.separator + "test.pdf",
LocalDateTime.now(),
1234,
LocalDateTime.now().minusDays(2));
assertEquals(
File.separator + "tmp" + File.separator + "test.pdf",
fileInfo.getFilePathAsPath().toString());
}
@Nested
@DisplayName("Date formatting")
class DateFormattingTests {
@Test
@DisplayName("Should format modificationDate as 'yyyy-MM-dd HH:mm:ss'")
void shouldFormatModificationDate() {
LocalDateTime mod = LocalDateTime.of(2025, 8, 10, 15, 30, 45);
FileInfo fi =
new FileInfo(
"example.txt",
"/path/to/example.txt",
mod,
1,
LocalDateTime.of(2024, 1, 1, 0, 0, 0));
assertEquals("2025-08-10 15:30:45", fi.getFormattedModificationDate());
}
@Test
@DisplayName("Should format creationDate as 'yyyy-MM-dd HH:mm:ss'")
void shouldFormatCreationDate() {
LocalDateTime created = LocalDateTime.of(2024, 12, 31, 23, 59, 59);
FileInfo fi =
new FileInfo(
"example.txt",
"/path/to/example.txt",
LocalDateTime.of(2025, 1, 1, 0, 0, 0),
1,
created);
assertEquals("2024-12-31 23:59:59", fi.getFormattedCreationDate());
}
@Test
@DisplayName("Should throw NPE when modificationDate is null (current behavior)")
void shouldThrowWhenModificationDateNull() {
// Assumption: Current implementation does not guard null -> NPE is expected.
FileInfo fi =
new FileInfo(
"example.txt",
"/path/to/example.txt",
null, // modificationDate null
1,
FIXED_NOW);
assertThrows(
NullPointerException.class,
fi::getFormattedModificationDate,
"Formatting a null modificationDate should throw NPE with current"
+ " implementation");
}
@Test
@DisplayName("Should throw NPE when creationDate is null (current behavior)")
void shouldThrowWhenCreationDateNull() {
// Assumption: Current implementation does not guard null -> NPE is expected.
FileInfo fi =
new FileInfo(
"example.txt",
"/path/to/example.txt",
FIXED_NOW,
1,
null); // creationDate null
assertThrows(
NullPointerException.class,
fi::getFormattedCreationDate,
"Formatting a null creationDate should throw NPE with current implementation");
}
@Test
void testGetFormattedModificationDate() {
LocalDateTime modDate = LocalDateTime.of(2024, 6, 1, 15, 30, 45);
FileInfo fileInfo =
new FileInfo(
"file.txt",
File.separator + "file.txt",
modDate,
100,
LocalDateTime.of(2024, 5, 31, 10, 0, 0));
assertEquals("2024-06-01 15:30:45", fileInfo.getFormattedModificationDate());
}
@Nested
@DisplayName("Additional size formatting cases")
class AdditionalSizeFormattingTests {
@Test
void testGetFormattedCreationDate() {
LocalDateTime creationDate = LocalDateTime.of(2023, 12, 25, 8, 15, 0);
FileInfo fileInfo =
new FileInfo(
"holiday.txt",
File.separator + "holiday.txt",
LocalDateTime.of(2024, 1, 1, 0, 0, 0),
500,
creationDate);
assertEquals("2023-12-25 08:15:00", fileInfo.getFormattedCreationDate());
}
@Test
@DisplayName("Should round to two decimals for KB (e.g., 1536 B -> 1.50 KB)")
void shouldRoundKbToTwoDecimals() {
FileInfo fi =
new FileInfo(
"example.txt",
"/path/to/example.txt",
FIXED_NOW,
1536, // 1.5 KB
FIXED_NOW.minusDays(1));
@Test
void testGettersAndSetters() {
LocalDateTime now = LocalDateTime.now();
FileInfo fileInfo =
new FileInfo(
"doc.pdf",
File.separator + "docs" + File.separator + "doc.pdf",
now,
2048,
now.minusDays(1));
// Test getters
assertEquals("doc.pdf", fileInfo.getFileName());
assertEquals(File.separator + "docs" + File.separator + "doc.pdf", fileInfo.getFilePath());
assertEquals(now, fileInfo.getModificationDate());
assertEquals(2048, fileInfo.getFileSize());
assertEquals(now.minusDays(1), fileInfo.getCreationDate());
assertEquals("1.50 KB", fi.getFormattedFileSize());
}
// Test setters
fileInfo.setFileName("new.pdf");
fileInfo.setFilePath(File.separator + "new" + File.separator + "new.pdf");
fileInfo.setModificationDate(now.plusDays(1));
fileInfo.setFileSize(4096);
fileInfo.setCreationDate(now.minusDays(2));
@Test
@DisplayName("Values above 1 TB are still represented in GB (design choice)")
void shouldRepresentTerabytesInGb() {
// 2 TB = 2 * 1024 GB -> 2 * 1024 * 1024^3 bytes
long twoTB = 2L * 1024 * 1024 * 1024 * 1024; // 2 * 2^40
FileInfo fi =
new FileInfo(
"example.txt",
"/path/to/example.txt",
FIXED_NOW,
twoTB,
FIXED_NOW.minusDays(1));
// 2 TB equals 2048.00 GB with current implementation
assertEquals(
"2048.00 GB",
fi.getFormattedFileSize(),
"Current implementation caps at GB and shows TB in GB units");
}
assertEquals("new.pdf", fileInfo.getFileName());
assertEquals(File.separator + "new" + File.separator + "new.pdf", fileInfo.getFilePath());
assertEquals(now.plusDays(1), fileInfo.getModificationDate());
assertEquals(4096, fileInfo.getFileSize());
assertEquals(now.minusDays(2), fileInfo.getCreationDate());
}
}
@@ -1,34 +0,0 @@
package stirling.software.common.model.exception;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@DisplayName("Tests for UnsupportedClaimException")
class UnsupportedClaimExceptionTest {
@Test
@DisplayName("should store message passed to constructor")
void shouldStoreMessageFromConstructor() {
String expectedMessage = "This claim is not supported";
UnsupportedClaimException exception = new UnsupportedClaimException(expectedMessage);
// Verify the stored message
assertEquals(
expectedMessage,
exception.getMessage(),
"Constructor should correctly store the provided message");
}
@Test
@DisplayName("should allow null message without throwing exception")
void shouldAllowNullMessage() {
UnsupportedClaimException exception = new UnsupportedClaimException(null);
// Null message should be stored as null
assertNull(
exception.getMessage(),
"Constructor should accept null message and store it as null");
}
}
@@ -1,6 +1,7 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static stirling.software.common.service.SpyPDFDocumentFactory.*;
@@ -21,6 +22,7 @@ import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.SpyPDFDocumentFactory.StrategyType;
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@@ -41,7 +43,58 @@ class CustomPDFDocumentFactoryTest {
}
}
private static byte[] inflatePdf(byte[] input, int sizeInMB) throws IOException {
@ParameterizedTest
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
void testStrategy_FileInput(int sizeMB, StrategyType expected) throws IOException {
File file = writeTempFile(inflatePdf(basePdfBytes, sizeMB));
try (PDDocument doc = factory.load(file)) {
Assertions.assertEquals(expected, factory.lastStrategyUsed);
}
}
@ParameterizedTest
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
void testStrategy_ByteArray(int sizeMB, StrategyType expected) throws IOException {
byte[] inflated = inflatePdf(basePdfBytes, sizeMB);
try (PDDocument doc = factory.load(inflated)) {
Assertions.assertEquals(expected, factory.lastStrategyUsed);
}
}
@ParameterizedTest
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
void testStrategy_InputStream(int sizeMB, StrategyType expected) throws IOException {
byte[] inflated = inflatePdf(basePdfBytes, sizeMB);
try (PDDocument doc = factory.load(new ByteArrayInputStream(inflated))) {
Assertions.assertEquals(expected, factory.lastStrategyUsed);
}
}
@ParameterizedTest
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
void testStrategy_MultipartFile(int sizeMB, StrategyType expected) throws IOException {
byte[] inflated = inflatePdf(basePdfBytes, sizeMB);
MockMultipartFile multipart =
new MockMultipartFile("file", "doc.pdf", MediaType.APPLICATION_PDF_VALUE, inflated);
try (PDDocument doc = factory.load(multipart)) {
Assertions.assertEquals(expected, factory.lastStrategyUsed);
}
}
@ParameterizedTest
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
void testStrategy_PDFFile(int sizeMB, StrategyType expected) throws IOException {
byte[] inflated = inflatePdf(basePdfBytes, sizeMB);
MockMultipartFile multipart =
new MockMultipartFile("file", "doc.pdf", MediaType.APPLICATION_PDF_VALUE, inflated);
PDFFile pdfFile = new PDFFile();
pdfFile.setFileInput(multipart);
try (PDDocument doc = factory.load(pdfFile)) {
Assertions.assertEquals(expected, factory.lastStrategyUsed);
}
}
private byte[] inflatePdf(byte[] input, int sizeInMB) throws IOException {
try (PDDocument doc = Loader.loadPDF(input)) {
byte[] largeData = new byte[sizeInMB * 1024 * 1024];
Arrays.fill(largeData, (byte) 'A');
@@ -60,46 +113,6 @@ class CustomPDFDocumentFactoryTest {
}
}
private static File writeTempFile(byte[] content) throws IOException {
File file = Files.createTempFile("pdf-test-", ".pdf").toFile();
Files.write(file.toPath(), content);
return file;
}
@ParameterizedTest
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
void testStrategy_FileInput(int sizeMB, StrategyType expected) throws IOException {
File file = writeTempFile(inflatePdf(basePdfBytes, sizeMB));
factory.load(file);
Assertions.assertEquals(expected, factory.lastStrategyUsed);
}
@ParameterizedTest
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
void testStrategy_ByteArray(int sizeMB, StrategyType expected) throws IOException {
byte[] inflated = inflatePdf(basePdfBytes, sizeMB);
factory.load(inflated);
Assertions.assertEquals(expected, factory.lastStrategyUsed);
}
@ParameterizedTest
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
void testStrategy_InputStream(int sizeMB, StrategyType expected) throws IOException {
byte[] inflated = inflatePdf(basePdfBytes, sizeMB);
factory.load(new ByteArrayInputStream(inflated));
Assertions.assertEquals(expected, factory.lastStrategyUsed);
}
@ParameterizedTest
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
void testStrategy_MultipartFile(int sizeMB, StrategyType expected) throws IOException {
byte[] inflated = inflatePdf(basePdfBytes, sizeMB);
MockMultipartFile multipart =
new MockMultipartFile("file", "doc.pdf", MediaType.APPLICATION_PDF_VALUE, inflated);
factory.load(multipart);
Assertions.assertEquals(expected, factory.lastStrategyUsed);
}
@Test
void testLoadFromPath() throws IOException {
File file = writeTempFile(inflatePdf(basePdfBytes, 5));
@@ -117,7 +130,7 @@ class CustomPDFDocumentFactoryTest {
}
}
// need to add password pdf
// neeed to add password pdf
// @Test
// void testLoadPasswordProtectedPdfFromInputStream() throws IOException {
// try (InputStream is = getClass().getResourceAsStream("/protected.pdf")) {
@@ -199,16 +212,10 @@ class CustomPDFDocumentFactoryTest {
assertTrue(newBytes.length > 0);
}
@ParameterizedTest
@CsvSource({"5,MEMORY_ONLY", "20,MIXED", "60,TEMP_FILE"})
void testStrategy_PDFFile(int sizeMB, StrategyType expected) throws IOException {
byte[] inflated = inflatePdf(basePdfBytes, sizeMB);
MockMultipartFile multipart =
new MockMultipartFile("file", "doc.pdf", MediaType.APPLICATION_PDF_VALUE, inflated);
PDFFile pdfFile = new PDFFile();
pdfFile.setFileInput(multipart);
factory.load(pdfFile);
Assertions.assertEquals(expected, factory.lastStrategyUsed);
private File writeTempFile(byte[] content) throws IOException {
File file = Files.createTempFile("pdf-test-", ".pdf").toFile();
Files.write(file.toPath(), content);
return file;
}
@BeforeEach
@@ -1,11 +1,15 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.AdditionalAnswers.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -82,7 +86,7 @@ class FileStorageTest {
void testRetrieveFile() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
String fileId = "test-file-1";
String fileId = UUID.randomUUID().toString();
Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent);
@@ -102,7 +106,7 @@ class FileStorageTest {
void testRetrieveBytes() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
String fileId = "test-file-2";
String fileId = UUID.randomUUID().toString();
Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent);
@@ -135,7 +139,7 @@ class FileStorageTest {
void testDeleteFile() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
String fileId = "test-file-3";
String fileId = UUID.randomUUID().toString();
Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent);
@@ -163,7 +167,7 @@ class FileStorageTest {
void testFileExists() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
String fileId = "test-file-4";
String fileId = UUID.randomUUID().toString();
Path filePath = tempDir.resolve(fileId);
Files.write(filePath, fileContent);
@@ -1,7 +1,6 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -106,7 +105,7 @@ class JobExecutorServiceTest {
// Then
assertEquals(HttpStatus.OK, response.getStatusCode());
assertInstanceOf(JobResponse.class, response.getBody());
assertTrue(response.getBody() instanceof JobResponse);
JobResponse<?> jobResponse = (JobResponse<?>) response.getBody();
assertTrue(jobResponse.isAsync());
assertNotNull(jobResponse.getJobId());
@@ -135,7 +134,7 @@ class JobExecutorServiceTest {
}
@Test
void shouldQueueJobWhenResourcesLimited() throws Exception {
void shouldQueueJobWhenResourcesLimited() {
// Given
Supplier<Object> work = () -> "test-result";
CompletableFuture<ResponseEntity<?>> future = new CompletableFuture<>();
@@ -151,7 +150,7 @@ class JobExecutorServiceTest {
// Then
assertEquals(HttpStatus.OK, response.getStatusCode());
assertInstanceOf(JobResponse.class, response.getBody());
assertTrue(response.getBody() instanceof JobResponse);
// Verify job was queued
verify(jobQueue).queueJob(anyString(), eq(80), any(), eq(5000L));
@@ -185,13 +184,13 @@ class JobExecutorServiceTest {
// Given
Supplier<Object> work =
() -> {
// Simulate long-running job without actual sleep
// Use a loop to consume time instead of Thread.sleep
long startTime = System.nanoTime();
while (System.nanoTime() - startTime < 100_000_000) { // 100ms in nanoseconds
// Busy wait to simulate work without Thread.sleep
try {
Thread.sleep(100); // Simulate long-running job
return "test-result";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
return "test-result";
};
// Use reflection to access the private executeWithTimeout method
@@ -204,7 +203,7 @@ class JobExecutorServiceTest {
try {
executeMethod.invoke(jobExecutorService, work, 1L); // Very short timeout
} catch (Exception e) {
assertInstanceOf(TimeoutException.class, e.getCause());
assertTrue(e.getCause() instanceof TimeoutException);
}
}
}
@@ -1,11 +1,12 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.management.MemoryMXBean;
import java.lang.management.OperatingSystemMXBean;
import java.time.Instant;
import java.util.Locale;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.BeforeEach;
@@ -32,11 +33,11 @@ class ResourceMonitorTest {
@Mock private MemoryMXBean memoryMXBean;
@Spy
private final AtomicReference<ResourceStatus> currentStatus =
private AtomicReference<ResourceStatus> currentStatus =
new AtomicReference<>(ResourceStatus.OK);
@Spy
private final AtomicReference<ResourceMetrics> latestMetrics =
private AtomicReference<ResourceMetrics> latestMetrics =
new AtomicReference<>(new ResourceMetrics());
@BeforeEach
@@ -117,24 +118,18 @@ class ResourceMonitorTest {
shouldQueue,
result,
String.format(
Locale.ROOT,
"For weight %d and status %s, shouldQueue should be %s",
weight,
status,
shouldQueue));
weight, status, shouldQueue));
}
@Test
void resourceMetricsShouldDetectStaleState() {
// Capture test time at the beginning for deterministic calculations
final Instant testTime = Instant.now();
// Given
Instant pastInstant =
testTime.minusMillis(6000); // 6 seconds ago (relative to test start time)
Instant now = Instant.now();
Instant pastInstant = now.minusMillis(6000);
ResourceMetrics staleMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, pastInstant);
ResourceMetrics freshMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, testTime);
ResourceMetrics freshMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, now);
// When/Then
assertTrue(
@@ -5,6 +5,7 @@ import static org.mockito.Mockito.*;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -41,7 +42,7 @@ class TaskManagerTest {
@Test
void testCreateTask() {
// Act
String jobId = "test-job-1";
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
// Assert
@@ -55,7 +56,7 @@ class TaskManagerTest {
@Test
void testSetResult() {
// Arrange
String jobId = "test-job-2";
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
Object resultObject = "Test result";
@@ -73,7 +74,7 @@ class TaskManagerTest {
@Test
void testSetFileResult() throws Exception {
// Arrange
String jobId = "test-job-3";
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
String fileId = "file-id";
String originalFileName = "test.pdf";
@@ -107,7 +108,7 @@ class TaskManagerTest {
@Test
void testSetError() {
// Arrange
String jobId = "test-job-4";
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
String errorMessage = "Test error";
@@ -125,7 +126,7 @@ class TaskManagerTest {
@Test
void testSetComplete_WithExistingResult() {
// Arrange
String jobId = "test-job-5";
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
Object resultObject = "Test result";
taskManager.setResult(jobId, resultObject);
@@ -143,7 +144,7 @@ class TaskManagerTest {
@Test
void testSetComplete_WithoutExistingResult() {
// Arrange
String jobId = "test-job-6";
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
// Act
@@ -159,7 +160,7 @@ class TaskManagerTest {
@Test
void testIsComplete() {
// Arrange
String jobId = "test-job-7";
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
// Assert - not complete initially
@@ -214,9 +215,7 @@ class TaskManagerTest {
}
@Test
void testCleanupOldJobs() {
// Capture test time at the beginning for deterministic calculations
final LocalDateTime testTime = LocalDateTime.now();
void testCleanupOldJobs() throws Exception {
// Arrange
// 1. Create a recent completed job
String recentJobId = "recent-job";
@@ -228,9 +227,8 @@ class TaskManagerTest {
taskManager.createTask(oldJobId);
JobResult oldJob = taskManager.getJobResult(oldJobId);
// Manually set the completion time to be older than the expiry (relative to test start
// time)
LocalDateTime oldTime = testTime.minusHours(1);
// Manually set the completion time to be older than the expiry
LocalDateTime oldTime = LocalDateTime.now().minusHours(1);
ReflectionTestUtils.setField(oldJob, "completedAt", oldTime);
ReflectionTestUtils.setField(oldJob, "complete", true);
@@ -255,7 +253,6 @@ class TaskManagerTest {
taskManager.createTask(activeJobId);
// Verify all jobs are in the map
assertNotNull(jobResultsMap);
assertTrue(jobResultsMap.containsKey(recentJobId));
assertTrue(jobResultsMap.containsKey(oldJobId));
assertTrue(jobResultsMap.containsKey(activeJobId));
@@ -271,7 +268,7 @@ class TaskManagerTest {
}
@Test
void testShutdown() {
void testShutdown() throws Exception {
// This mainly tests that the shutdown method doesn't throw exceptions
taskManager.shutdown();
@@ -282,7 +279,7 @@ class TaskManagerTest {
@Test
void testAddNote() {
// Arrange
String jobId = "test-job-8";
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
String note = "Test note";
@@ -1,6 +1,8 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.*;
import java.io.File;
@@ -131,9 +133,6 @@ public class TempFileCleanupServiceTest {
// Use MockedStatic to mock Files operations
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
// Capture test time at the beginning for deterministic calculations
final long testTime = System.currentTimeMillis();
// Mock Files.list for each directory we'll process
mockedFiles
.when(() -> Files.list(eq(systemTempDir)))
@@ -178,17 +177,18 @@ public class TempFileCleanupServiceTest {
// maxAgeMillis
if (fileName.contains("old")) {
return FileTime.fromMillis(
testTime - 5000000); // ~1.4 hours ago
System.currentTimeMillis() - 5000000);
}
// For empty.tmp file, return a timestamp older than 5 minutes (for
// empty file test)
else if ("empty.tmp".equals(fileName)) {
else if (fileName.equals("empty.tmp")) {
return FileTime.fromMillis(
testTime - 6 * 60 * 1000); // 6 minutes ago
System.currentTimeMillis() - 6 * 60 * 1000);
}
// For all other files, return a recent timestamp
else {
return FileTime.fromMillis(testTime - 60000); // 1 minute ago
return FileTime.fromMillis(
System.currentTimeMillis() - 60000); // 1 minute ago
}
});
@@ -201,7 +201,7 @@ public class TempFileCleanupServiceTest {
String fileName = path.getFileName().toString();
// Return 0 bytes for the empty file
if ("empty.tmp".equals(fileName)) {
if (fileName.equals("empty.tmp")) {
return 0L;
}
// Return normal size for all other files
@@ -221,9 +221,9 @@ public class TempFileCleanupServiceTest {
});
// Act - set containerMode to false for this test
invokeCleanupDirectoryStreaming(systemTempDir, false, 3600000);
invokeCleanupDirectoryStreaming(customTempDir, false, 3600000);
invokeCleanupDirectoryStreaming(libreOfficeTempDir, false, 3600000);
invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000);
invokeCleanupDirectoryStreaming(customTempDir, false, 0, 3600000);
invokeCleanupDirectoryStreaming(libreOfficeTempDir, false, 0, 3600000);
// Assert - Only old temp files and empty files should be deleted
assertTrue(deletedFiles.contains(oldTempFile), "Old temp file should be deleted");
@@ -276,9 +276,6 @@ public class TempFileCleanupServiceTest {
// Use MockedStatic to mock Files operations
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
// Capture test time at the beginning for deterministic calculations
final long testTime = System.currentTimeMillis();
// Mock Files.list for systemTempDir
mockedFiles
.when(() -> Files.list(eq(systemTempDir)))
@@ -293,7 +290,9 @@ public class TempFileCleanupServiceTest {
// Configure Files.getLastModifiedTime to return recent timestamps
mockedFiles
.when(() -> Files.getLastModifiedTime(any(Path.class)))
.thenReturn(FileTime.fromMillis(testTime - 60000)); // 1 minute ago
.thenReturn(
FileTime.fromMillis(
System.currentTimeMillis() - 60000)); // 1 minute ago
// Configure Files.size to return normal size
mockedFiles.when(() -> Files.size(any(Path.class))).thenReturn(1024L); // 1 KB
@@ -309,7 +308,7 @@ public class TempFileCleanupServiceTest {
});
// Act - set containerMode to true and maxAgeMillis to 0 for container startup cleanup
invokeCleanupDirectoryStreaming(systemTempDir, true, 0);
invokeCleanupDirectoryStreaming(systemTempDir, true, 0, 0);
// Assert - In container mode, both our temp files and system temp files should be
// deleted
@@ -338,9 +337,6 @@ public class TempFileCleanupServiceTest {
// Use MockedStatic to mock Files operations
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
// Capture test time at the beginning for deterministic calculations
final long testTime = System.currentTimeMillis();
// Mock Files.list for systemTempDir
mockedFiles
.when(() -> Files.list(eq(systemTempDir)))
@@ -360,14 +356,14 @@ public class TempFileCleanupServiceTest {
Path path = invocation.getArgument(0);
String fileName = path.getFileName().toString();
if ("empty.tmp".equals(fileName)) {
if (fileName.equals("empty.tmp")) {
// More than 5 minutes old
return FileTime.fromMillis(
testTime - 6 * 60 * 1000); // 6 minutes ago
System.currentTimeMillis() - 6 * 60 * 1000);
} else {
// Less than 5 minutes old
return FileTime.fromMillis(
testTime - 2 * 60 * 1000); // 2 minutes ago
System.currentTimeMillis() - 2 * 60 * 1000);
}
});
@@ -385,7 +381,7 @@ public class TempFileCleanupServiceTest {
});
// Act
invokeCleanupDirectoryStreaming(systemTempDir, false, 3600000);
invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000);
// Assert
assertTrue(
@@ -416,25 +412,14 @@ public class TempFileCleanupServiceTest {
// Use MockedStatic to mock Files operations
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
// Capture test time at the beginning for deterministic calculations
final long testTime = System.currentTimeMillis();
// Mock Files.list for each directory
mockedFiles
.when(() -> Files.list(eq(systemTempDir)))
.thenAnswer(invocation -> Stream.of(dir1));
mockedFiles.when(() -> Files.list(eq(systemTempDir))).thenReturn(Stream.of(dir1));
mockedFiles
.when(() -> Files.list(eq(dir1)))
.thenAnswer(invocation -> Stream.of(tempFile1, dir2));
mockedFiles.when(() -> Files.list(eq(dir1))).thenReturn(Stream.of(tempFile1, dir2));
mockedFiles
.when(() -> Files.list(eq(dir2)))
.thenAnswer(invocation -> Stream.of(tempFile2, dir3));
mockedFiles.when(() -> Files.list(eq(dir2))).thenReturn(Stream.of(tempFile2, dir3));
mockedFiles
.when(() -> Files.list(eq(dir3)))
.thenAnswer(invocation -> Stream.of(tempFile3));
mockedFiles.when(() -> Files.list(eq(dir3))).thenReturn(Stream.of(tempFile3));
// Configure Files.isDirectory for each path
mockedFiles.when(() -> Files.isDirectory(eq(dir1))).thenReturn(true);
@@ -447,9 +432,6 @@ public class TempFileCleanupServiceTest {
// Configure Files.exists to return true for all paths
mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true);
// Configure Files.size to return 0 for all files (ensure they're not empty)
mockedFiles.when(() -> Files.size(any(Path.class))).thenReturn(1024L);
// Configure Files.getLastModifiedTime to return different times based on file names
mockedFiles
.when(() -> Files.getLastModifiedTime(any(Path.class)))
@@ -459,14 +441,19 @@ public class TempFileCleanupServiceTest {
String fileName = path.getFileName().toString();
if (fileName.contains("old")) {
// Old file - very old timestamp (older than 1 hour)
return FileTime.fromMillis(testTime - 7200000); // 2 hours ago
// Old file
return FileTime.fromMillis(
System.currentTimeMillis() - 5000000);
} else {
// Recent file - very recent timestamp (less than 1 hour)
return FileTime.fromMillis(testTime - 60000); // 1 minute ago
// Recent file
return FileTime.fromMillis(System.currentTimeMillis() - 60000);
}
});
// Configure Files.size to return normal size
mockedFiles.when(() -> Files.size(any(Path.class))).thenReturn(1024L);
// For deleteIfExists, track which files would be deleted
mockedFiles
.when(() -> Files.deleteIfExists(any(Path.class)))
.thenAnswer(
@@ -476,8 +463,12 @@ public class TempFileCleanupServiceTest {
return true;
});
// Act - pass maxAgeMillis = 3600000 (1 hour)
invokeCleanupDirectoryStreaming(systemTempDir, false, 3600000);
// Act
invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000);
// Debug - print what was deleted
System.out.println("Deleted files: " + deletedFiles);
System.out.println("Looking for: " + tempFile3);
// Assert
assertFalse(deletedFiles.contains(tempFile1), "Recent temp file should be preserved");
@@ -490,7 +481,8 @@ public class TempFileCleanupServiceTest {
/** Helper method to invoke the private cleanupDirectoryStreaming method using reflection */
private void invokeCleanupDirectoryStreaming(
Path directory, boolean containerMode, long maxAgeMillis) {
Path directory, boolean containerMode, int depth, long maxAgeMillis)
throws IOException {
try {
// Create a consumer that tracks deleted files
AtomicInteger deleteCount = new AtomicInteger(0);
@@ -513,7 +505,7 @@ public class TempFileCleanupServiceTest {
cleanupService,
directory,
containerMode,
0,
depth,
maxAgeMillis,
false,
deleteCallback);
@@ -1,6 +1,9 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.times;
@@ -23,7 +26,6 @@ class CheckProgramInstallTest {
private MockedStatic<ProcessExecutor> mockProcessExecutor;
private ProcessExecutor mockExecutor;
private ProcessExecutor mockFfmpegExecutor;
@BeforeEach
void setUp() throws Exception {
@@ -32,14 +34,10 @@ class CheckProgramInstallTest {
// Set up mock for ProcessExecutor
mockExecutor = Mockito.mock(ProcessExecutor.class);
mockFfmpegExecutor = Mockito.mock(ProcessExecutor.class);
mockProcessExecutor = mockStatic(ProcessExecutor.class);
mockProcessExecutor
.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV))
.thenReturn(mockExecutor);
mockProcessExecutor
.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.FFMPEG))
.thenReturn(mockFfmpegExecutor);
}
@AfterEach
@@ -51,7 +49,7 @@ class CheckProgramInstallTest {
}
/** Reset static fields in the CheckProgramInstall class using reflection */
private static void resetStaticFields() throws Exception {
private void resetStaticFields() throws Exception {
Field pythonAvailableCheckedField =
CheckProgramInstall.class.getDeclaredField("pythonAvailableChecked");
pythonAvailableCheckedField.setAccessible(true);
@@ -61,15 +59,6 @@ class CheckProgramInstallTest {
CheckProgramInstall.class.getDeclaredField("availablePythonCommand");
availablePythonCommandField.setAccessible(true);
availablePythonCommandField.set(null, null);
Field ffmpegAvailableCheckedField =
CheckProgramInstall.class.getDeclaredField("ffmpegAvailableChecked");
ffmpegAvailableCheckedField.setAccessible(true);
ffmpegAvailableCheckedField.set(null, false);
Field ffmpegAvailableField = CheckProgramInstall.class.getDeclaredField("ffmpegAvailable");
ffmpegAvailableField.setAccessible(true);
ffmpegAvailableField.set(null, false);
}
@Test
@@ -119,7 +108,8 @@ class CheckProgramInstallTest {
}
@Test
void testGetAvailablePythonCommand_WhenPythonReturnsNonZeroExitCode() throws Exception {
void testGetAvailablePythonCommand_WhenPythonReturnsNonZeroExitCode()
throws IOException, InterruptedException, Exception {
// Arrange
// Reset the static fields again to ensure clean state
resetStaticFields();
@@ -215,45 +205,4 @@ class CheckProgramInstallTest {
// Verify getAvailablePythonCommand was called internally
verify(mockExecutor).runCommandWithOutputHandling(Arrays.asList("python3", "--version"));
}
@Test
void testIsFfmpegAvailable_WhenInstalled() throws Exception {
resetStaticFields();
ProcessExecutorResult result = Mockito.mock(ProcessExecutorResult.class);
when(mockFfmpegExecutor.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version")))
.thenReturn(result);
assertTrue(CheckProgramInstall.isFfmpegAvailable());
verify(mockFfmpegExecutor)
.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version"));
}
@Test
void testIsFfmpegAvailable_WhenNotInstalled() throws Exception {
resetStaticFields();
when(mockFfmpegExecutor.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version")))
.thenThrow(new IOException("Command not found"));
assertFalse(CheckProgramInstall.isFfmpegAvailable());
verify(mockFfmpegExecutor)
.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version"));
}
@Test
void testIsFfmpegAvailable_CachesResult() throws Exception {
resetStaticFields();
ProcessExecutorResult result = Mockito.mock(ProcessExecutorResult.class);
when(mockFfmpegExecutor.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version")))
.thenReturn(result);
assertTrue(CheckProgramInstall.isFfmpegAvailable());
when(mockFfmpegExecutor.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version")))
.thenThrow(new IOException("Command not found"));
assertTrue(CheckProgramInstall.isFfmpegAvailable());
verify(mockFfmpegExecutor, times(1))
.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version"));
}
}
@@ -46,7 +46,7 @@ public class ChecksumUtilsTest {
@Test
void crc32_unsignedFormatting_highBitSet() throws Exception {
// CRC32 of single zero byte (0x00) is 0xD202EF8D (>= 0x8000_0000)
byte[] data = {0x00};
byte[] data = new byte[] {0x00};
// Hex (unsigned, 8 chars, lowercase)
try (InputStream is = new ByteArrayInputStream(data)) {
@@ -1,44 +1,41 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.SsrfProtectionService;
@ExtendWith(MockitoExtension.class)
class CustomHtmlSanitizerTest {
@Mock private SsrfProtectionService ssrfProtectionService;
@Mock private ApplicationProperties applicationProperties;
@Mock private ApplicationProperties.System systemProperties;
private CustomHtmlSanitizer customHtmlSanitizer;
@BeforeEach
void setUp() {
// Default behavior: allow all URLs and enable sanitization. Lenient stubs avoid
// strict-stubbing failures when individual tests bypass certain branches.
lenient().when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(true);
lenient().when(applicationProperties.getSystem()).thenReturn(systemProperties);
lenient().when(systemProperties.isDisableSanitize()).thenReturn(false);
SsrfProtectionService mockSsrfProtectionService = mock(SsrfProtectionService.class);
stirling.software.common.model.ApplicationProperties mockApplicationProperties =
mock(stirling.software.common.model.ApplicationProperties.class);
stirling.software.common.model.ApplicationProperties.System mockSystem =
mock(stirling.software.common.model.ApplicationProperties.System.class);
customHtmlSanitizer = new CustomHtmlSanitizer(ssrfProtectionService, applicationProperties);
// Allow all URLs by default for basic tests
when(mockSsrfProtectionService.isUrlAllowed(org.mockito.ArgumentMatchers.anyString()))
.thenReturn(true);
when(mockApplicationProperties.getSystem()).thenReturn(mockSystem);
when(mockSystem.getDisableSanitize()).thenReturn(false); // Enable sanitization for tests
customHtmlSanitizer =
new CustomHtmlSanitizer(mockSsrfProtectionService, mockApplicationProperties);
}
@ParameterizedTest
@@ -59,11 +56,10 @@ class CustomHtmlSanitizerTest {
"<p>This is <strong>valid</strong> HTML with <em>formatting</em>.</p>",
new String[] {"<p>", "<strong>", "<em>"}),
Arguments.of(
"<p>Text with <b>bold</b>, <i>italic</i>, <u>underline</u>,"
+ " <em>emphasis</em>, <strong>strong</strong>,"
+ " <strike>strikethrough</strike>, <s>strike</s>,"
+ " <sub>subscript</sub>, <sup>superscript</sup>, <tt>teletype</tt>,"
+ " <code>code</code>, <big>big</big>, <small>small</small>.</p>",
"<p>Text with <b>bold</b>, <i>italic</i>, <u>underline</u>, "
+ "<em>emphasis</em>, <strong>strong</strong>, <strike>strikethrough</strike>, "
+ "<s>strike</s>, <sub>subscript</sub>, <sup>superscript</sup>, "
+ "<tt>teletype</tt>, <code>code</code>, <big>big</big>, <small>small</small>.</p>",
new String[] {
"<b>bold</b>",
"<i>italic</i>",
@@ -167,7 +163,7 @@ class CustomHtmlSanitizerTest {
@Test
void testSanitizeAllowsImages() {
// Arrange - Testing custom images policy with SSRF check
// Arrange - Testing Sanitizers.IMAGES
String htmlWithImage =
"<img src=\"image.jpg\" alt=\"An image\" width=\"100\" height=\"100\">";
@@ -186,16 +182,7 @@ class CustomHtmlSanitizerTest {
void testSanitizeDisallowsDataUrlImages() {
// Arrange
String htmlWithDataUrlImage =
"<img src=\"data:image/svg+xml;base64,PHN2ZyBvbmxvYWQ9ImFsZXJ0KDEpIj48L3N2Zz4=\""
+ " alt=\"SVG with XSS\">";
// Changed: Explicitly tell SSRF service to reject data: URLs so the custom AttributePolicy
// drops the src attribute. Without this, a permissive SSRF mock might allow data: URLs.
lenient()
.when(
ssrfProtectionService.isUrlAllowed(
argThat(v -> v != null && v.startsWith("data:"))))
.thenReturn(false);
"<img src=\"data:image/svg+xml;base64,PHN2ZyBvbmxvYWQ9ImFsZXJ0KDEpIj48L3N2Zz4=\" alt=\"SVG with XSS\">";
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithDataUrlImage);
@@ -270,9 +257,9 @@ class CustomHtmlSanitizerTest {
void testSanitizeRemovesObjectAndEmbed() {
// Arrange
String htmlWithObjects =
"<p>Safe content</p><object data=\"data.swf\""
+ " type=\"application/x-shockwave-flash\"></object><embed src=\"embed.swf\""
+ " type=\"application/x-shockwave-flash\">";
"<p>Safe content</p>"
+ "<object data=\"data.swf\" type=\"application/x-shockwave-flash\"></object>"
+ "<embed src=\"embed.swf\" type=\"application/x-shockwave-flash\">";
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithObjects);
@@ -308,12 +295,17 @@ class CustomHtmlSanitizerTest {
void testSanitizeHandlesComplexHtml() {
// Arrange
String complexHtml =
"<div class=\"container\"> <h1 style=\"color: blue;\">Welcome</h1> <p>This is a"
+ " <strong>test</strong> with <a href=\"https://example.com\">link</a>.</p> "
+ " <table> <tr><th>Name</th><th>Value</th></tr> <tr><td>Item"
+ " 1</td><td>100</td></tr> </table> <img src=\"image.jpg\" alt=\"Test"
+ " image\"> <script>alert('XSS');</script> <iframe"
+ " src=\"https://evil.com\"></iframe></div>";
"<div class=\"container\">"
+ " <h1 style=\"color: blue;\">Welcome</h1>"
+ " <p>This is a <strong>test</strong> with <a href=\"https://example.com\">link</a>.</p>"
+ " <table>"
+ " <tr><th>Name</th><th>Value</th></tr>"
+ " <tr><td>Item 1</td><td>100</td></tr>"
+ " </table>"
+ " <img src=\"image.jpg\" alt=\"Test image\">"
+ " <script>alert('XSS');</script>"
+ " <iframe src=\"https://evil.com\"></iframe>"
+ "</div>";
// Act
String sanitizedHtml = customHtmlSanitizer.sanitize(complexHtml);
@@ -361,121 +353,4 @@ class CustomHtmlSanitizerTest {
// Assert
assertEquals("", sanitizedHtml, "Null input should result in empty string");
}
// -----------------------
// Additional coverage
// -----------------------
@Test
@DisplayName("Should return input unchanged when sanitize is disabled via properties")
void shouldBypassSanitizationWhenDisabled() {
// Arrange
String malicious =
"<p>ok</p><script>alert('XSS')</script><img src=\"http://blocked.local/a.png\">";
// For this test, disable sanitize
when(systemProperties.isDisableSanitize()).thenReturn(true);
// Also ensure SSRF would block it if sanitization were enabled (to prove bypass)
lenient().when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
// Act
String result = customHtmlSanitizer.sanitize(malicious);
// Assert
// When disabled, sanitizer must return original string as-is.
assertEquals(malicious, result, "Sanitization disabled should return input as-is");
}
@Test
@DisplayName("Should remove img src when SSRF service rejects the URL")
void shouldRemoveImageSrcWhenSsrfRejects() {
// Arrange
String html = "<img src=\"http://internal/admin\" alt=\"x\">";
// Reject this URL
lenient()
.when(ssrfProtectionService.isUrlAllowed("http://internal/admin"))
.thenReturn(false);
// Act
String sanitized = customHtmlSanitizer.sanitize(html);
// Assert
assertTrue(sanitized.contains("<img"), "Image element should remain");
assertFalse(
sanitized.contains("src=\"http://internal/admin\""),
"Rejected URL should be removed from src attribute");
assertTrue(sanitized.contains("alt=\"x\""), "Other allowed attributes should remain");
}
@Test
@DisplayName("Should trim and preserve allowed img src values")
void shouldTrimAndPreserveAllowedImgSrc() {
// Arrange
String html = "<img src=\" https://example.com/image.jpg \" alt=\"pic\" title=\"t\">";
// Explicit allow (clarity)
lenient()
.when(ssrfProtectionService.isUrlAllowed("https://example.com/image.jpg"))
.thenReturn(true);
// Act
String sanitized = customHtmlSanitizer.sanitize(html);
// Assert
assertTrue(sanitized.contains("<img"), "Image element should remain");
assertFalse(
sanitized.contains(" https://example.com/image.jpg "),
"Untrimmed src value should not appear in output");
assertTrue(
sanitized.contains("alt=\"pic\"") || sanitized.contains("alt='pic'"),
"Alt attribute should remain");
assertTrue(
sanitized.contains("title=\"t\"") || sanitized.contains("title='t'"),
"Title attribute should remain");
}
@Test
@DisplayName("Should drop empty or whitespace-only img src values")
void shouldDropEmptyImgSrc() {
// Arrange
String html = "<img src=\" \" alt=\"no src\">";
// Act
String sanitized = customHtmlSanitizer.sanitize(html);
// Assert
assertTrue(sanitized.contains("<img"), "Image element should remain");
assertFalse(sanitized.contains("src="), "Empty src should be removed entirely");
assertTrue(sanitized.contains("alt=\"no src\""), "Other attributes should remain");
}
@Nested
@DisplayName("Anchor tag protocol handling (sanitizer-level)")
class AnchorProtocolTests {
@Test
@DisplayName(
"mailto: links should keep anchor and text; protocol may be stripped depending on"
+ " sanitizer version")
void mailtoAllowed() {
String html = "<a href=\"mailto:hello@example.com\">mail me</a>";
String sanitized = customHtmlSanitizer.sanitize(html);
// Anchor and text should remain
assertTrue(sanitized.contains("<a"), "Anchor element should be preserved");
assertTrue(sanitized.contains("mail me"), "Link text should remain");
// Some sanitizer versions allow mailto:, others strip it. Accept both.
boolean keepsMailto = sanitized.contains("mailto:hello@example.com");
boolean strippedHref =
sanitized.contains("href="); // still has an href, possibly sanitized
// We accept either: mailto kept OR href present in sanitized form OR (in strict setups)
// href removed but anchor kept.
assertTrue(
keepsMailto || strippedHref || sanitized.contains("</a>"),
"Sanitized output should keep anchor; mailto: may or may not be present"
+ " depending on sanitizer configuration");
}
}
}
@@ -1,6 +1,11 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
@@ -12,7 +17,6 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Locale;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
@@ -48,7 +52,7 @@ class EmlToPdfTest {
when(mockSsrfProtectionService.isUrlAllowed(org.mockito.ArgumentMatchers.anyString()))
.thenReturn(true);
when(mockApplicationProperties.getSystem()).thenReturn(mockSystem);
when(mockSystem.isDisableSanitize()).thenReturn(false);
when(mockSystem.getDisableSanitize()).thenReturn(false);
customHtmlSanitizer =
new CustomHtmlSanitizer(mockSsrfProtectionService, mockApplicationProperties);
@@ -761,7 +765,7 @@ class EmlToPdfTest {
}
// Helper methods
private static String getTimestamp() {
private String getTimestamp() {
java.time.ZonedDateTime fixedDateTime =
java.time.ZonedDateTime.of(2023, 1, 1, 12, 0, 0, 0, java.time.ZoneId.of("GMT"));
return java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME.format(fixedDateTime);
@@ -774,33 +778,20 @@ class EmlToPdfTest {
private String createSimpleTextEmailWithCharset(
String from, String to, String subject, String body, String charset) {
return String.format(
Locale.ROOT,
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nContent-Type: text/plain; charset=%s\nContent-Transfer-Encoding: 8bit\n\n%s",
from,
to,
subject,
getTimestamp(),
charset,
body);
from, to, subject, getTimestamp(), charset, body);
}
private String createEmailWithCustomHeaders() {
return String.format(
Locale.ROOT,
"From: sender@example.com\nDate: %s\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n\n%s",
getTimestamp(),
"This is an email body with some headers missing.");
getTimestamp(), "This is an email body with some headers missing.");
}
private String createHtmlEmail(String from, String to, String subject, String htmlBody) {
return String.format(
Locale.ROOT,
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nContent-Type: text/html; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n\n%s",
from,
to,
subject,
getTimestamp(),
htmlBody);
from, to, subject, getTimestamp(), htmlBody);
}
private String createMultipartEmailWithAttachment(
@@ -815,7 +806,6 @@ class EmlToPdfTest {
Base64.getEncoder()
.encodeToString(attachmentContent.getBytes(StandardCharsets.UTF_8));
return String.format(
Locale.ROOT,
"""
From: %s
To: %s
@@ -855,7 +845,6 @@ class EmlToPdfTest {
Base64.getEncoder()
.encodeToString(attachmentEmlContent.getBytes(StandardCharsets.UTF_8));
return String.format(
Locale.ROOT,
"""
From: %s
To: %s
@@ -894,7 +883,6 @@ class EmlToPdfTest {
private String createMultipartAlternativeEmail(
String textBody, String htmlBody, String boundary) {
return String.format(
Locale.ROOT,
"""
From: %s
To: %s
@@ -930,7 +918,6 @@ class EmlToPdfTest {
private String createQuotedPrintableEmail() {
return String.format(
Locale.ROOT,
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: quoted-printable\n\n%s",
"sender@example.com",
"recipient@example.com",
@@ -943,7 +930,6 @@ class EmlToPdfTest {
String encodedBody =
Base64.getEncoder().encodeToString(body.getBytes(StandardCharsets.UTF_8));
return String.format(
Locale.ROOT,
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: base64\n\n%s",
"sender@example.com",
"recipient@example.com",
@@ -955,7 +941,6 @@ class EmlToPdfTest {
private String createEmailWithInlineImage(
String htmlBody, String boundary, String contentId, String base64Image) {
return String.format(
Locale.ROOT,
"""
From: %s
To: %s
@@ -1000,7 +985,6 @@ class EmlToPdfTest {
String encodedAttachment =
Base64.getEncoder().encodeToString(attachmentBody.getBytes(StandardCharsets.UTF_8));
return String.format(
Locale.ROOT,
"""
From: %s
To: %s
@@ -1055,13 +1039,13 @@ class EmlToPdfTest {
}
// Creates a basic EmlToPdfRequest with default settings
private static EmlToPdfRequest createBasicRequest() {
private EmlToPdfRequest createBasicRequest() {
EmlToPdfRequest request = new EmlToPdfRequest();
request.setIncludeAttachments(false);
return request;
}
private static EmlToPdfRequest createRequestWithAttachments() {
private EmlToPdfRequest createRequestWithAttachments() {
EmlToPdfRequest request = new EmlToPdfRequest();
request.setIncludeAttachments(true);
request.setMaxAttachmentSizeMB(10);
@@ -1,6 +1,7 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.Test;
import org.springframework.ui.Model;
@@ -1,354 +0,0 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.IOException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
/**
* Unit tests for {@link ExceptionUtils}. Assumptions: - PdfErrorUtils.isCorruptedPdfError is a
* static method that returns true for certain exception types. We will mock it in tests for
* handlePdfException and logException.
*/
class ExceptionUtilsTest {
@Nested
@DisplayName("PDF corruption exception creation")
class PdfCorruptionTests {
@Test
@DisplayName("should create PdfCorruptedException without context")
void testCreatePdfCorruptedExceptionWithoutContext() {
Exception cause = new Exception("root");
IOException ex = ExceptionUtils.createPdfCorruptedException(null, cause);
assertTrue(ex.getMessage().contains("PDF file appears to be corrupted"));
assertSame(cause, ex.getCause());
}
@Test
@DisplayName("should create PdfCorruptedException with context")
void testCreatePdfCorruptedExceptionWithContext() {
Exception cause = new Exception("root");
IOException ex = ExceptionUtils.createPdfCorruptedException("during merge", cause);
assertTrue(
ex.getMessage()
.startsWith("Error during merge: PDF file appears to be corrupted"));
assertSame(cause, ex.getCause());
}
@Test
@DisplayName("should create MultiplePdfCorruptedException")
void testCreateMultiplePdfCorruptedException() {
Exception cause = new Exception("root");
IOException ex = ExceptionUtils.createMultiplePdfCorruptedException(cause);
assertTrue(ex.getMessage().startsWith("One or more PDF files appear to be corrupted"));
assertSame(cause, ex.getCause());
}
}
@Nested
@DisplayName("PDF encryption and password exception creation")
class PdfSecurityTests {
@Test
void testCreatePdfEncryptionException() {
Exception cause = new Exception("root");
IOException ex = ExceptionUtils.createPdfEncryptionException(cause);
assertTrue(ex.getMessage().contains("corrupted encryption data"));
assertSame(cause, ex.getCause());
}
@Test
void testCreatePdfPasswordException() {
Exception cause = new Exception("root");
IOException ex = ExceptionUtils.createPdfPasswordException(cause);
assertTrue(ex.getMessage().contains("passworded"));
assertSame(cause, ex.getCause());
}
}
@Nested
@DisplayName("File processing exception creation")
class FileProcessingTests {
@Test
void testCreateFileProcessingException() {
Exception cause = new Exception("boom");
IOException ex = ExceptionUtils.createFileProcessingException("merge", cause);
assertTrue(ex.getMessage().contains("while processing the file during merge"));
assertSame(cause, ex.getCause());
}
}
@Nested
@DisplayName("Generic exception creation")
class GenericCreationTests {
@Test
void testCreateIOException() {
IOException ex =
ExceptionUtils.createIOException(
"key", "Default message: {0}", new Exception("cause"), "X");
assertEquals("Default message: X", ex.getMessage());
}
@Test
void testCreateRuntimeException() {
RuntimeException ex =
ExceptionUtils.createRuntimeException(
"key", "Default message: {0}", new Exception("cause"), "Y");
assertEquals("Default message: Y", ex.getMessage());
}
@Test
void testCreateIllegalArgumentException() {
IllegalArgumentException ex =
ExceptionUtils.createIllegalArgumentException("key", "Format {0}", "Z");
assertEquals("Format Z", ex.getMessage());
}
}
@Nested
@DisplayName("Predefined validation exceptions")
class PredefinedValidationTests {
@Test
void testCreateHtmlFileRequiredException() {
IllegalArgumentException ex = ExceptionUtils.createHtmlFileRequiredException();
assertTrue(ex.getMessage().contains("HTML or ZIP"));
}
@Test
void testCreatePdfFileRequiredException() {
IllegalArgumentException ex = ExceptionUtils.createPdfFileRequiredException();
assertTrue(ex.getMessage().contains("PDF"));
}
@Test
void testCreateInvalidPageSizeException() {
IllegalArgumentException ex = ExceptionUtils.createInvalidPageSizeException("A5");
assertTrue(ex.getMessage().contains("page size"));
}
}
@Nested
@DisplayName("OCR and system requirement exceptions")
class OcrAndSystemTests {
@Test
void testCreateOcrLanguageRequiredException() {
IOException ex = ExceptionUtils.createOcrLanguageRequiredException();
assertTrue(ex.getMessage().contains("OCR language"));
}
@Test
void testCreateOcrInvalidLanguagesException() {
IOException ex = ExceptionUtils.createOcrInvalidLanguagesException();
assertTrue(ex.getMessage().contains("none of the selected languages"));
}
@Test
void testCreateOcrToolsUnavailableException() {
IOException ex = ExceptionUtils.createOcrToolsUnavailableException();
assertTrue(ex.getMessage().contains("OCR tools"));
}
@Test
void testCreatePythonRequiredForWebpException() {
IOException ex = ExceptionUtils.createPythonRequiredForWebpException();
assertTrue(ex.getMessage().contains("Python"));
assertTrue(ex.getMessage().contains("WebP conversion"));
}
}
@Nested
@DisplayName("File operation and compression exceptions")
class FileAndCompressionTests {
@Test
void testCreatePdfaConversionFailedException() {
RuntimeException ex = ExceptionUtils.createPdfaConversionFailedException();
assertTrue(ex.getMessage().contains("PDF/A conversion failed"));
}
@Test
void testCreateMd5AlgorithmException() {
RuntimeException ex = ExceptionUtils.createMd5AlgorithmException(new Exception("x"));
assertTrue(ex.getMessage().contains("MD5"));
}
@Test
void testCreateGhostscriptCompressionExceptionNoCause() {
IOException ex = ExceptionUtils.createGhostscriptCompressionException();
assertTrue(ex.getMessage().contains("Ghostscript"));
}
@Test
void testCreateGhostscriptCompressionExceptionWithCause() {
IOException ex =
ExceptionUtils.createGhostscriptCompressionException(new Exception("cause"));
assertTrue(ex.getMessage().contains("Ghostscript"));
}
}
@Nested
@DisplayName("PDF exception handling")
class PdfExceptionHandlingTests {
@Test
void testHandlePdfExceptionWhenCorrupted() {
IOException original = new IOException("corrupted pdf");
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(original)).thenReturn(true);
IOException result = ExceptionUtils.handlePdfException(original);
assertNotSame(original, result);
assertTrue(result.getMessage().contains("corrupted"));
}
}
@Test
void testHandlePdfExceptionWhenEncryptionError() {
IOException original = new IOException("BadPaddingException");
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(original)).thenReturn(false);
IOException result = ExceptionUtils.handlePdfException(original);
assertTrue(result.getMessage().contains("corrupted encryption data"));
}
}
@Test
void testHandlePdfExceptionWhenPasswordError() {
IOException original = new IOException("password is incorrect");
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(original)).thenReturn(false);
IOException result = ExceptionUtils.handlePdfException(original);
assertTrue(result.getMessage().contains("passworded"));
}
}
@Test
void testHandlePdfExceptionWhenNoSpecialError() {
IOException original = new IOException("something else");
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(original)).thenReturn(false);
IOException result = ExceptionUtils.handlePdfException(original);
assertSame(original, result);
}
}
}
@Nested
@DisplayName("Encryption and password detection")
class ErrorDetectionTests {
@Test
void testIsEncryptionErrorTrue() {
assertTrue(ExceptionUtils.isEncryptionError(new IOException("BadPaddingException")));
assertTrue(
ExceptionUtils.isEncryptionError(
new IOException("Given final block not properly padded")));
assertTrue(
ExceptionUtils.isEncryptionError(
new IOException("AES initialization vector not fully read")));
assertTrue(ExceptionUtils.isEncryptionError(new IOException("Failed to decrypt")));
}
@Test
void testIsEncryptionErrorFalse() {
assertFalse(ExceptionUtils.isEncryptionError(new IOException("other message")));
assertFalse(ExceptionUtils.isEncryptionError(new IOException((String) null)));
}
@Test
void testIsPasswordErrorTrue() {
assertTrue(ExceptionUtils.isPasswordError(new IOException("password is incorrect")));
assertTrue(ExceptionUtils.isPasswordError(new IOException("Password is not provided")));
assertTrue(
ExceptionUtils.isPasswordError(
new IOException("PDF contains an encryption dictionary")));
}
@Test
void testIsPasswordErrorFalse() {
assertFalse(ExceptionUtils.isPasswordError(new IOException("something else")));
assertFalse(ExceptionUtils.isPasswordError(new IOException((String) null)));
}
}
@Nested
@DisplayName("Logging behavior")
class LoggingTests {
@Test
void testLogExceptionWhenCorruptedPdf() {
Exception e = new IOException("corrupted");
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(e)).thenReturn(true);
// We can't assert log output here without a custom appender, but this ensures no
// exception is thrown
ExceptionUtils.logException("merge", e);
}
}
@Test
void testLogExceptionWhenEncryptionError() {
IOException e = new IOException("BadPaddingException");
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(e)).thenReturn(false);
ExceptionUtils.logException("merge", e);
}
}
@Test
void testLogExceptionWhenPasswordError() {
IOException e = new IOException("password is incorrect");
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(e)).thenReturn(false);
ExceptionUtils.logException("merge", e);
}
}
@Test
void testLogExceptionUnexpectedError() {
Exception e = new RuntimeException("unexpected");
try (MockedStatic<PdfErrorUtils> mock = mockStatic(PdfErrorUtils.class)) {
mock.when(() -> PdfErrorUtils.isCorruptedPdfError(e)).thenReturn(false);
ExceptionUtils.logException("merge", e);
}
}
}
@Nested
@DisplayName("Invalid and null argument exceptions")
class ArgumentValidationTests {
@Test
void testCreateInvalidArgumentExceptionSingle() {
IllegalArgumentException ex =
ExceptionUtils.createInvalidArgumentException("arg", "invalidValue");
assertTrue(ex.getMessage().contains("arg"));
assertTrue(ex.getMessage().contains("invalidValue"));
}
@Test
void testCreateInvalidArgumentExceptionWithValue() {
IllegalArgumentException ex =
ExceptionUtils.createInvalidArgumentException("arg", "val");
assertTrue(ex.getMessage().contains("val"));
}
@Test
void testCreateNullArgumentException() {
IllegalArgumentException ex = ExceptionUtils.createNullArgumentException("arg");
assertTrue(ex.getMessage().contains("arg"));
}
}
}
@@ -1,6 +1,7 @@
package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@@ -45,15 +46,12 @@ class FileMonitorTest {
@Test
void testIsFileReadyForProcessing_OldFile() throws IOException {
// Capture test time at the beginning for deterministic calculations
final Instant testTime = Instant.now();
// Create a test file
Path testFile = tempDir.resolve("test-file.txt");
Files.write(testFile, "test content".getBytes());
// Set modified time to 10 seconds ago (relative to test start time)
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
// Set modified time to 10 seconds ago
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000)));
// File should be ready for processing as it was modified more than 5 seconds ago
assertTrue(fileMonitor.isFileReadyForProcessing(testFile));
@@ -61,15 +59,12 @@ class FileMonitorTest {
@Test
void testIsFileReadyForProcessing_RecentFile() throws IOException {
// Capture test time at the beginning for deterministic calculations
final Instant testTime = Instant.now();
// Create a test file
Path testFile = tempDir.resolve("recent-file.txt");
Files.write(testFile, "test content".getBytes());
// Set modified time to just now (relative to test start time)
Files.setLastModifiedTime(testFile, FileTime.from(testTime));
// Set modified time to just now
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now()));
// File should not be ready for processing as it was just modified
assertFalse(fileMonitor.isFileReadyForProcessing(testFile));
@@ -86,16 +81,12 @@ class FileMonitorTest {
@Test
void testIsFileReadyForProcessing_LockedFile() throws IOException {
// Capture test time at the beginning for deterministic calculations
final Instant testTime = Instant.now();
// Create a test file
Path testFile = tempDir.resolve("locked-file.txt");
Files.write(testFile, "test content".getBytes());
// Set modified time to 10 seconds ago (relative to test start time) to make sure it passes
// the time check
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
// Set modified time to 10 seconds ago to make sure it passes the time check
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000)));
// Verify the file is considered ready when it meets the time criteria
assertTrue(
@@ -114,12 +105,12 @@ class FileMonitorTest {
// Create a PDF file
Path pdfFile = tempDir.resolve("test.pdf");
Files.write(pdfFile, "pdf content".getBytes());
Files.setLastModifiedTime(pdfFile, FileTime.from(Instant.ofEpochMilli(1000000L)));
Files.setLastModifiedTime(pdfFile, FileTime.from(Instant.now().minusMillis(10000)));
// Create a TXT file
Path txtFile = tempDir.resolve("test.txt");
Files.write(txtFile, "text content".getBytes());
Files.setLastModifiedTime(txtFile, FileTime.from(Instant.ofEpochMilli(1000000L)));
Files.setLastModifiedTime(txtFile, FileTime.from(Instant.now().minusMillis(10000)));
// PDF file should be ready for processing
assertTrue(pdfMonitor.isFileReadyForProcessing(pdfFile));
@@ -135,15 +126,12 @@ class FileMonitorTest {
@Test
void testIsFileReadyForProcessing_FileInUse() throws IOException {
// Capture test time at the beginning for deterministic calculations
final Instant testTime = Instant.now();
// Create a test file
Path testFile = tempDir.resolve("in-use-file.txt");
Files.write(testFile, "initial content".getBytes());
// Set modified time to 10 seconds ago (relative to test start time)
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
// Set modified time to 10 seconds ago
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000)));
// First check that the file is ready when meeting time criteria
assertTrue(
@@ -152,7 +140,7 @@ class FileMonitorTest {
// After modifying the file to simulate closing, it should still be ready
Files.write(testFile, "updated content".getBytes());
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000)));
assertTrue(
fileMonitor.isFileReadyForProcessing(testFile),
@@ -161,15 +149,12 @@ class FileMonitorTest {
@Test
void testIsFileReadyForProcessing_FileWithAbsolutePath() throws IOException {
// Capture test time at the beginning for deterministic calculations
final Instant testTime = Instant.now();
// Create a test file
Path testFile = tempDir.resolve("absolute-path-file.txt");
Files.write(testFile, "test content".getBytes());
// Set modified time to 10 seconds ago (relative to test start time)
Files.setLastModifiedTime(testFile, FileTime.from(testTime.minusMillis(10000)));
// Set modified time to 10 seconds ago
Files.setLastModifiedTime(testFile, FileTime.from(Instant.now().minusMillis(10000)));
// File should be ready for processing as it was modified more than 5 seconds ago
// Use the absolute path to make sure it's handled correctly
@@ -183,7 +168,7 @@ class FileMonitorTest {
Files.createDirectory(testDir);
// Set modified time to 10 seconds ago
Files.setLastModifiedTime(testDir, FileTime.from(Instant.ofEpochMilli(1000000L)));
Files.setLastModifiedTime(testDir, FileTime.from(Instant.now().minusMillis(10000)));
// A directory should not be considered ready for processing
boolean isReady = fileMonitor.isFileReadyForProcessing(testDir);

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