Merge remote-tracking branch 'origin/main' into security/frontend-cves

# Conflicts:
#	frontend/package-lock.json
#	frontend/package.json
This commit is contained in:
Anthony Stirling
2026-08-12 12:07:18 +01:00
861 changed files with 112113 additions and 48477 deletions
-2
View File
@@ -119,8 +119,6 @@
"extensions": [
"elagil.pre-commit-helper", // Support for pre-commit hooks to enforce code quality
"josevseb.google-java-format-for-vs-code", // Google Java code formatter to follow the Google Java Style Guide
"ms-python.black-formatter", // Python code formatter using Black
"ms-python.flake8", // Flake8 linter for Python to enforce code quality
"ms-python.python", // Official Microsoft Python extension with IntelliSense, debugging, and Jupyter support
"ms-vscode-remote.vscode-remote-extensionpack", // Remote Development Pack for SSH, WSL, and Containers
// "Oracle.oracle-java", // Oracle Java extension with additional features for Java development
+23 -13
View File
@@ -1,18 +1,28 @@
# All PRs must be approved by Frooodle or Ludy87
* @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
# Review ownership is assigned to teams where possible.
# Teams can only contain org members, so outside collaborators are listed by hand.
#
# @Stirling-Tools/maintainers - Frooodle, jbrunton96, ConnorYoh
# @Stirling-Tools/backend-reviewers - Frooodle, jbrunton96, ConnorYoh
# @Stirling-Tools/frontend-reviewers - Frooodle, jbrunton96, ConnorYoh, reecebrowne, EthanHealy01
# @Stirling-Tools/devops-reviewers - Frooodle, jbrunton96, ConnorYoh
# @Stirling-Tools/all - all of the above
#
# Outside collaborators (need Write access to count as owners): @Ludy87 @balazs-szucs
# Default owners for everything
* @Stirling-Tools/maintainers @Ludy87
# Backend
/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh @balazs-szucs
/app/** @Stirling-Tools/backend-reviewers @Ludy87 @balazs-szucs
#V2 frontend
/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @balazs-szucs
/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87 @balazs-szucs
# V2 frontend
/frontend/** @Stirling-Tools/frontend-reviewers @balazs-szucs
/app/core/src/main/resources/static/** @Stirling-Tools/frontend-reviewers @Ludy87 @balazs-szucs
#V2 docker
/docker/backend/** @Frooodle @Ludy87 @DarioGii
/docker/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
/docker/compose/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
# V2 docker
/docker/backend/** @Stirling-Tools/devops-reviewers @Ludy87
/docker/frontend/** @Stirling-Tools/frontend-reviewers @Stirling-Tools/devops-reviewers @Ludy87
/docker/compose/** @Stirling-Tools/frontend-reviewers @Stirling-Tools/devops-reviewers @Ludy87
#GHA (All users)
/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87 @balazs-szucs
# GHA (all users)
/.github/** @Stirling-Tools/all @Ludy87 @balazs-szucs
-1
View File
@@ -11,7 +11,6 @@
"LaserKaspar",
"sbplat",
"reecebrowne",
"DarioGii",
"ConnorYoh",
"EthanHealy01",
"jbrunton96",
+5 -8
View File
@@ -16,6 +16,11 @@ updates:
cooldown:
default-days: 7
rebase-strategy: "auto"
groups:
simple-java-mail:
patterns:
- "org.simplejavamail:simple-java-mail"
- "org.simplejavamail:outlook-module"
- package-ecosystem: "docker"
directories:
@@ -73,14 +78,6 @@ updates:
- "react-dom"
- "@types/react"
- "@types/react-dom"
typescript-eslint:
patterns:
- "@typescript-eslint/*"
- "typescript-eslint"
eslint:
patterns:
- "eslint"
- "@eslint/*"
vite:
patterns:
- "vite"
+9 -28
View File
@@ -19,9 +19,9 @@ import argparse
import glob
import os
import re
import tomllib # Python 3.11+ (stdlib)
from pathlib import Path
import tomllib # Python 3.11+ (stdlib)
import tomli_w # For writing TOML files
@@ -133,11 +133,7 @@ def update_missing_keys(reference_file, file_list, branch=""):
file_path = Path(file_path)
language_dir = file_path.parent.name
reference_lang_dir = reference_file.parent.name
if (
language_dir == reference_lang_dir
or file_path.suffix != ".toml"
or file_path.parents[1].name != "locales"
):
if language_dir == reference_lang_dir or file_path.suffix != ".toml" or file_path.parents[1].name != "locales":
print(f"Skipping file: {file_path}")
continue
@@ -198,9 +194,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
# Verify that file is within the expected directory
if not absolute_path.is_relative_to(base_dir):
has_differences = True
report.append(
f"\n⚠️ Unsafe file found: `{locale_dir}/{basename_current_file}`\n\n---\n"
)
report.append(f"\n⚠️ Unsafe file found: `{locale_dir}/{basename_current_file}`\n\n---\n")
continue
# Verify file size before processing
@@ -214,10 +208,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
if basename_current_file == basename_reference_file and locale_dir == "en-US":
continue
if (
file_normpath.suffix != ".toml"
or basename_current_file != "translation.toml"
):
if file_normpath.suffix != ".toml" or basename_current_file != "translation.toml":
continue
only_reference_file = False
@@ -261,9 +252,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
)
report.append("")
report.append(" Use the following command to remove them:")
report.append(
f" `python scripts/translations/translation_merger.py {locale_dir} remove-unused`"
)
report.append(f" `python scripts/translations/translation_merger.py {locale_dir} remove-unused`")
report.append("")
if extra_keys_list:
report.append(
@@ -271,9 +260,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
)
report.append("")
report.append(" Use the following command to add them:")
report.append(
f" `python scripts/translations/translation_merger.py {locale_dir} add-missing`"
)
report.append(f" `python scripts/translations/translation_merger.py {locale_dir} add-missing`")
report.append("")
if missing_keys_list or extra_keys_list:
@@ -288,9 +275,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
output = "\n".join(
[
f" - `{key}`: first at {first}, duplicate at `{duplicate}`"
for key, first, duplicate in find_duplicate_keys(
branch_path / file_normpath
)
for key, first, duplicate in find_duplicate_keys(branch_path / file_normpath)
]
)
report.append("3. **Test Status:** ❌ **_Failed_**")
@@ -313,18 +298,14 @@ def check_for_differences(reference_file, file_list, branch, actor):
else:
report.append("## ✅ Overall Check Status: **_Success_**")
report.append("")
report.append(
f"Thanks @{actor} for your help in keeping the translations up to date."
)
report.append(f"Thanks @{actor} for your help in keeping the translations up to date.")
if not only_reference_file:
print("\n".join(report))
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Find missing keys in TOML translation files"
)
parser = argparse.ArgumentParser(description="Find missing keys in TOML translation files")
parser.add_argument(
"--actor",
required=False,
-9
View File
@@ -1,9 +0,0 @@
pip
setuptools
WeasyPrint
pdf2image
pillow
unoserver
opencv-python-headless
pre-commit
brotli @ git+https://github.com/google/brotli.git@028fb5a23661f123017c060daa546b55cf4bde29
-528
View File
@@ -1,528 +0,0 @@
#
# This file is autogenerated by pip-compile with Python 3.13
# by the following command:
#
# 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
brotli @ git+https://github.com/google/brotli.git@028fb5a23661f123017c060daa546b55cf4bde29
# via
# -r .github/scripts/requirements_dev.in
# fonttools
cffi==2.1.0 \
--hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \
--hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \
--hash=sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d \
--hash=sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5 \
--hash=sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f \
--hash=sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6 \
--hash=sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c \
--hash=sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda \
--hash=sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd \
--hash=sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a \
--hash=sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd \
--hash=sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd \
--hash=sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3 \
--hash=sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb \
--hash=sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66 \
--hash=sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d \
--hash=sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f \
--hash=sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6 \
--hash=sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0 \
--hash=sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c \
--hash=sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93 \
--hash=sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d \
--hash=sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d \
--hash=sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8 \
--hash=sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b \
--hash=sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001 \
--hash=sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d \
--hash=sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43 \
--hash=sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b \
--hash=sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0 \
--hash=sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0 \
--hash=sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458 \
--hash=sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8 \
--hash=sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d \
--hash=sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94 \
--hash=sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022 \
--hash=sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db \
--hash=sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479 \
--hash=sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376 \
--hash=sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d \
--hash=sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6 \
--hash=sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3 \
--hash=sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea \
--hash=sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd \
--hash=sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02 \
--hash=sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde \
--hash=sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224 \
--hash=sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76 \
--hash=sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804 \
--hash=sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1 \
--hash=sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913 \
--hash=sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714 \
--hash=sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc \
--hash=sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2 \
--hash=sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e \
--hash=sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda \
--hash=sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512 \
--hash=sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28 \
--hash=sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699 \
--hash=sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3 \
--hash=sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c \
--hash=sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe \
--hash=sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a \
--hash=sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f \
--hash=sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c \
--hash=sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2 \
--hash=sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f \
--hash=sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b \
--hash=sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565 \
--hash=sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056 \
--hash=sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629 \
--hash=sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7 \
--hash=sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0 \
--hash=sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9 \
--hash=sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853 \
--hash=sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13 \
--hash=sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a \
--hash=sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4 \
--hash=sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce \
--hash=sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac \
--hash=sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c \
--hash=sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46 \
--hash=sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384 \
--hash=sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b \
--hash=sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210 \
--hash=sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc \
--hash=sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a \
--hash=sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5 \
--hash=sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7 \
--hash=sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2 \
--hash=sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326 \
--hash=sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f \
--hash=sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca \
--hash=sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98 \
--hash=sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9 \
--hash=sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5 \
--hash=sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7 \
--hash=sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc \
--hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \
--hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f
# via weasyprint
cfgv==3.5.0 \
--hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \
--hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132
# via pre-commit
cssselect2==0.9.0 \
--hash=sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563 \
--hash=sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb
# via weasyprint
distlib==0.4.3 \
--hash=sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b \
--hash=sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed
# via virtualenv
filelock==3.30.0 \
--hash=sha256:1774e682dbe443bd60f9609162fc596e2c80dc84ffc2957068953406d0520090 \
--hash=sha256:40632998f0772e64183bb819f086a1b9def6be1090cf1dcb9d45f46806ef279b
# via
# python-discovery
# virtualenv
fonttools==4.63.0 \
--hash=sha256:032038247a96c1690f9f31e377c389383c902531b085aa4e4dabd6f57f870e69 \
--hash=sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c \
--hash=sha256:0c18358a155d75034911c5ee397a5b44cd19dd325dbb8b35fb60bf421d6a72ac \
--hash=sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096 \
--hash=sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d \
--hash=sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68 \
--hash=sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616 \
--hash=sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78 \
--hash=sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f \
--hash=sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b \
--hash=sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b \
--hash=sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02 \
--hash=sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d \
--hash=sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f \
--hash=sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8 \
--hash=sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272 \
--hash=sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49 \
--hash=sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419 \
--hash=sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001 \
--hash=sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03 \
--hash=sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196 \
--hash=sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9 \
--hash=sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e \
--hash=sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5 \
--hash=sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007 \
--hash=sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380 \
--hash=sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8 \
--hash=sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27 \
--hash=sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40 \
--hash=sha256:a8b33a82979e0a6a34ff435cc81317be1f95ec1ebb7a3a2d1c8a6a54f02ae44e \
--hash=sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0 \
--hash=sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263 \
--hash=sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb \
--hash=sha256:b1cd75a03ad8cb5bc40c90bfde68c0c47de423aa19e5c0f362b43520645eea94 \
--hash=sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b \
--hash=sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6 \
--hash=sha256:c0425b277a59cff3d80ca42162a8de360f318438a2ac83570842a678d826d579 \
--hash=sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4 \
--hash=sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59 \
--hash=sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0 \
--hash=sha256:cb014d58140a38135f16064c74c652ed57aa0b75cbf8bb59cac821f7edb5334e \
--hash=sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be \
--hash=sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd \
--hash=sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18 \
--hash=sha256:d7e5c9973aa04c95650c96e5f5ad865fbf42d62079163ecfab1e01cbc2504c22 \
--hash=sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0 \
--hash=sha256:e3297a6a4059b4acc3a1e9a8b04741f240a80044eef08ebd32e8b5bcdddce75b \
--hash=sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b \
--hash=sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af \
--hash=sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745
# via weasyprint
identify==2.6.19 \
--hash=sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a \
--hash=sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842
# via pre-commit
nodeenv==1.10.0 \
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
# via pre-commit
numpy==2.4.6 \
--hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \
--hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \
--hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \
--hash=sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079 \
--hash=sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096 \
--hash=sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47 \
--hash=sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66 \
--hash=sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d \
--hash=sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1 \
--hash=sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e \
--hash=sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147 \
--hash=sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd \
--hash=sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75 \
--hash=sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063 \
--hash=sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73 \
--hash=sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab \
--hash=sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4 \
--hash=sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 \
--hash=sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402 \
--hash=sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698 \
--hash=sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7 \
--hash=sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8 \
--hash=sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b \
--hash=sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8 \
--hash=sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0 \
--hash=sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662 \
--hash=sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91 \
--hash=sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0 \
--hash=sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f \
--hash=sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3 \
--hash=sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f \
--hash=sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67 \
--hash=sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6 \
--hash=sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997 \
--hash=sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b \
--hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \
--hash=sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538 \
--hash=sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627 \
--hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93 \
--hash=sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02 \
--hash=sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 \
--hash=sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c \
--hash=sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43 \
--hash=sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd \
--hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \
--hash=sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089 \
--hash=sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778 \
--hash=sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1 \
--hash=sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb \
--hash=sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 \
--hash=sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb \
--hash=sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a \
--hash=sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8 \
--hash=sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359 \
--hash=sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 \
--hash=sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7 \
--hash=sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 \
--hash=sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8 \
--hash=sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605 \
--hash=sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e \
--hash=sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45 \
--hash=sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2 \
--hash=sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895 \
--hash=sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe \
--hash=sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb \
--hash=sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a \
--hash=sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577 \
--hash=sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d \
--hash=sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a \
--hash=sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda \
--hash=sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6 \
--hash=sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20
# via opencv-python-headless
opencv-python-headless==5.0.0.93 \
--hash=sha256:030ca5e0837a2963ab36ef896baa9767eb8d2b83353fb28af5a521e40dd8756f \
--hash=sha256:09a872a157c1376ab922a69bbf22f9a95bcc7b658a9d8b436a60212b02b2eeb4 \
--hash=sha256:10818d91510e05c04568ae12b5cd120779c70c01bf897b001a6221fe430df80f \
--hash=sha256:1e55af3abfb462eeeabe5c775f12bdb36216d8a93a3583d69e6bd6e1d6ba7d00 \
--hash=sha256:829717b6a95554f273e49e357cee3b3a2a26b6f4842fbc1bed2b45bdd8f87e0e \
--hash=sha256:840bd717c21e5c11cadadc022a823315ea417f961213d06b4df010e019eb16f4 \
--hash=sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c \
--hash=sha256:c6bcd96b185975ea240d22cfdb15a1f6d080cc95264cfbe2621f21bb144d89b9 \
--hash=sha256:ed709fdf9aa0bd1f2ed8549e71d19449b03a675bb581eb292285f6861953be37
# via -r .github/scripts/requirements_dev.in
pdf2image==1.17.0 \
--hash=sha256:eaa959bc116b420dd7ec415fcae49b98100dda3dd18cd2fdfa86d09f112f6d57 \
--hash=sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2
# via -r .github/scripts/requirements_dev.in
pillow==12.3.0 \
--hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \
--hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \
--hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \
--hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \
--hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \
--hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \
--hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \
--hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \
--hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \
--hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \
--hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \
--hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \
--hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \
--hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \
--hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \
--hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \
--hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \
--hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \
--hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \
--hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \
--hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \
--hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \
--hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \
--hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \
--hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \
--hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \
--hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \
--hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \
--hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \
--hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \
--hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \
--hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \
--hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \
--hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \
--hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \
--hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \
--hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \
--hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \
--hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \
--hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \
--hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \
--hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \
--hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \
--hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \
--hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \
--hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \
--hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \
--hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \
--hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \
--hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \
--hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \
--hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \
--hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \
--hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \
--hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \
--hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \
--hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \
--hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \
--hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \
--hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \
--hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \
--hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \
--hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \
--hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \
--hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \
--hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \
--hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \
--hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \
--hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \
--hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \
--hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \
--hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \
--hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \
--hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \
--hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \
--hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \
--hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \
--hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \
--hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \
--hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \
--hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \
--hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \
--hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \
--hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \
--hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \
--hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \
--hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7
# via
# -r .github/scripts/requirements_dev.in
# pdf2image
# weasyprint
platformdirs==4.10.0 \
--hash=sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7 \
--hash=sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a
# via
# python-discovery
# virtualenv
pre-commit==4.6.0 \
--hash=sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9 \
--hash=sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b
# via -r .github/scripts/requirements_dev.in
pycparser==3.0 \
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
# via cffi
pydyf==0.12.1 \
--hash=sha256:ea25b4e1fe7911195cb57067560daaa266639184e8335365cc3ee5214e7eaadc \
--hash=sha256:fbd7e759541ac725c29c506612003de393249b94310ea78ae44cb1d04b220095
# via weasyprint
pyphen==0.17.2 \
--hash=sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd \
--hash=sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3
# via weasyprint
python-discovery==1.4.4 \
--hash=sha256:5cad33982d412c1f3ffb8f9ca4ea292c9680bca3942451d30b69c37fce53a4a3 \
--hash=sha256:abebe9120b43453b68c908acfb1e72a19d1a959ed2cb620ad38fc57d08056dbe
# via virtualenv
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
# via pre-commit
tinycss2==1.5.1 \
--hash=sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661 \
--hash=sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957
# via
# cssselect2
# weasyprint
tinyhtml5==2.1.0 \
--hash=sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67 \
--hash=sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a
# via weasyprint
unoserver==3.7 \
--hash=sha256:b05f9578506ac7374ae1b314c3a79528636c542ac78220a9ce99110584ca424b \
--hash=sha256:fc44e6808071c9d2957e705ecf1742cea8a582aa5d5cc23babf36bb332ec6e8e
# via -r .github/scripts/requirements_dev.in
virtualenv==21.6.1 \
--hash=sha256:15f978b7cd329f24855ff4a0c4b4899cc7678589f49adbdcbbb4d3232e641128 \
--hash=sha256:afe991df855715a2b2f60edfcc0107ef95a79fdfd8cb4cdaa71603d1c12e463b
# via pre-commit
weasyprint==69.0 \
--hash=sha256:475951cfd917014de6d4d005caff48c6aa867e7e42b80cd5b16a0484a1609ee6 \
--hash=sha256:a7a32f39ca16bd82ef11de99c92ea4b5f14951c9033af035e451ce4f4ee0a88c
# via -r .github/scripts/requirements_dev.in
webencodings==0.5.1 \
--hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \
--hash=sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923
# via
# cssselect2
# tinycss2
# tinyhtml5
zopfli==0.4.3 \
--hash=sha256:0087c9a6f0c8a052be0f6d1a9bb71b6caffdd3e10201d6d6166e28d482cebe6d \
--hash=sha256:47604eee5c6704bdf0e94d8391fe3b74ddb2abd84128fbcfdc3ee0fc265feaef \
--hash=sha256:62248dbf8dbcbd588ee194b210e5be9fa80bce29641f55599d6d394bd2a9d8a3 \
--hash=sha256:628c3e941752880b3491db8d44163d0aedb221944e22a17187ff7fc549b050f6 \
--hash=sha256:769875152d0625c46707bcca57d4b2233fe653482067acd55fbf6ec525cb9bdc \
--hash=sha256:7e9703ca6e7ef66c8d05e0826b6f558b680c9db8206f84f05a3ee93430a12e42 \
--hash=sha256:7fa3c35193475290e3f007bbcdebdbae64ba2f012d75c632da0d727e1da50d5e \
--hash=sha256:88f4fbe429aad72bc206275d81fab11a097e0f951a5848d1f51083c37ea73073 \
--hash=sha256:921c2c9907f4364963848da5ad194b46d68865e07fdb975d04fd09bc42d47357 \
--hash=sha256:d3a50f91a13cea9bafe025de8fd87a005eb26de02a4f0c193127ddbf23ac8ebe \
--hash=sha256:d4f51dd1ab5312e837e2091284e0d9f1a138188f2e65812f9a5799dc02c45f94 \
--hash=sha256:eb0c9c1d40a8cb1d58762d7e57290ccb753e0828c4d01be8acb59aae5d0ca206 \
--hash=sha256:f2e0adcf7d36c6fd0dd36cc771ef7f0c5803a05666feafcd90d7170174a4148e
# via fonttools
# The following packages are considered to be unsafe in a requirements file:
pip==26.1.2 \
--hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \
--hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605
# via -r .github/scripts/requirements_dev.in
setuptools==83.0.0 \
--hash=sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef \
--hash=sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3
# via -r .github/scripts/requirements_dev.in
@@ -1,2 +0,0 @@
tomlkit
tomli-w
@@ -1,14 +0,0 @@
#
# This file is autogenerated by pip-compile with Python 3.13
# by the following command:
#
# 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.15.0 \
--hash=sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738 \
--hash=sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3
# via -r .github/scripts/requirements_sync_readme.in
+6 -7
View File
@@ -4,19 +4,18 @@
Usage: verify-updater-signatures.py <dir-to-scan> [tauri.conf.json]
"""
import binascii
import sys
import json
import base64
import binascii
import hashlib
import json
import sys
from pathlib import Path
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
ART_ROOT = Path(sys.argv[1])
CONF = Path(
sys.argv[2] if len(sys.argv) > 2 else "frontend/editor/src-tauri/tauri.conf.json"
)
CONF = Path(sys.argv[2] if len(sys.argv) > 2 else "frontend/editor/src-tauri/tauri.conf.json")
def load_pubkey():
+2 -2
View File
@@ -86,7 +86,7 @@ jobs:
fi
fi
else
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs")
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs")
is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done
if [ "$is_auth" = true ]; then
should=true
@@ -193,7 +193,7 @@ jobs:
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
@@ -54,7 +54,6 @@ jobs:
github.event.comment.user.login == 'Ludy87' ||
github.event.comment.user.login == 'balazs-szucs' ||
github.event.comment.user.login == 'reecebrowne' ||
github.event.comment.user.login == 'DarioGii' ||
github.event.comment.user.login == 'EthanHealy01' ||
github.event.comment.user.login == 'jbrunton96' ||
github.event.comment.user.login == 'ConnorYoh'
@@ -206,15 +205,21 @@ jobs:
token: ${{ steps.setup-bot.outputs.token }}
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -236,7 +241,7 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Login to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
@@ -259,7 +264,7 @@ jobs:
if: needs.check-comment.outputs.enable_prototypes == 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: ./engine
context: .
file: ./engine/Dockerfile
push: true
cache-from: type=gha,scope=stirling-pdf-engine
+11
View File
@@ -31,6 +31,9 @@ jobs:
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
cache-suffix: ai-engine
- name: Install Task
@@ -96,6 +99,14 @@ jobs:
echo "============================================"
exit 1
- name: Build engine production image
if: always()
run: docker build --file engine/Dockerfile --tag stirling-pdf-engine:ci .
- name: Build engine development image
if: always()
run: docker build --file engine/Dockerfile.dev --tag stirling-pdf-engine-dev:ci .
- name: Remove engine check comment on success
if: steps.engine-check.outcome == 'success' && github.event_name == 'pull_request'
continue-on-error: true
+15 -17
View File
@@ -35,25 +35,21 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK ${{ matrix.jdk-version }}
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -196,12 +192,14 @@ jobs:
retention-days: 3
if-no-files-found: warn
- name: Install defusedxml for coverage summary
# coverage-summary.py parses JaCoCo XML through defusedxml to
# silence security scanners that pattern-match on the stdlib
# xml.etree.ElementTree.parse call.
- name: Install uv
if: always() && matrix.flavor == 'saas'
run: python -m pip install --quiet defusedxml
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: JaCoCo coverage step summary
# Only the saas leg posts the JUnit summary - it's a strict
@@ -210,7 +208,7 @@ jobs:
# near-identical tables crowding out the aggregate report.
if: always() && matrix.flavor == 'saas'
run: |
python scripts/coverage-summary.py \
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
--title "Backend JUnit coverage (JDK ${{ matrix.jdk-version }})" \
--jacoco "common=app/common/build/reports/jacoco/test/jacocoTestReport.xml" \
--jacoco "core=app/core/build/reports/jacoco/test/jacocoTestReport.xml" \
+20 -9
View File
@@ -56,10 +56,20 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -319,15 +329,16 @@ jobs:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: ./testing/cucumber/requirements.txt
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: Install behave test deps
run: |
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
uv sync --project engine --locked --group cucumber
- name: Build the multi-node image
working-directory: testing/compose
run: docker compose -f "$MN_COMPOSE" build
@@ -350,10 +361,10 @@ jobs:
- name: Run multi-node regression (implemented guarantees)
working-directory: testing/cucumber
# -e overrides behave.ini's exclusion of features/multinode; ~@known_gap skips any tracked-gap scenarios.
run: python -m behave features/multinode -e "features/enterprise" --tags="~@known_gap ~@destructive" --no-capture -f plain
run: uv run --project ../../engine --locked --group cucumber python -m behave features/multinode -e "features/enterprise" --tags="~@known_gap ~@destructive" --no-capture -f plain
- name: Run multi-node failover (destructive)
working-directory: testing/cucumber
run: python -m behave features/multinode -e "features/enterprise" --tags="@destructive ~@known_gap" --no-capture -f plain
run: uv run --project ../../engine --locked --group cucumber python -m behave features/multinode -e "features/enterprise" --tags="@destructive ~@known_gap" --no-capture -f plain
- name: Dump node logs on failure
if: failure()
working-directory: testing/compose
+44 -9
View File
@@ -60,8 +60,43 @@ jobs:
with:
filters: .github/config/.files.yaml
build:
gradle-cache-prime:
name: Prime shared Gradle cache
needs: [files-changed]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Resolve backend dependencies
run: ./gradlew :stirling-pdf:classes -PnoSpotless --no-daemon
env:
STIRLING_FLAVOR: saas
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
build:
needs: [files-changed, gradle-cache-prime]
permissions:
actions: read
contents: read
@@ -76,7 +111,7 @@ jobs:
# works after Hibernate's ddl-auto=update migrates the schema. Gated on
# the `project` filter so doc-only PRs skip this ~5-minute job.
if: needs.files-changed.outputs.project == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
uses: ./.github/workflows/db-migration-test.yml
@@ -84,7 +119,7 @@ jobs:
check-generateOpenApiDocs:
if: needs.files-changed.outputs.openapi == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
uses: ./.github/workflows/check-openapi.yml
@@ -120,7 +155,7 @@ jobs:
playwright-e2e-live:
if: needs.files-changed.outputs.frontend == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
uses: ./.github/workflows/e2e-live.yml
@@ -128,7 +163,7 @@ jobs:
playwright-e2e-enterprise:
if: needs.files-changed.outputs.proprietary == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
uses: ./.github/workflows/build-enterprise.yml
@@ -136,7 +171,7 @@ jobs:
check-licence:
if: needs.files-changed.outputs.build == 'true'
needs: [files-changed, build]
needs: [files-changed, build, gradle-cache-prime]
permissions:
contents: read
uses: ./.github/workflows/check-licence.yml
@@ -144,7 +179,7 @@ jobs:
docker-compose-tests:
if: needs.files-changed.outputs.project == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
actions: write
contents: read
@@ -156,7 +191,7 @@ jobs:
test-build-docker-images:
if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true'
needs: [files-changed, build, check-generateOpenApiDocs, check-licence]
needs: [files-changed, build, check-generateOpenApiDocs, check-licence, gradle-cache-prime]
permissions:
contents: read
packages: read
@@ -199,7 +234,7 @@ jobs:
# frontend filter, so a CSS-only PR does not pay for a backend build.
generated-models:
if: needs.files-changed.outputs.generated-models == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
pull-requests: write
+13 -4
View File
@@ -34,18 +34,27 @@ jobs:
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
cache-suffix: generated-models
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.0
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+7 -11
View File
@@ -21,25 +21,21 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+7 -11
View File
@@ -22,25 +22,21 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+8 -5
View File
@@ -195,20 +195,23 @@ jobs:
console.log(`Reference file path: ${referenceFilePath}`);
core.exportVariable("REFERENCE_FILE", referenceFilePath);
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.12"
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: Install Python dependencies
run: |
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
uv sync --project engine --locked --group tools
- name: Run Python script to check files
id: run-check
run: |
echo "Running Python script to check TOML files..."
python .github/scripts/check_language_toml.py \
uv run --project engine --locked --group tools python .github/scripts/check_language_toml.py \
--actor ${{ github.event.pull_request.user.login }} \
--reference-file "${REFERENCE_FILE}" \
--branch "pr-branch" \
+19 -25
View File
@@ -41,35 +41,29 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
gradle-version: 9.6.1
cache-disabled: true
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install defusedxml for coverage scripts
# Both coverage-summary.py and coverage-matrix.py parse JaCoCo
# XML through defusedxml - see the script headers for context.
run: python -m pip install --quiet defusedxml
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
# Pattern matches every artifact this PR's producers might upload:
# jacoco-exec-junit-jdk-25 (uploaded only by the saas
@@ -81,7 +75,7 @@ jobs:
# Each lands as a sibling dir under coverage-execs/, with the .exec
# files preserving their original relative paths.
- name: Download all .exec artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: jacoco-exec-*
path: coverage-execs/
@@ -162,7 +156,7 @@ jobs:
# ("how much of the backend do real user flows cover?").
if: steps.inventory.outputs.found_e2e == 'true'
run: |
python scripts/coverage-summary.py \
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
--title "Real user-flow backend coverage (e2e:live + cucumber)" \
--jacoco "merged=build/reports/jacoco/aggregate-e2e/jacocoTestReport.xml" \
--github-step-summary
@@ -173,7 +167,7 @@ jobs:
# is meaningless when one is a strict superset of the other.
if: steps.inventory.outputs.found_all == 'true'
run: |
python scripts/coverage-summary.py \
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
--title "Combined backend coverage (JUnit + e2e:live + cucumber)" \
--jacoco "merged=build/reports/jacoco/aggregate-all/jacocoTestReport.xml" \
--github-step-summary
@@ -206,7 +200,7 @@ jobs:
# absence on backend-only runs by skipping the download entirely
# when the producer job was not part of this workflow run.
if: inputs.frontend-validation-result == 'success'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: frontend-coverage
path: matrix-inputs/vitest/
@@ -216,7 +210,7 @@ jobs:
# e2e-live uploads the artifact with a stable name. Skip the
# download entirely when the producer job did not run.
if: inputs.playwright-e2e-live-result == 'success'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: playwright-frontend-coverage
path: matrix-inputs/playwright/
@@ -228,7 +222,7 @@ jobs:
# generated above) plus whichever frontend artifacts landed.
# Every input is optional; missing ones render as "-".
run: |
python scripts/coverage-matrix.py \
uv run --project engine --locked --group tools python scripts/coverage-matrix.py \
${{ steps.inventory.outputs.found_all == 'true' && '--jacoco-all build/reports/jacoco/aggregate-all/jacocoTestReport.xml' || '' }} \
${{ steps.inventory.outputs.found_e2e == 'true' && '--jacoco-e2e build/reports/jacoco/aggregate-e2e/jacocoTestReport.xml' || '' }} \
--vitest matrix-inputs/vitest/coverage-summary.json \
+9 -13
View File
@@ -25,28 +25,24 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: 25
distribution: temurin
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
# No `-PnoSpotless` here yet because the upstream cache layer matches the
# backend build's; reuse keeps cold-cache cost identical.
# Keep the normal formatting path here so this smoke test exercises the
# same Gradle configuration as the backend build.
- name: Build Stirling-PDF JAR
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
+1 -1
View File
@@ -85,7 +85,7 @@ jobs:
fi
- name: Login to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
+16 -25
View File
@@ -33,25 +33,21 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
# When the PR changes the base image, test.sh builds it locally
# (stirling-pdf-base:local) into the daemon image store. A buildx
@@ -73,16 +69,17 @@ jobs:
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.39.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
cache-dependency-path: ./testing/cucumber/requirements.txt
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: Pip requirements
- name: Install Cucumber and coverage dependencies
run: |
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
uv sync --project engine --locked --group cucumber --group tools
- name: Extract JaCoCo agent for cucumber coverage
# Stages build/jacoco/jacocoagent.jar where the coverage override
@@ -125,16 +122,10 @@ jobs:
echo "report=false" >> "$GITHUB_OUTPUT"
fi
- name: Install defusedxml for coverage summary
# coverage-summary.py parses JaCoCo XML through defusedxml -
# see the script header for context.
if: always() && steps.cucumber-coverage.outputs.report == 'true'
run: python -m pip install --quiet defusedxml
- name: Cucumber coverage step summary
if: always() && steps.cucumber-coverage.outputs.report == 'true'
run: |
python scripts/coverage-summary.py \
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
--title "Cucumber (docker) JaCoCo coverage" \
--jacoco "cucumber=build/reports/jacoco/cucumber/jacocoTestReport.xml" \
--github-step-summary
+20 -44
View File
@@ -21,25 +21,20 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
# Same cache layer as backend-build.yml. Without it every run resolved the
# whole classpath cold and eventually got HTTP 429 from Maven Central.
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
# Gradle does not retry 429s, and a cold cache resolving the buildscript
# classpath is exactly where Maven Central rate-limits us. Retry it here,
# where a failure is cheap, instead of inside the backgrounded bootRun.
@@ -124,20 +119,18 @@ jobs:
echo "::warning::No e2e:live .exec found at .test-state/playwright/jacoco.exec; skipping report"
echo "report=false" >> "$GITHUB_OUTPUT"
fi
- name: Set up Python for coverage summary
if: always() && steps.live-coverage.outputs.report == 'true'
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- name: Install uv
if: always()
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.12"
- name: Install defusedxml for coverage summary
# coverage-summary.py uses defusedxml instead of stdlib xml.etree
# to dodge XXE / billion-laughs scanner findings.
if: always() && steps.live-coverage.outputs.report == 'true'
run: python -m pip install --quiet defusedxml
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: e2e:live coverage step summary
if: always() && steps.live-coverage.outputs.report == 'true'
run: |
python scripts/coverage-summary.py \
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
--title "Playwright (live backend) JaCoCo coverage" \
--jacoco "e2e-live=build/reports/jacoco/e2e-live/jacocoTestReport.xml" \
--github-step-summary
@@ -160,23 +153,6 @@ jobs:
retention-days: 7
if-no-files-found: warn
- name: Set up Python for frontend coverage summary
# Separate from the backend-coverage python step because the
# frontend path doesn't depend on a JaCoCo report - it produces
# a summary even on backend failure, as long as some Playwright
# tests ran far enough to dump V8 coverage.
if: always()
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install defusedxml for frontend coverage summary
# Idempotent re-install: the backend-coverage step may have
# installed it already, but this leg can run on its own when the
# backend report step skips (e.g. .exec missing).
if: always()
run: python -m pip install --quiet defusedxml
- name: Aggregate Playwright frontend (V8) coverage
# Rolls per-test V8 dumps from the test-base fixture into one
# vitest-shaped coverage-summary.json. Tolerates a missing dump
@@ -187,7 +163,7 @@ jobs:
run: |
if [ -d .test-state/playwright/coverage-pw ] && \
find .test-state/playwright/coverage-pw -name '*.json' -type f | grep -q .; then
python scripts/playwright-coverage-summary.py \
uv run --project engine --locked --group tools python scripts/playwright-coverage-summary.py \
.test-state/playwright/coverage-pw \
--out .test-state/playwright/coverage-pw-summary/coverage-summary.json
echo "summary=true" >> "$GITHUB_OUTPUT"
@@ -199,10 +175,10 @@ jobs:
- name: Playwright frontend coverage step summary
if: always() && steps.pw-frontend-coverage.outputs.summary == 'true'
run: |
python scripts/coverage-summary.py \
--title "Playwright (live) frontend coverage" \
--vitest .test-state/playwright/coverage-pw-summary/coverage-summary.json \
--github-step-summary
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
--title "Playwright (live) frontend coverage" \
--vitest .test-state/playwright/coverage-pw-summary/coverage-summary.json \
--github-step-summary
- name: Upload Playwright frontend coverage
# Bundle both the aggregated summary and the raw V8 dumps so
+19 -6
View File
@@ -2,7 +2,8 @@ name: Playwright E2E (stubbed)
# Reusable workflow called from build.yml. Backend-free Playwright suite —
# fast, no Spring Boot required. Runs against the `stubbed` project which
# mocks API responses in the browser.
# mocks API responses in the browser. Fans out one job per browser
# (chromium/firefox/webkit) so all three run in parallel on their own runner.
on:
workflow_call:
@@ -11,7 +12,19 @@ permissions:
jobs:
playwright-e2e:
name: playwright-e2e (${{ matrix.browser }})
runs-on: ubuntu-latest
strategy:
# One browser breaking must not mask a failure in another - report all.
fail-fast: false
matrix:
include:
- browser: chromium
project: stubbed
- browser: firefox
project: stubbed-firefox
- browser: webkit
project: stubbed-webkit
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
@@ -27,16 +40,16 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Install Playwright (chromium only)
run: task e2e:install -- chromium
- name: Install Playwright (${{ matrix.browser }})
run: task e2e:install -- ${{ matrix.browser }}
- name: Build frontend (production bundle for vite preview)
env:
VITE_BUILD_FOR_PREVIEW: "1"
run: task frontend:build
- name: Run stubbed E2E tests (chromium)
- name: Run stubbed E2E tests (${{ matrix.browser }})
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
run: task e2e:stubbed -- --workers=3
run: task e2e:stubbed-project PROJECT=${{ matrix.project }} -- --workers=3
- name: Flag flaky tests
# Runs regardless of the test outcome: a flaky test (passed on retry)
# leaves the step green, so this is the only place it surfaces. Emits
@@ -50,6 +63,6 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-stubbed-${{ github.run_id }}
name: playwright-report-stubbed-${{ matrix.browser }}-${{ github.run_id }}
path: frontend/playwright-report/
retention-days: 7
@@ -345,15 +345,21 @@ jobs:
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+9 -21
View File
@@ -1,8 +1,8 @@
name: Frontend lint, type-check, and build
# Reusable workflow called from build.yml when frontend / testing sources
# change. Runs the consolidated `task frontend:check:all` (lint, types,
# unit tests, build) and uploads the dist artifact for downstream jobs.
# change. Runs `task frontend:check:all` and uploads the
# coverage + dist artifacts for downstream jobs.
on:
workflow_call:
@@ -105,30 +105,18 @@ jobs:
comment_id: existing.id,
});
}
- name: Vitest coverage
# Separate from `frontend:check:all` so the quality-gate run stays
# uninstrumented (faster signal) and coverage stays an informational
# follow-up. Continue-on-error keeps the workflow green even when
# a handful of test files refuse to import (e.g. missing icon
# specifiers) - the summary still gets posted with whatever
# vitest managed to instrument.
id: frontend-coverage
continue-on-error: true
run: task frontend:test:coverage
- name: Set up Python for coverage summary
- name: Install uv
if: always()
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.12"
- name: Install defusedxml for coverage summary
# See coverage-summary.py header - it parses XML through defusedxml
# to dodge the stdlib parser's exposure to XXE / billion-laughs.
if: always()
run: python -m pip install --quiet defusedxml
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: Vitest coverage step summary
if: always()
run: |
python scripts/coverage-summary.py \
uv run --project engine --locked --group tools python scripts/coverage-summary.py \
--title "Frontend Vitest coverage" \
--vitest frontend/editor/coverage/coverage-summary.json \
--github-step-summary
+36 -23
View File
@@ -52,25 +52,21 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependencies
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -146,15 +142,21 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Setup Node.js
if: matrix.variant.build_frontend == true
@@ -238,7 +240,7 @@ jobs:
# before the second setup-java overwrites JAVA_HOME.
- name: Set up x86_64 JDK 25 (macOS universal JRE)
if: matrix.platform == 'macos-15'
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
@@ -250,15 +252,21 @@ jobs:
# Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does.
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -467,7 +475,7 @@ jobs:
fi
- name: Build Tauri app
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
@@ -497,7 +505,6 @@ jobs:
projectPath: ./frontend/editor
tauriScript: npx tauri
args: ${{ matrix.args }}
updaterJsonKeepUniversal: true
# Bundled libwayland conflicts with the host's on some distros (Fedora
# Wayland: EGL_BAD_PARAMETER, blank window - #6878). Repack without it,
@@ -880,10 +887,16 @@ jobs:
# Gate publish on valid updater sigs. Runs after the review upload (so
# artifacts survive for debugging) and before action-gh-release.
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
- name: Verify updater signatures
run: |
python3 -m pip install --quiet 'cryptography==44.0.0'
python3 .github/scripts/verify-updater-signatures.py \
uv run --project engine --locked --only-group updater-signatures python .github/scripts/verify-updater-signatures.py \
./artifacts/tauri frontend/editor/src-tauri/tauri.conf.json
# workflow_dispatch path requires platform=='all' so a single-platform
@@ -894,7 +907,7 @@ jobs:
# instead of silently shipping a broken auto-update.
- name: Upload binaries to Release
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master'
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
tag_name: v${{ needs.determine-matrix.outputs.version }}
# Don't regenerate/append notes on re-runs, and don't force this into the
+4 -3
View File
@@ -59,9 +59,10 @@ jobs:
# the story itself — a shared component, a theme token — still surfaces within
# a day.
a11y-all-stories:
name: a11y (every story)
name: a11y (every story, light + dark)
runs-on: ubuntu-latest
timeout-minutes: 60
# Two full sweeps (one per theme), each ~30 minutes of browser time.
timeout-minutes: 120
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
@@ -81,7 +82,7 @@ jobs:
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: a11y gate (every story)
- name: a11y gate (every story, light + dark)
run: task frontend:storybook:a11y
- name: Upload scan reports
+3
View File
@@ -28,6 +28,9 @@ jobs:
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
cache-suffix: pre-commit
- name: Install Task
+4 -4
View File
@@ -50,13 +50,13 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -67,7 +67,7 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Convert repository owner to lowercase
id: repoowner
@@ -75,7 +75,7 @@ jobs:
- name: Generate tags for base image
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: |
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf-base
+11 -15
View File
@@ -60,25 +60,21 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependencies
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Set up Docker Buildx
id: buildx
@@ -107,20 +103,20 @@ jobs:
cosign-release: "v2.4.1"
- name: Login to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Convert repository owner to lowercase
id: repoowner
@@ -129,7 +125,7 @@ jobs:
- name: Generate tags for latest
id: meta
if: env.RUN_MAIN_APP == 'true'
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
@@ -177,7 +173,7 @@ jobs:
- name: Generate tags for latest-fat
id: meta-fat
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
with:
images: |
@@ -221,7 +217,7 @@ jobs:
- name: Generate tags for ultra-lite
id: meta-lite
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
with:
images: |
+2 -2
View File
@@ -26,13 +26,13 @@ jobs:
uses: imjasonh/setup-crane@31b88afe9de28ae0ffa220711af4b60be9435f6e # v0.4
- name: Login to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
with:
results_file: results.sarif
results_format: sarif
+10 -4
View File
@@ -34,15 +34,21 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
+9 -12
View File
@@ -51,28 +51,25 @@ jobs:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
- name: Install Python dependencies
run: |
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
cache-dependency-glob: |
engine/pyproject.toml
engine/uv.lock
cache-suffix: sync-files
- name: Install Python dependencies
run: |
uv sync --project engine --locked --group tools
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
- name: Sync translation TOML files
run: |
python .github/scripts/check_language_toml.py --reference-file "frontend/editor/public/locales/en-US/translation.toml" --branch main
uv run --project engine --locked --group tools python .github/scripts/check_language_toml.py --reference-file "frontend/editor/public/locales/en-US/translation.toml" --branch main
- name: Sort translation TOML files
run: |
@@ -85,7 +82,7 @@ jobs:
- name: Sync README.md
run: |
python scripts/counter_translation_v3.py
uv run --project engine --locked --group tools python scripts/counter_translation_v3.py
- name: Run git add
run: |
+14 -8
View File
@@ -162,7 +162,7 @@ jobs:
- name: Set up x86_64 JDK 25 (macOS universal JRE)
if: matrix.platform == 'macos-15'
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
@@ -174,15 +174,21 @@ jobs:
# Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does.
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Setup Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -380,7 +386,7 @@ jobs:
- name: Build Tauri app (signed)
if: env.SIGN_BUNDLE == 'true'
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
@@ -415,7 +421,7 @@ jobs:
- name: Build Tauri app (unsigned)
if: env.SIGN_BUNDLE != 'true'
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SIGN: "0"
@@ -441,7 +447,7 @@ jobs:
- name: Build Tauri app (Linux AppImage)
if: matrix.platform == 'ubuntu-22.04' && !inputs.minimal
continue-on-error: true
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SIGN: ${{ (inputs.sign && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
+10 -14
View File
@@ -61,7 +61,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to GitHub Container Registry
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -79,25 +79,21 @@ jobs:
echo "Disk space after cleanup:" && df -h
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle dependency artifacts
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/caches/modules-2/files-2.1
~/.gradle/caches/modules-2/metadata-2.*
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.1
cache-disabled: true
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -111,7 +107,7 @@ jobs:
STIRLING_PDF_DESKTOP_UI: false
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx
id: buildx
@@ -203,7 +199,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx
id: buildx
+11 -5
View File
@@ -33,15 +33,21 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
gradle-version: 9.6.1
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Build with Gradle
run: ./gradlew build
@@ -61,7 +67,7 @@ jobs:
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
+3
View File
@@ -22,6 +22,9 @@ pipeline/
customFiles/
configs/
watchedFolders/
# The rule above targets the app's runtime watched-folders working dir, but it
# also matches this frontend source component dir; keep the source tracked.
!frontend/editor/src/proprietary/components/watchedFolders/
clientWebUI/
# Scratch dir used by local fixture-regeneration runs (see
# app/proprietary/src/test/resources/db-migration-fixtures/README.md).
+1 -1
View File
@@ -22,7 +22,7 @@ frontend/editor/src/portal/components/docs/GettingStartedSection.tsx:generic-api
# False positive: generic-api-key matches the Java type name "X509Certificate"
# in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret.
app/core/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java:generic-api-key:224
app/core/src/main/java/stirling/software/SPDF/pdf/signature/CreateSignatureBase.java:generic-api-key:224
# Supabase publishable key (public by design, RLS-protected) used as a CI fallback
# default in the tauri-build workflow when the GitHub secret is unset - not a real secret.
+1
View File
@@ -146,6 +146,7 @@ tasks:
swagger:
desc: "Generate OpenAPI docs"
run: once
cmds:
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:copySwaggerDoc"
platforms: [windows]
+9
View File
@@ -15,6 +15,15 @@ tasks:
cmds:
- npx playwright test --project=stubbed {{.CLI_ARGS}}
stubbed-project:
desc: "Run the stubbed E2E suite for a single Playwright project"
dir: frontend/editor
deps: [ ':frontend:prepare' ]
vars:
PROJECT: '{{.PROJECT | default "stubbed"}}'
cmds:
- npx playwright test --project={{.PROJECT}} {{.CLI_ARGS}}
live:
desc: "Run live E2E tests"
summary: |
+40 -13
View File
@@ -2,22 +2,49 @@ version: '3'
tasks:
install:
desc: "Install engine dependencies"
desc: "Install engine runtime and development dependencies"
run: once
cmds:
- uv python install 3.13.8
- uv sync
- uv sync --locked --group engine --group engine-dev
sources:
- uv.lock
- pyproject.toml
status:
- test -d .venv
lock:
desc: "Update the engine lockfile from project metadata"
cmds:
- uv lock
lock:upgrade:
desc: "Upgrade allowed engine dependencies and update the lockfile"
cmds:
- uv lock --upgrade
lock:check:
desc: "Check whether the engine lockfile is current"
cmds:
- uv lock --check
update:
desc: "Upgrade engine dependencies and synchronize the environment"
cmds:
- task: lock:upgrade
- uv sync --locked --group engine --group engine-dev
update:all:
desc: "Upgrade all Python dependency groups and synchronize the environment"
cmds:
- task: lock:upgrade
- uv sync --locked --all-groups
prepare:
desc: "Set up engine .env from template"
deps: [install]
cmds:
- uv run scripts/setup_env.py
- uv run --locked --group engine --group engine-dev scripts/setup_env.py
sources:
- scripts/setup_env.py
generates:
@@ -33,7 +60,7 @@ tasks:
env:
PYTHONUNBUFFERED: "1"
cmds:
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --workers "${STIRLING_ENGINE_WORKERS:-4}"
- uv run --locked --group engine uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --workers "${STIRLING_ENGINE_WORKERS:-4}"
dev:
desc: "Start engine dev server with hot reload"
@@ -45,43 +72,43 @@ tasks:
env:
PYTHONUNBUFFERED: "1"
cmds:
- uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --reload
- uv run --locked --group engine --group engine-dev uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --reload
lint:
desc: "Run linting"
deps: [install]
cmds:
- uv run ruff check .
- uv run --locked --group engine --group engine-dev ruff check .
lint:fix:
desc: "Auto-fix lint issues"
deps: [install]
cmds:
- uv run ruff check . --fix
- uv run --locked --group engine --group engine-dev ruff check . --fix
format:
desc: "Auto-fix code formatting"
deps: [install]
cmds:
- uv run ruff format .
- uv run --locked --group engine --group engine-dev ruff format .
format:check:
desc: "Check code formatting"
deps: [install]
cmds:
- uv run ruff format . --diff
- uv run --locked --group engine --group engine-dev ruff format . --diff
typecheck:
desc: "Run type checking"
deps: [install]
cmds:
- uv run pyright . --warnings
- uv run --locked --group engine --group engine-dev pyright . --warnings
test:
desc: "Run tests"
deps: [prepare]
cmds:
- uv run pytest tests
- uv run --locked --group engine --group engine-dev pytest tests
fix:
desc: "Auto-fix lint + format"
@@ -102,7 +129,7 @@ tasks:
desc: "Generate tool_models.py from Java OpenAPI spec (SwaggerDoc.json)"
deps: [install, ":backend:swagger"]
cmds:
- uv run python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py --io-output src/stirling/models/tool_io.py
- uv run --locked --group engine --group engine-dev python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py --io-output src/stirling/models/tool_io.py
sources:
- ../SwaggerDoc.json
- scripts/generate_tool_models.py
@@ -114,7 +141,7 @@ tasks:
desc: "Fail if the committed tool models are out of date"
deps: [install, ":backend:swagger"]
cmds:
- uv run python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py --io-output src/stirling/models/tool_io.py --check
- uv run --locked --group engine --group engine-dev python scripts/generate_tool_models.py --spec ../SwaggerDoc.json --output src/stirling/models/tool_models.py --io-output src/stirling/models/tool_io.py --check
clean:
desc: "Clean build artifacts"
+25 -34
View File
@@ -211,11 +211,13 @@ tasks:
- npx vitest run --config .storybook/vitest.config.ts {{.CLI_ARGS}}
storybook:a11y:
desc: "a11y regression gate over every story: fail only on NEW axe violations"
desc: "a11y regression gate over every story, light and dark: fail only on NEW axe violations"
deps: [prepare, storybook:browser]
cmds:
- node .storybook/a11y-scan.mjs
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt
- SCAN_THEME=dark node .storybook/a11y-scan.mjs
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --baseline .storybook/a11y-baseline.dark.json
storybook:a11y:changed:
desc: "a11y gate over the stories this branch affects (default base origin/main)"
@@ -244,13 +246,17 @@ tasks:
fi
node .storybook/a11y-scan.mjs {{.CHANGED}}
node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt
SCAN_THEME=dark node .storybook/a11y-scan.mjs {{.CHANGED}}
node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --baseline .storybook/a11y-baseline.dark.json
storybook:a11y:record:
desc: "Re-record the a11y baseline (run after intentionally fixing/adding violations)"
desc: "Re-record both a11y baselines (run after intentionally fixing/adding violations)"
deps: [prepare, storybook:browser]
cmds:
- node .storybook/a11y-scan.mjs
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record
- SCAN_THEME=dark node .storybook/a11y-scan.mjs
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record --baseline .storybook/a11y-baseline.dark.json
# ============================================================
# Code quality
@@ -260,8 +266,7 @@ tasks:
desc: "Run linting"
deps: [install]
cmds:
- task: lint:eslint
- task: lint:dpdm
- task: lint:oxlint
- task: lint:colors
- task: lint:css
@@ -290,25 +295,17 @@ tasks:
cmds:
- node editor/scripts/lint/theme-lint.mjs contrast
lint:eslint:
desc: "Run ESLint linting"
lint:oxlint:
desc: "Run oxlint linting"
deps: [install]
cmds:
- npx eslint --max-warnings=0
lint:dpdm:
desc: "Run circular import linting"
deps: [install]
cmds:
# Globs so dpdm walks the whole tree. dpdm expands the braces itself, so this is
# shell-agnostic. Covers the whole editor tree, including the portal layer.
- npx dpdm "editor/src/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
- npx oxlint --config oxlint.config.ts --max-warnings=0
lint:fix:
desc: "Auto-fix lint issues"
deps: [install]
cmds:
- npx eslint --fix
- npx oxlint --config oxlint.config.ts --fix
format:
desc: "Auto-fix code formatting"
@@ -460,8 +457,17 @@ tasks:
test:editor:
desc: "Run editor tests"
deps: [prepare]
vars:
COVERAGE: '{{.COVERAGE | default .CI | default "false"}}'
cmds:
- npx vitest run --root editor
- >
npx vitest run --root editor
{{if eq .COVERAGE "true"}}--coverage
--coverage.provider=v8
--coverage.reporter=text-summary
--coverage.reporter=json-summary
--coverage.reporter=html
--coverage.reportsDirectory=./coverage{{end}}
test:watch:
desc: "Run tests in watch mode"
@@ -471,24 +477,9 @@ tasks:
test:coverage:
desc: "Run tests with coverage (one-shot; CI-friendly)."
deps: [prepare]
cmds:
# `vitest run` makes this CI-safe (the bare `vitest` form enters watch
# mode). Explicit reporter list because v8 + json-summary is what the
# coverage-summary.py helper consumes; html/text are kept for humans.
#
# reportsDirectory is pinned to ./coverage relative to vitest's root
# (--root editor), so output lands at frontend/editor/coverage/. The
# CI upload step reads from that path. An earlier attempt with
# `./editor/coverage` double-nested into frontend/editor/editor/coverage;
# pinning future-proofs against vitest changing the default.
- >
npx vitest run --root editor --coverage
--coverage.provider=v8
--coverage.reporter=text-summary
--coverage.reporter=json-summary
--coverage.reporter=html
--coverage.reportsDirectory=./coverage
- task: test:editor
vars: { COVERAGE: "true" }
# ============================================================
# Code Generation
+17 -13
View File
@@ -45,6 +45,10 @@ vars:
# which owns the version and caches the binary here.
GITLEAKS_BIN: '.task/bin/gitleaks{{if eq OS "windows"}}.exe{{end}}'
env:
# Keep repository-wide checks isolated from the engine runtime environment.
UV_PROJECT_ENVIRONMENT: '.venv-pre-commit'
tasks:
default:
desc: "Check formatting, spelling, and secrets across the repo"
@@ -76,19 +80,19 @@ tasks:
desc: "Install the pinned pre-commit Python tools"
run: once
cmds:
- uv sync --project scripts/pre-commit --locked
- uv sync --project engine --locked --group pre-commit
sources:
- scripts/pre-commit/uv.lock
- scripts/pre-commit/pyproject.toml
- engine/uv.lock
- engine/pyproject.toml
status:
- test -d scripts/pre-commit/.venv
- test -d engine/.venv-pre-commit
clean:
desc: "Remove the cached gitleaks binary and the tool virtualenv"
desc: "Remove the cached gitleaks binary and the pre-commit virtualenv"
cmds:
- cmd: rm -rf scripts/pre-commit/.venv .task/bin/gitleaks
- cmd: rm -rf engine/.venv-pre-commit .task/bin/gitleaks
platforms: [linux, darwin]
- cmd: cmd /c "rmdir /s /q scripts\pre-commit\.venv & del /q .task\bin\gitleaks.exe"
- cmd: cmd /c "rmdir /s /q engine\.venv-pre-commit & del /q .task\bin\gitleaks.exe"
platforms: [windows]
ignore_error: true
@@ -97,26 +101,26 @@ tasks:
ruff:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync ruff check --line-length=127 {{if .FIX}}--fix {{end}}$(git ls-files {{.PY_FILES}})
- uv run --project engine --locked --group pre-commit ruff check --isolated --line-length=120 {{if .FIX}}--fix {{end}}$(git ls-files {{.PY_FILES}})
ruff-format:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync ruff format {{if .FIX}}{{else}}--check {{end}}$(git ls-files {{.PY_FILES}})
- uv run --project engine --locked --group pre-commit ruff format --isolated --line-length=120 {{if .FIX}}{{else}}--check {{end}}$(git ls-files {{.PY_FILES}})
codespell:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync codespell --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment --quiet-level=2 $(git ls-files {{.SPELL_FILES}})
- uv run --project engine --locked --group pre-commit codespell --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment --quiet-level=2 $(git ls-files {{.SPELL_FILES}})
toml-sort:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync python scripts/pre-commit/sort_locale_toml.py {{if .FIX}}--fix {{end}}{{.LOCALE_TOML}}
- uv run --project engine --locked --group pre-commit python scripts/pre-commit/sort_locale_toml.py {{if .FIX}}--fix {{end}}{{.LOCALE_TOML}}
whitespace:
cmds:
- uv run --no-project python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}{{.WS_FILES}}
- uv run --project engine --locked --group pre-commit python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}{{.WS_FILES}}
gitleaks:
deps: [gitleaks-bin]
@@ -130,4 +134,4 @@ tasks:
internal: true
desc: "Ensure the pinned, checksum-verified gitleaks binary is cached in .task/bin"
cmds:
- uv run --no-project python scripts/pre-commit/install_gitleaks.py
- uv run --project engine --locked --group pre-commit python scripts/pre-commit/install_gitleaks.py
+2 -3
View File
@@ -2,8 +2,6 @@
"recommendations": [
"elagil.pre-commit-helper", // Support for pre-commit hooks to enforce code quality
"josevseb.google-java-format-for-vs-code", // Google Java code formatter to follow the Google Java Style Guide
"ms-python.black-formatter", // Python code formatter using Black
"ms-python.flake8", // Flake8 linter for Python to enforce code quality
"ms-python.python", // Official Microsoft Python extension with IntelliSense, debugging, and Jupyter support
"ms-vscode-remote.vscode-remote-extensionpack", // Remote Development Pack for SSH, WSL, and Containers
// "Oracle.oracle-java", // Oracle Java extension with additional features for Java development
@@ -19,6 +17,7 @@
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
"redhat.vscode-yaml", // YAML extension for Visual Studio Code
"dbaeumer.vscode-eslint", // ESLint extension for TypeScript linting
"tamasfe.even-better-toml", // TOML language support and formatting
"oxc.oxc-vscode", // Oxc (oxlint) extension for JavaScript/TypeScript linting
]
}
+2 -1
View File
@@ -20,8 +20,9 @@
"editor.defaultFormatter": "vscode.json-language-features"
},
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter"
"editor.defaultFormatter": "charliermarsh.ruff"
},
"ruff.configuration": "${workspaceFolder}/engine/pyproject.toml",
"[gradle-kotlin-dsl]": {
"editor.defaultFormatter": "vscjava.vscode-gradle"
},
+1 -1
View File
@@ -192,7 +192,7 @@ What goes where:
- **saas** — web-only: Supabase web auth, AuthCallback, avatar canvas, `window.location`.
- **desktop** — Tauri-only: keyring authService, tauriHttpClient, native files/windows, backend routing.
`cloud/` MUST NOT import `@supabase/*`, `@tauri-apps/*`, raw `fetch`, `window.location`, `localStorage`, `sessionStorage`, or `import.meta.env.VITE_*` (enforced by ESLint). It reaches platform-specific things only via `@app/*` seams: `services/apiClient`, `auth/session.getAccessToken`, `auth/supabase`, `platform/openExternal`, `services/billing`, `hooks/useSaaSMode` — each provided per-platform in `saas/` and `desktop/`.
`cloud/` MUST NOT import `@supabase/*`, `@tauri-apps/*`, raw `fetch`, `window.location`, `localStorage`, `sessionStorage`, or `import.meta.env.VITE_*` (all enforced by the linter). It reaches platform-specific things only via `@app/*` seams: `services/apiClient`, `auth/session.getAccessToken`, `auth/supabase`, `platform/openExternal`, `services/billing`, `hooks/useSaaSMode` — each provided per-platform in `saas/` and `desktop/`.
Rule of thumb — **move, don't copy**: share via `cloud/`, override by shadowing the same `@app/*` path in a leaf (`saas/` or `desktop/`).
+1 -1
View File
@@ -158,7 +158,7 @@ Stirling-PDF/
│ │ │ └── locales/ # Internationalization files (JSON)
│ │ └── vite.config.ts # Vite configuration
│ ├── package.json # Shared workspace dependencies
│ └── eslint.config.mjs # Shared lint config
│ └── oxlint.config.ts # Shared lint config
├── customFiles/ # Custom static files and templates (generated at runtime used to replace existing files)
├── docs/ # Documentation files
├── exampleYmlFiles/ # Example YAML configuration files
+8 -8
View File
@@ -156,6 +156,14 @@
"moduleName": ".*",
"moduleLicense": "GNU GENERAL PUBLIC LICENSE, Version 2 + Classpath Exception"
},
{
"moduleName": ".*",
"moduleLicense": "GNU Lesser Public License"
},
{
"moduleName": ".*",
"moduleLicense": "The GNU Lesser General Public License"
},
{
"moduleName": "com.martiansoftware:jsap",
"moduleLicense": "LGPL"
@@ -224,14 +232,6 @@
"moduleName": "com.google.re2j:re2j",
"moduleLicense": "Go License"
},
{
"moduleName": "com.hubspot:algebra",
"moduleLicense": null
},
{
"moduleName": "com.hubspot.immutables:immutables-exceptions",
"moduleLicense": null
},
{
"moduleName": ".*",
"moduleLicense": "UnRar License"
+4 -4
View File
@@ -10,19 +10,19 @@ dependencies {
api 'com.fathzer:javaluator:3.0.6'
api 'com.posthog.java:posthog:1.2.0'
api "org.apache.commons:commons-lang3:${commonsLang3}"
api 'com.drewnoakes:metadata-extractor:2.20.0' // Image metadata extractor
api 'com.drewnoakes:metadata-extractor:2.21.0' // Image metadata extractor
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
api "org.apache.pdfbox:pdfbox-io:$pdfboxVersion"
api "org.apache.pdfbox:xmpbox:$pdfboxVersion"
api "org.apache.pdfbox:preflight:$pdfboxVersion"
api 'com.github.junrar:junrar:7.6.0' // RAR archive support for CBR files
api 'com.github.junrar:junrar:8.0.0' // RAR archive support for CBR files
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
api 'org.simplejavamail:simple-java-mail:8.12.6'
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
api 'org.simplejavamail:simple-java-mail:9.2.0'
api 'org.simplejavamail:outlook-module:9.2.0' // MSG file support
api 'jakarta.mail:jakarta.mail-api:2.1.5'
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
@@ -1,5 +1,6 @@
package stirling.software.common.config.swagger;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
@@ -18,6 +19,7 @@ import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.model.tool.ToolIOCase;
import stirling.software.common.model.tool.ToolIOWhen;
import stirling.software.common.service.ToolIOParameterDefaults;
/**
* Publishes each {@link ToolIO} into the spec as {@code x-stirling-io}, which is how the frontend
@@ -49,40 +51,42 @@ public class ToolIOOperationCustomizer
if (declaration == null) {
return operation;
}
operation.addExtension(EXTENSION_NAME, toExtension(declaration));
operation.addExtension(EXTENSION_NAME, toExtension(declaration, handlerMethod.getMethod()));
operation.setDescription(appendSummaryLine(operation.getDescription(), declaration));
return operation;
}
private static Map<String, Object> toExtension(ToolIO declaration) {
private static Map<String, Object> toExtension(ToolIO declaration, Method handler) {
Map<String, Object> extension = new LinkedHashMap<>();
extension.put("accepts", names(declaration.accepts()));
extension.put("produces", declaration.produces().name());
extension.put("arity", declaration.arity().name());
if (declaration.cases().length > 0) {
extension.put("cases", cases(declaration));
extension.put("cases", cases(declaration, handler));
}
return extension;
}
private static List<Map<String, Object>> cases(ToolIO declaration) {
return Arrays.stream(declaration.cases()).map(ToolIOOperationCustomizer::toCase).toList();
private static List<Map<String, Object>> cases(ToolIO declaration, Method handler) {
return Arrays.stream(declaration.cases()).map(rule -> toCase(rule, handler)).toList();
}
private static Map<String, Object> toCase(ToolIOCase rule) {
private static Map<String, Object> toCase(ToolIOCase rule, Method handler) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put(
"when",
Arrays.stream(rule.when()).map(ToolIOOperationCustomizer::toCondition).toList());
entry.put("when", Arrays.stream(rule.when()).map(c -> toCondition(c, handler)).toList());
entry.put("produces", rule.produces().name());
entry.put("arity", rule.arity().name());
return entry;
}
private static Map<String, Object> toCondition(ToolIOWhen condition) {
private static Map<String, Object> toCondition(ToolIOWhen condition, Method handler) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("param", condition.param());
entry.put("matches", List.of(condition.matches()));
// The default the endpoint uses when this parameter is absent, so a step that never sends
// it still resolves. Omitted when the parameter is required with none.
ToolIOParameterDefaults.resolve(handler, condition.param())
.ifPresent(value -> entry.put("default", value));
return entry;
}
@@ -1046,6 +1046,8 @@ public class ApplicationProperties {
// 'https://app.example.com'). If not set, falls back to backendUrl.
private boolean enableMobileScanner = true; // Enable mobile phone QR code upload feature
private boolean enableMobileSignature =
true; // Enable drawing signatures on a phone via QR code
private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings();
private ServerCertificate serverCertificate = new ServerCertificate();
@@ -1104,6 +1106,13 @@ public class ApplicationProperties {
@Data
public static class Encryption {
private boolean enabled = false;
/**
* Emit an audit event for every decrypt of an encrypted blob. Compliance reviewers
* (HIPAA) expect read audit, so it defaults on; busy multi-user installs can disable.
* Denied decrypts and key lifecycle events are always audited regardless.
*/
private boolean auditReads = true;
}
@Data
@@ -5,13 +5,18 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/** The runtime form of a {@link ToolIO} declaration, read off a handler method once at startup. */
public record ToolIOSpec(
Set<ToolFormat> accepts, ToolFormat produces, ToolArity arity, List<Case> cases) {
public record When(String param, List<String> matches) {
/**
* @param paramDefault the value used when the parameter is absent, or null when it has no
* default - an absent parameter then leaves the case unresolved rather than defaulted.
*/
public record When(String param, List<String> matches, String paramDefault) {
boolean holdsFor(Object value) {
String normalised = normalise(value);
@@ -19,6 +24,14 @@ public record ToolIOSpec(
}
}
/** Supplies the default value a request parameter takes when a caller omits it. */
@FunctionalInterface
public interface ParameterDefaults {
Optional<String> defaultFor(String param);
ParameterDefaults NONE = param -> Optional.empty();
}
/**
* Both sides of a condition are normalised at comparison, not at construction: the declaration
* reaches the frontend and the engine as published data, and normalising only one side there
@@ -44,25 +57,33 @@ public record ToolIOSpec(
}
public static ToolIOSpec from(ToolIO annotation) {
return from(annotation, ParameterDefaults.NONE);
}
public static ToolIOSpec from(ToolIO annotation, ParameterDefaults defaults) {
return new ToolIOSpec(
new LinkedHashSet<>(Arrays.asList(annotation.accepts())),
annotation.produces(),
annotation.arity(),
Arrays.stream(annotation.cases()).map(ToolIOSpec::toCase).toList());
Arrays.stream(annotation.cases()).map(rule -> toCase(rule, defaults)).toList());
}
private static Case toCase(ToolIOCase rule) {
List<When> when = Arrays.stream(rule.when()).map(ToolIOSpec::toWhen).toList();
private static Case toCase(ToolIOCase rule, ParameterDefaults defaults) {
List<When> when = Arrays.stream(rule.when()).map(c -> toWhen(c, defaults)).toList();
return new Case(when, rule.produces(), rule.arity());
}
private static When toWhen(ToolIOWhen condition) {
return new When(condition.param(), List.of(condition.matches()));
private static When toWhen(ToolIOWhen condition, ParameterDefaults defaults) {
return new When(
condition.param(),
List.of(condition.matches()),
defaults.defaultFor(condition.param()).orElse(null));
}
/**
* First matching {@link Case} wins. If none match but one reads a parameter we cannot see, the
* declared output comes back uncertain: a value we never saw might have picked another branch.
* First matching {@link Case} wins. A parameter the caller omitted resolves to its declared
* default; only a parameter with no default leaves the output uncertain, since an unseen value
* might then have picked another branch.
*
* @param parameters the step's configured parameters, or null when not known
*/
@@ -71,12 +92,17 @@ public record ToolIOSpec(
for (Case rule : cases) {
boolean allHold = true;
for (When condition : rule.when()) {
if (parameters == null || !parameters.containsKey(condition.param())) {
Object value;
if (parameters != null && parameters.containsKey(condition.param())) {
value = parameters.get(condition.param());
} else if (condition.paramDefault() != null) {
value = condition.paramDefault();
} else {
sawUnknownParam = true;
allHold = false;
continue;
}
allHold &= condition.holdsFor(parameters.get(condition.param()));
allHold &= condition.holdsFor(value);
}
if (allHold) {
return new Output(rule.produces(), rule.arity(), true);
@@ -983,7 +983,7 @@ public class PdfMarkdownConverter {
ordered.sort(Comparator.comparingDouble((Line l) -> l.y).reversed());
StringBuilder sb = new StringBuilder();
for (Line l : ordered) {
if (sb.length() > 0) {
if (!sb.isEmpty()) {
sb.append(' ');
}
sb.append(l.text);
@@ -17,7 +17,6 @@ import java.util.concurrent.Semaphore;
import java.util.function.Consumer;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.examples.util.DeletingRandomAccessFile;
import org.apache.pdfbox.io.IOUtils;
import org.apache.pdfbox.io.MemoryUsageSetting;
import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
@@ -31,6 +30,7 @@ import org.springframework.web.multipart.MultipartFile;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.util.DeletingRandomAccessFile;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.TempFileManager;
@@ -19,6 +19,7 @@ import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import jakarta.annotation.PreDestroy;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
@@ -63,6 +64,21 @@ public class JobExecutorService {
"Job executor configured with effective timeout of {} ms", this.effectiveTimeoutMs);
}
/** Stop the service-owned executor when the application context is closed or restarted. */
@PreDestroy
public void shutdown() {
log.debug("Shutting down job executor");
executor.shutdown();
try {
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
executor.shutdownNow();
}
}
public ResponseEntity<?> runJobGeneric(boolean async, Supplier<Object> work) {
return runJobGeneric(async, work, -1);
}
@@ -225,19 +225,21 @@ public class MobileScannerService {
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);
}
});
try (var paths = Files.walk(sessionDir)) {
paths.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) {
@@ -48,6 +48,10 @@ public class TaskManager {
@Value("${stirling.jobResultExpiryMinutes:30}")
private int jobResultExpiryMinutes = 30;
/** Maximum age of a task that never reached a terminal state. */
@Value("${stirling.job.pendingExpiryMinutes:1440}")
private int pendingJobExpiryMinutes = 1440;
private final FileStorage fileStorage;
private final JobStore jobStore;
private final ClusterBackplane clusterBackplane;
@@ -332,19 +336,32 @@ public class TaskManager {
}
LocalDateTime expiryThreshold =
LocalDateTime.now().minus(jobResultExpiryMinutes, ChronoUnit.MINUTES);
LocalDateTime pendingExpiryThreshold =
LocalDateTime.now().minus(pendingJobExpiryMinutes, ChronoUnit.MINUTES);
int removedCount = 0;
try {
for (Map.Entry<String, JobResult> entry : jobResults.entrySet()) {
JobResult result = entry.getValue();
// Remove completed jobs that are older than the expiry threshold
if (result.isComplete()
&& result.getCompletedAt() != null
&& result.getCompletedAt().isBefore(expiryThreshold)) {
boolean expiredCompletedJob =
result.isComplete()
&& result.getCompletedAt() != null
&& result.getCompletedAt().isBefore(expiryThreshold);
boolean abandonedPendingJob =
!result.isComplete()
&& result.getCreatedAt() != null
&& result.getCreatedAt().isBefore(pendingExpiryThreshold);
// Remove old terminal results and abandoned pending jobs. Without the second
// branch, a client that starts a task and never completes it keeps its result in
// memory forever.
if (expiredCompletedJob || abandonedPendingJob) {
// Clean up file results
cleanupJobFiles(result, entry.getKey());
if (expiredCompletedJob) {
cleanupJobFiles(result, entry.getKey());
}
// Remove the job result
jobResults.remove(entry.getKey());
@@ -0,0 +1,92 @@
package stirling.software.common.service;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.Optional;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.extern.slf4j.Slf4j;
/**
* The value a request parameter takes when the caller omits it, read from the request model so a
* {@code @ToolIOCase} can be resolved even for a step that never sends the parameter it branches
* on. The default is read from the field so it cannot drift.
*/
@Slf4j
public final class ToolIOParameterDefaults {
// Swagger's sentinel for an unset @Schema string member; not a real default value.
private static final String SCHEMA_UNSET = "##default";
private ToolIOParameterDefaults() {}
/**
* The default {@code param} resolves to when absent, or empty when the parameter is required
* with no declared default - in which case an unset value leaves the output genuinely unknown
* rather than defaulted, and the chain reports it as uncertain.
*
* <p>Precedence: an explicit {@code @Schema(defaultValue)}, then the field's own value (a
* primitive's language default, or an initializer), then the empty string for an optional field
* left null, and finally empty for a required field with none of the above.
*/
public static Optional<String> resolve(Method handler, String param) {
for (Parameter parameter : handler.getParameters()) {
Field field = findField(parameter.getType(), param);
if (field != null) {
return fromField(parameter.getType(), field);
}
}
return Optional.empty();
}
private static Optional<String> fromField(Class<?> owner, Field field) {
Schema schema = field.getAnnotation(Schema.class);
if (schema != null
&& !schema.defaultValue().isEmpty()
&& !SCHEMA_UNSET.equals(schema.defaultValue())) {
return Optional.of(schema.defaultValue());
}
Object value = readField(owner, field);
if (value != null) {
return Optional.of(String.valueOf(value));
}
return isRequired(field, schema) ? Optional.empty() : Optional.of("");
}
private static Object readField(Class<?> owner, Field field) {
try {
Object instance = owner.getDeclaredConstructor().newInstance();
field.setAccessible(true);
return field.get(instance);
} catch (ReflectiveOperationException | RuntimeException e) {
// A request model we cannot instantiate leaves the default unknown, which the check
// treats conservatively as uncertain. Never break startup over it.
log.warn("Could not read default of {}.{}", owner.getSimpleName(), field.getName(), e);
return null;
}
}
private static boolean isRequired(Field field, Schema schema) {
if (schema != null && schema.requiredMode() == Schema.RequiredMode.REQUIRED) {
return true;
}
return field.isAnnotationPresent(NotNull.class)
|| field.isAnnotationPresent(NotBlank.class);
}
private static Field findField(Class<?> type, String name) {
for (Class<?> c = type; c != null && c != Object.class; c = c.getSuperclass()) {
try {
return c.getDeclaredField(name);
} catch (NoSuchFieldException ignored) {
// Try the superclass; request models extend a shared file-input base.
}
}
return null;
}
}
@@ -67,7 +67,10 @@ public class ToolIORegistry implements ToolMetadataService, ToolIOSource {
if (annotation == null) {
return;
}
ToolIOSpec spec = ToolIOSpec.from(annotation);
Method method = handler.getMethod();
ToolIOSpec spec =
ToolIOSpec.from(
annotation, param -> ToolIOParameterDefaults.resolve(method, param));
for (String pattern : extractPatterns(info)) {
target.put(pattern, spec);
}
@@ -1,4 +1,4 @@
package org.apache.pdfbox.examples.util;
package stirling.software.common.util;
import java.io.File;
import java.io.IOException;
@@ -194,7 +194,7 @@ public class EmlParser {
}
attachment.setFilename(filename);
String contentId = embedded ? stripCid(resourceName) : null;
String contentId = embedded ? stripCid(resource.getContentId()) : null;
attachment.setContentId(contentId);
String detectedContentType = EmlProcessingUtils.detectMimeType(filename, contentType);
@@ -941,7 +941,7 @@ public class GeneralUtils {
}
// If no MAC address found, use hostname as fallback
if (sb.length() == 0) {
if (sb.isEmpty()) {
String hostname = InetAddress.getLocalHost().getHostName();
sb.append(hostname != null ? hostname : "unknown-host");
log.warn("No MAC address found, using hostname for fingerprint generation");
@@ -1,17 +1,22 @@
package stirling.software.common.util;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.util.concurrent.UncheckedExecutionException;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public final class RegexPatternUtils {
private static final RegexPatternUtils INSTANCE = new RegexPatternUtils();
private final ConcurrentHashMap<PatternKey, Pattern> patternCache = new ConcurrentHashMap<>();
private static final long MAX_CACHED_PATTERNS = 512;
private final Cache<PatternKey, Pattern> patternCache =
CacheBuilder.newBuilder().maximumSize(MAX_CACHED_PATTERNS).build();
private static final String WHITESPACE_REGEX = "\\s++";
private static final String EXTENSION_REGEX = "\\.(?:[^.]*+)?$";
@@ -51,7 +56,7 @@ public final class RegexPatternUtils {
throw new IllegalArgumentException("Regex pattern cannot be null");
}
return patternCache.computeIfAbsent(new PatternKey(regex, 0), this::compilePattern);
return getOrCompile(new PatternKey(regex, 0));
}
/**
@@ -77,7 +82,7 @@ public final class RegexPatternUtils {
throw new IllegalArgumentException("Regex pattern cannot be null");
}
return patternCache.computeIfAbsent(new PatternKey(regex, flags), this::compilePattern);
return getOrCompile(new PatternKey(regex, flags));
}
/**
@@ -98,7 +103,7 @@ public final class RegexPatternUtils {
* @return true if pattern is cached, false otherwise
*/
public boolean isCached(String regex, int flags) {
return regex != null && patternCache.containsKey(new PatternKey(regex, flags));
return regex != null && patternCache.getIfPresent(new PatternKey(regex, flags)) != null;
}
/**
@@ -107,7 +112,7 @@ public final class RegexPatternUtils {
* @return number of patterns currently cached
*/
public int getCacheSize() {
return patternCache.size();
return (int) patternCache.size();
}
/**
@@ -115,7 +120,7 @@ public final class RegexPatternUtils {
* useful for testing or memory cleanup in long-running applications.
*/
public void clearCache() {
patternCache.clear();
patternCache.invalidateAll();
log.debug("Regex pattern cache cleared");
}
@@ -141,13 +146,32 @@ public final class RegexPatternUtils {
return false;
}
PatternKey key = new PatternKey(regex, flags);
boolean removed = patternCache.remove(key) != null;
boolean removed = patternCache.getIfPresent(key) != null;
patternCache.invalidate(key);
if (removed) {
log.debug("Removed regex pattern from cache: {} (flags: {})", regex, flags);
}
return removed;
}
private Pattern getOrCompile(PatternKey key) {
try {
return patternCache.get(key, () -> compilePattern(key));
} catch (UncheckedExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof PatternSyntaxException patternSyntaxException) {
throw patternSyntaxException;
}
throw e;
} catch (java.util.concurrent.ExecutionException e) {
Throwable cause = e.getCause();
if (cause instanceof PatternSyntaxException patternSyntaxException) {
throw patternSyntaxException;
}
throw new IllegalStateException("Failed to compile regex pattern", cause);
}
}
/**
* Internal method to compile a pattern and handle errors consistently.
*
@@ -56,8 +56,10 @@ 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")) {
// Mobile pages reached by scanning a QR code (peer-to-peer, no backend auth
// needed): /mobile-scanner uploads photos, /mobile-sign draws a signature.
if (normalizedUri.startsWith("/mobile-scanner")
|| normalizedUri.startsWith("/mobile-sign")) {
return true;
}
@@ -154,7 +154,7 @@ class PdfMarkdownConverterTest {
|| isTableSeparatorRow(line)) {
continue;
}
if (sb.length() > 0) {
if (!sb.isEmpty()) {
sb.append('\n');
}
sb.append(line);
@@ -101,7 +101,12 @@ class ToolChainValidatorConformanceTest {
for (JsonNode match : condition.get("matches")) {
matches.add(match.asString());
}
when.add(new ToolIOSpec.When(condition.get("param").asString(), matches));
JsonNode paramDefault = condition.get("default");
when.add(
new ToolIOSpec.When(
condition.get("param").asString(),
matches,
paramDefault == null ? null : paramDefault.asString()));
}
cases.add(
new ToolIOSpec.Case(
@@ -73,6 +73,13 @@ class RequestUriUtilsTest {
assertTrue(RequestUriUtils.isStaticResource("/mobile-scanner"));
}
@Test
void testIsStaticResource_mobileSignPath() {
// The phone-side signature drawing page, reached from the Sign tool QR code.
assertTrue(RequestUriUtils.isStaticResource("/mobile-sign"));
assertTrue(RequestUriUtils.isStaticResource("/app", "/app/mobile-sign"));
}
@Test
void testIsStaticResource_portalShell() {
// The admin portal SPA shell (/processor) is served pre-auth so it's directly navigable.
+4 -3
View File
@@ -57,7 +57,7 @@ dependencies {
// veraPDF still uses javax.xml.bind, not the new jakarta namespace
implementation 'javax.xml.bind:jaxb-api:2.3.1'
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
implementation 'com.sun.xml.bind:jaxb-core:4.0.7'
implementation 'com.sun.xml.bind:jaxb-core:4.0.9'
// CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7)
implementation "com.google.code.gson:gson:${gsonVersion}"
@@ -312,8 +312,9 @@ tasks.register('cleanFrontendAssets', Delete) {
delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) }
// Prerendered per-route SPA pages (e.g. compress.html) carry per-tool OG tags and are
// copied from the frontend build. Remove stale ones so renamed/removed tools don't linger.
// api-landing.html and mobile-upload.html are real backend source files, not generated artifacts.
delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html', 'mobile-upload.html'])
// api-landing.html, mobile-upload.html and mobile-sign.html are real backend source files,
// not generated artifacts.
delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html', 'mobile-upload.html', 'mobile-sign.html'])
// Nested prerendered route pages (e.g. settings/people.html)
delete new File(resourcesStaticDir, 'settings')
}
@@ -321,7 +321,7 @@ public class ExternalAppDepConfig {
new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
if (sb.length() > 0) sb.append('\n');
if (!sb.isEmpty()) sb.append('\n');
sb.append(line);
}
}
@@ -61,6 +61,7 @@ public class PdfOverlayController {
int overlayPos = request.getOverlayPosition();
MultipartFile[] overlayFiles = request.getOverlayFiles();
validateOverlayFiles(overlayFiles);
File[] overlayPdfFiles = new File[overlayFiles.length];
List<File> tempFiles = new ArrayList<>(); // List to keep track of temporary files
@@ -120,10 +121,29 @@ public class PdfOverlayController {
}
}
// Both fields are declared required, but @ModelAttribute binding leaves them null when the
// caller omits them, which would otherwise surface as a 500 instead of a 400.
private void validateOverlayFiles(MultipartFile[] overlayFiles) {
if (overlayFiles == null || overlayFiles.length == 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.overlayFilesRequired", "At least one overlay file is required");
}
for (MultipartFile overlayFile : overlayFiles) {
if (overlayFile == null || overlayFile.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.overlayFileEmpty", "Overlay files must not be empty");
}
}
}
private Map<Integer, String> prepareOverlayGuide(
int basePageCount, File[] overlayFiles, String mode, int[] counts, List<File> tempFiles)
throws IOException {
Map<Integer, String> overlayGuide = new HashMap<>();
if (mode == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat", "Invalid {0} format: {1}", "overlay mode", "null");
}
switch (mode) {
case "SequentialOverlay":
sequentialOverlay(overlayGuide, overlayFiles, basePageCount, tempFiles);
@@ -130,7 +130,7 @@ public class UIDataController {
objectMapper.readValue(
config, new TypeReference<Map<String, Object>>() {});
String name = (String) jsonContent.get("name");
if (name == null || name.length() < 1) {
if (name == null || name.isEmpty()) {
String filename =
jsonFiles
.get(pipelineConfigs.indexOf(config))
@@ -194,6 +194,9 @@ public class ConfigController {
configData.put(
"enableMobileScanner",
applicationProperties.getSystem().isEnableMobileScanner());
configData.put(
"enableMobileSignature",
applicationProperties.getSystem().isEnableMobileSignature());
configData.put(
"mobileScannerConvertToPdf",
applicationProperties.getSystem().getMobileScannerSettings().isConvertToPdf());
@@ -62,12 +62,17 @@ public class MobileScannerController {
}
/**
* Check if mobile scanner feature is enabled
* Check if any feature backed by these transfer sessions is enabled. The mobile scanner and
* mobile signature drawing share this session/upload API, so the endpoints stay available while
* either feature is on; each flag independently controls only its own UI.
*
* @return Error response if disabled, null if enabled
*/
private ResponseEntity<Map<String, Object>> checkFeatureEnabled() {
if (!applicationProperties.getSystem().isEnableMobileScanner()) {
boolean anyEnabled =
applicationProperties.getSystem().isEnableMobileScanner()
|| applicationProperties.getSystem().isEnableMobileSignature();
if (!anyEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(
Map.of(
@@ -275,7 +280,8 @@ public class MobileScannerController {
@Parameter(description = "Filename to download", required = true) @PathVariable
String filename) {
if (!applicationProperties.getSystem().isEnableMobileScanner()) {
if (!applicationProperties.getSystem().isEnableMobileScanner()
&& !applicationProperties.getSystem().isEnableMobileSignature()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
@@ -13,7 +13,6 @@ import java.util.Calendar;
import java.util.List;
import org.apache.commons.io.FileUtils;
import org.apache.pdfbox.examples.signature.CreateSignatureBase;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
@@ -76,6 +75,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
import stirling.software.SPDF.pdf.signature.CreateSignatureBase;
import stirling.software.SPDF.service.HardwareKeyStoreService;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.enumeration.ResourceWeight;
@@ -61,7 +61,6 @@ import stirling.software.common.model.api.PDFFile;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.WebResponseUtils;
@@ -270,25 +269,6 @@ public class GetInfoOnPDF {
}
}
private static void validatePdfFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("PDF file is required");
}
if (file.getSize() > MAX_FILE_SIZE) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileSizeLimit",
"File size ({0} bytes) exceeds maximum allowed size ({1} bytes)",
file.getSize(),
MAX_FILE_SIZE);
}
String contentType = file.getContentType();
if (contentType != null && !"application/pdf".equals(contentType)) {
log.warn("File content type is {}, expected application/pdf", contentType);
}
}
private static ResponseEntity<byte[]> createErrorResponse(String errorMessage) {
try {
ObjectNode errorNode = objectMapper.createObjectNode();
@@ -1104,14 +1084,6 @@ public class GetInfoOnPDF {
public ResponseEntity<byte[]> getPdfInfo(@ModelAttribute PDFFile request) throws IOException {
MultipartFile inputFile = request.getFileInput();
// Validate input
try {
validatePdfFile(inputFile);
} catch (IllegalArgumentException e) {
log.error("Invalid PDF file: {}", e.getMessage());
return createErrorResponse("Invalid PDF file: " + e.getMessage());
}
List<PDFVerificationResult> verificationResults = null;
try {
verificationResults = veraPDFService.validatePDF(inputFile.getInputStream());
@@ -124,15 +124,15 @@ public class PasswordController {
StandardProtectionPolicy spp =
new StandardProtectionPolicy(ownerPassword, password, ap);
if ((ownerPassword != null && ownerPassword.length() > 0)
|| (password != null && password.length() > 0)) {
if ((ownerPassword != null && !ownerPassword.isEmpty())
|| (password != null && !password.isEmpty())) {
spp.setEncryptionKeyLength(keyLength);
}
spp.setPermissions(ap);
document.protect(spp);
if ((ownerPassword == null || ownerPassword.length() == 0)
&& (password == null || password.length() == 0))
if ((ownerPassword == null || ownerPassword.isEmpty())
&& (password == null || password.isEmpty()))
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(
@@ -760,12 +760,12 @@ class RedactExecuteService {
char ch = raw.charAt(i);
if (Character.isLetterOrDigit(ch)) {
current.append(ch);
} else if (current.length() > 0) {
} else if (!current.isEmpty()) {
tokens.add(current.toString());
current.setLength(0);
}
}
if (current.length() > 0) tokens.add(current.toString());
if (!current.isEmpty()) tokens.add(current.toString());
if (tokens.size() < 2) return null;
StringBuilder out = new StringBuilder();
for (int i = 0; i < tokens.size(); i++) {
@@ -788,25 +788,25 @@ class RedactExecuteService {
StringBuilder current = new StringBuilder();
for (String token : tokens) {
if (token.isEmpty()) {
if (current.length() > 0) {
if (result.length() > 0) result.append(' ');
if (!current.isEmpty()) {
if (!result.isEmpty()) result.append(' ');
result.append(current);
current.setLength(0);
}
} else if (token.length() == 1) {
current.append(token);
} else {
if (current.length() > 0) {
if (result.length() > 0) result.append(' ');
if (!current.isEmpty()) {
if (!result.isEmpty()) result.append(' ');
result.append(current);
current.setLength(0);
}
if (result.length() > 0) result.append(' ');
if (!result.isEmpty()) result.append(' ');
result.append(token);
}
}
if (current.length() > 0) {
if (result.length() > 0) result.append(' ');
if (!current.isEmpty()) {
if (!result.isEmpty()) result.append(' ');
result.append(current);
}
return result.toString().trim();
@@ -251,7 +251,7 @@ public class MetricsController {
// For GET requests, validate if we have a list of valid endpoints
final boolean validateGetEndpoints =
endpointInspector.getValidGetEndpoints().size() != 0;
!endpointInspector.getValidGetEndpoints().isEmpty();
if ("GET".equals(method)
&& validateGetEndpoints
&& !endpointInspector.isValidGetEndpoint(uri)) {
@@ -292,7 +292,7 @@ public class MetricsController {
// For GET requests, validate if we have a list of valid endpoints
final boolean validateGetEndpoints =
endpointInspector.getValidGetEndpoints().size() != 0;
!endpointInspector.getValidGetEndpoints().isEmpty();
if ("GET".equals(method)
&& validateGetEndpoints
&& !endpointInspector.isValidGetEndpoint(uri)) {
@@ -332,7 +332,7 @@ public class MetricsController {
// For GET requests, validate if we have a list of valid endpoints
final boolean validateGetEndpoints =
endpointInspector.getValidGetEndpoints().size() != 0;
!endpointInspector.getValidGetEndpoints().isEmpty();
if ("GET".equals(method)
&& validateGetEndpoints
&& !endpointInspector.isValidGetEndpoint(uri)) {
@@ -5,17 +5,26 @@ import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.core.Ordered;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.RouterFunctions;
import org.springframework.web.servlet.function.ServerResponse;
import org.springframework.web.servlet.function.support.RouterFunctionMapping;
import org.springframework.web.util.HtmlUtils;
import org.springframework.web.util.JavaScriptUtils;
@@ -32,6 +41,33 @@ public class ReactRoutingController {
private static final Pattern BASE_HREF_PATTERN =
Pattern.compile("<base href=\\\"[^\\\"]*\\\"\\s*/?>");
// First path segments owned by the backend or static assets, never SPA routes.
// Mirrors the exclusion regexes on forwardRootPaths/forwardNestedPaths below.
private static final Set<String> NON_SPA_FIRST_SEGMENTS =
Set.of(
"api",
"static",
"pipeline",
"pdfjs",
"pdfjs-legacy",
"pdfium",
"vendor",
"fonts",
"images",
"css",
"js",
"assets",
"locales",
"modern-logo",
"classic-logo",
"Login",
"og_images",
"samples");
// After the annotated controllers (order 0), before the resource chain
// (LOWEST_PRECEDENCE - 1).
private static final int SPA_FALLBACK_ORDER = Ordered.LOWEST_PRECEDENCE - 2;
@Value("${server.servlet.context-path:/}")
private String contextPath;
@@ -44,6 +80,8 @@ public class ReactRoutingController {
private boolean saasLandingExists = false;
private String cachedMobileUploadHtml;
private boolean mobileUploadHtmlExists = false;
private String cachedMobileSignHtml;
private boolean mobileSignHtmlExists = false;
@PostConstruct
public void init() {
@@ -67,10 +105,12 @@ public class ReactRoutingController {
}
// Desktop (Tauri) serves the SPA from its bundled webview, so a phone scanning the QR can't
// load the React /mobile-scanner route from the local backend. Cache the self-contained
// static upload page to serve at that route in desktop mode instead.
// load the React /mobile-scanner or /mobile-sign routes from the local backend. Cache the
// self-contained static pages to serve at those routes in desktop mode instead.
this.cachedMobileUploadHtml = readStaticHtml("mobile-upload.html");
this.mobileUploadHtmlExists = this.cachedMobileUploadHtml != null;
this.cachedMobileSignHtml = readStaticHtml("mobile-sign.html");
this.mobileSignHtmlExists = this.cachedMobileSignHtml != null;
// Check for external index.html first (customFiles/static/)
Path externalIndexPath = Path.of(InstallationPathConfig.getStaticPath(), "index.html");
@@ -232,6 +272,17 @@ public class ReactRoutingController {
return serveIndexHtml(request);
}
@GetMapping(value = "/mobile-sign", produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> serveMobileSign(HttpServletRequest request) {
if (isDesktopMode() && mobileSignHtmlExists) {
return ResponseEntity.ok()
.cacheControl(CacheControl.noCache().mustRevalidate())
.contentType(MediaType.TEXT_HTML)
.body(cachedMobileSignHtml);
}
return serveIndexHtml(request);
}
@GetMapping(value = "/auth/callback/tauri", produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> serveTauriAuthCallback(HttpServletRequest request) {
// cachedCallbackHtml is always initialized in @PostConstruct
@@ -256,6 +307,59 @@ public class ReactRoutingController {
return serveIndexHtml(request);
}
// The regex mappings above only cover 1- and 2-segment paths (Spring path variables cannot
// span '/'), so deep SPA links like /processor/pipelines/new 404d on direct navigation.
//
// Registered as its own mapping rather than exposed as a bare RouterFunction @Bean:
// Spring's own RouterFunctionMapping is ordered -1, ahead of the annotated controllers at
// order 0, so a plain bean would shadow every dot-free backend route the denylist below
// does not name (/v1/api-docs, /error, /actuator, ...). LOWEST_PRECEDENCE - 2 puts it after
// the controllers and before the resource chain (LOWEST_PRECEDENCE - 1), which is the only
// position where a catch-all fallback is safe.
@Bean
public RouterFunctionMapping spaDeepLinkFallbackMapping() {
RouterFunction<ServerResponse> fallback =
RouterFunctions.route(
request -> {
HttpServletRequest servletRequest = request.servletRequest();
return "GET".equals(servletRequest.getMethod())
&& isSpaFallbackRoute(
stripContextPath(
servletRequest.getContextPath(),
servletRequest.getRequestURI()));
},
request ->
ServerResponse.ok()
.cacheControl(CacheControl.noCache().mustRevalidate())
.contentType(MediaType.TEXT_HTML)
.body(serveIndexHtml(request.servletRequest()).getBody()));
RouterFunctionMapping mapping = new RouterFunctionMapping(fallback);
mapping.setOrder(SPA_FALLBACK_ORDER);
mapping.setMessageConverters(
List.of(new StringHttpMessageConverter(StandardCharsets.UTF_8)));
return mapping;
}
// Dot-free paths only, so requests for real files still fall through to the resource
// handlers. This is a denylist, so it is only safe because the mapping above runs after
// the annotated controllers - see spaDeepLinkFallbackMapping.
static boolean isSpaFallbackRoute(String path) {
if (path == null || path.isEmpty() || "/".equals(path) || path.indexOf('.') >= 0) {
return false;
}
String[] segments = (path.startsWith("/") ? path.substring(1) : path).split("/");
return segments.length > 0
&& !segments[0].isEmpty()
&& !NON_SPA_FIRST_SEGMENTS.contains(segments[0]);
}
private static String stripContextPath(String contextPath, String uri) {
if (contextPath != null && !contextPath.isBlank() && uri.startsWith(contextPath)) {
return uri.substring(contextPath.length());
}
return uri;
}
private String buildFallbackHtml() {
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
@@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import java.io.IOException;
import java.io.InputStream;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import java.io.IOException;
import java.io.InputStream;
@@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import java.io.IOException;
import java.io.InputStream;
@@ -15,7 +15,7 @@
* limitations under the License.
*/
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import java.io.ByteArrayInputStream;
import java.io.IOException;
@@ -237,12 +237,12 @@ public class HardwareKeyStoreService {
combined.append(env);
}
if (prop != null && !prop.isBlank()) {
if (combined.length() > 0) {
if (!combined.isEmpty()) {
combined.append(java.io.File.pathSeparator);
}
combined.append(prop);
}
if (combined.length() == 0) {
if (combined.isEmpty()) {
return List.of();
}
return Arrays.stream(combined.toString().split("[,;" + java.io.File.pathSeparator + "]"))
@@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.pdfbox.examples.util;
package stirling.software.SPDF.utils;
import java.io.IOException;
import java.io.InputStream;
@@ -188,6 +188,7 @@ system:
backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
enableMobileSignature: true # Enable drawing signatures on a phone via QR code from the Sign tool. Requires frontendUrl to be configured.
mobileScannerSettings:
convertToPdf: true # Automatically convert uploaded images to PDF format. If false, images are kept as-is.
imageResolution: full # Image resolution for mobile uploads: 'full' (original size) or 'reduced' (max 1200px on longest side). Only applies when convertToPdf is true.
@@ -292,26 +293,15 @@ storage:
linkExpirationDays: 3 # Number of days before share links expire
signing:
enabled: false # set to 'true' to enable group signing workflow (requires storage.enabled) [ALPHA]
# ====================================================================================
# ENCRYPTION AT REST - PRO / ENTERPRISE LICENSE REQUIRED TO ENABLE
# ====================================================================================
# Encrypts stored files (AES-256 envelope encryption, per-team keys). The master key is
# resolved in this order:
# 1. stirling.security.fileEncryptionKey property
# 2. STIRLING_FILE_ENCRYPTION_KEY environment variable
# 3. an auto-generated configs/file-encryption.key (single-node only; cluster mode
# requires an explicitly shared key on every node)
# Generate a key with: openssl rand -base64 32
#
# *** BACK UP THE MASTER KEY. Losing it makes every encrypted stored file ***
# *** permanently unrecoverable. Verify backups against the key fingerprint logged ***
# *** at startup. ***
#
# Enabling encrypts new writes only (existing files stay readable as plaintext).
# Disabling later only stops encrypting new writes - existing encrypted files remain
# readable as long as the key material is present.
# Encryption at rest for stored files (AES-256, per-team keys). Requires a Pro or
# Enterprise licence. Key setup, cluster requirements, the encrypt-existing migration,
# the revocation kill switch and master-key rotation are documented in
# devGuide/STORAGE_ENCRYPTION_AT_REST.md
# WARNING: back up the master key (configs/file-encryption.key by default) - losing it
# makes every encrypted stored file permanently unrecoverable.
encryption:
enabled: false # set to 'true' to encrypt stored files at rest
auditReads: true # audit every decrypt of an encrypted file (denied decrypts and key lifecycle events are always audited). NOTE: audit events require an Enterprise licence; encryption itself works on Pro.
userListScope: org # Signing user-picker scope: 'org' (default) = whole instance, else caller's team only.
autoPipeline:
outputFolder: "" # Output folder for processed pipeline files (leave empty for default)
@@ -0,0 +1,642 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#c02223">
<meta name="robots" content="noindex">
<title>Stirling PDF - Draw Signature</title>
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="shortcut icon" href="favicon.ico">
<style>
:root {
--brand: #c02223;
--brand-dark: #882425;
--bg: #f3f4f6;
--card: #ffffff;
--text: #111827;
--text-dim: #6b7280;
--border: #e5e7eb;
--tile: #f9fafb;
--tile-hover: #f1f3f5;
--success: #16a34a;
--danger: #dc2626;
--shadow: 0 1.25rem 3rem rgba(0, 0, 0, 0.12);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1115;
--card: #1a1d23;
--text: #f3f4f6;
--text-dim: #9ca3af;
--border: #2c303a;
--tile: #22262e;
--tile-hover: #2a2f38;
--shadow: 0 1.25rem 3rem rgba(0, 0, 0, 0.5);
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html, body {
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg);
color: var(--text);
min-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: env(safe-area-inset-top) 1rem 1.5rem;
}
.wrap {
width: 100%;
max-width: 34rem;
display: flex;
flex-direction: column;
gap: 1rem;
margin: auto;
}
header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.5rem 0 0.25rem;
justify-content: center;
}
header .logo {
width: 2.75rem;
height: 2.75rem;
flex: none;
}
header .titles {
display: flex;
flex-direction: column;
line-height: 1.15;
}
header .wordmark {
height: 1.5rem;
width: auto;
display: block;
color: var(--text);
}
header .subtitle {
font-size: 0.8rem;
color: var(--text-dim);
font-weight: 500;
}
.card {
background-color: var(--card);
border-radius: 1.25rem;
box-shadow: var(--shadow);
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.status {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.85rem;
padding: 0.7rem 0.85rem;
border-radius: 0.75rem;
background: var(--tile);
color: var(--text-dim);
}
.status .dot {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: var(--text-dim);
flex: none;
}
.status.ok { color: var(--success); }
.status.ok .dot { background: var(--success); }
.status.err { color: var(--danger); }
.status.err .dot { background: var(--danger); }
/* The pad stays white in both themes: signatures land on paper-white
pages, so drawing on white previews the real result. */
.pad {
position: relative;
width: 100%;
height: min(45vh, 20rem);
background: #ffffff;
border: 1px solid var(--border);
border-radius: 0.9rem;
overflow: hidden;
}
.pad canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: crosshair;
}
.pad .guide {
position: absolute;
left: 8%;
right: 8%;
bottom: 22%;
border-bottom: 1px dashed #d1d5db;
pointer-events: none;
}
/* Landscape phones: shorter chrome, give the pad more of the height. */
@media (orientation: landscape) and (max-height: 32rem) {
header { padding-top: 0.5rem; }
.pad { height: min(58vh, 16rem); }
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.swatches { display: flex; gap: 0.5rem; }
.swatch {
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 3px solid transparent;
cursor: pointer;
}
.swatch.active { border-color: var(--brand); }
.sizes { display: flex; gap: 0.25rem; }
.size-btn {
border: 1px solid var(--border);
background: var(--tile);
color: var(--text);
border-radius: 0.5rem;
padding: 0.35rem 0.7rem;
font-size: 0.8rem;
cursor: pointer;
}
.size-btn.active {
border-color: var(--brand);
color: var(--brand);
font-weight: 600;
}
.tools { display: flex; gap: 0.5rem; }
.tool-btn {
border: 1px solid var(--border);
background: var(--tile);
color: var(--text);
border-radius: 0.5rem;
padding: 0.45rem 0.8rem;
font-size: 0.85rem;
cursor: pointer;
}
.tool-btn:disabled { opacity: 0.45; cursor: default; }
.btn {
appearance: none;
border: none;
border-radius: 0.9rem;
background: var(--brand);
color: #fff;
font-size: 1rem;
font-weight: 600;
padding: 0.9rem 1rem;
cursor: pointer;
}
.btn:active { background: var(--brand-dark); }
.btn:disabled { opacity: 0.5; cursor: default; }
.progress {
height: 0.4rem;
border-radius: 0.25rem;
background: var(--tile);
overflow: hidden;
}
.progress span {
display: block;
height: 100%;
width: 0;
background: var(--brand);
transition: width 0.15s ease;
}
.hint {
font-size: 0.78rem;
color: var(--text-dim);
text-align: center;
line-height: 1.4;
}
.done {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
padding: 0.75rem 0;
text-align: center;
}
.done .check {
width: 3.25rem;
height: 3.25rem;
border-radius: 50%;
background: var(--success);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.done .check svg { width: 1.75rem; height: 1.75rem; }
.hidden { display: none !important; }
footer {
text-align: center;
font-size: 0.72rem;
color: var(--text-dim);
padding-top: 0.25rem;
}
</style>
</head>
<body>
<div class="wrap">
<header>
<svg class="logo" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<linearGradient id="lg" x1="223" x2="241" y1="372" y2="114" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#dcf1f3"/><stop offset="1" stop-color="#c2c2c9"/>
</linearGradient>
<path fill="#c02223" d="M347 14 99 69c-25 6-43 28-43 54v315c0 22 17 41 39 44l130 16 115-114 116-119-2-180c0-22-20-38-42-34l-29 7 0-14C383 25 366 10 347 14Z"/>
<path fill="#882425" d="M383 57v286l73-78-2-186c0-19-18-33-36-29Z"/>
<path fill="url(#lg)" d="M283 215s-22-29-39-37c-10-5-26-7-38-1-18 9-20 34-3 44 4 2 9 5 16 6 37 8 97 26 90 96 0 0-4 66-74 73-12 1-25 0-36-2-16-4-46-11-60-20l-3-75h7s14 33 53 50c7 3 15 4 23 3 10-1 21-7 26-23 0 0 8-23-33-36-33-10-63-21-76-55-6-16-7-34-3-52 5-22 21-54 64-68 0 0 57-16 97 6l-2 90Z"/>
</svg>
<div class="titles">
<div class="wordmark" style="font-size:1.25rem;font-weight:700;">Stirling PDF</div>
<div class="subtitle">Draw Signature</div>
</div>
</header>
<div class="card" id="signer">
<div class="status" id="status"><span class="dot"></span><span id="statusText">Connecting&hellip;</span></div>
<div class="pad">
<div class="guide"></div>
<canvas id="pad" aria-label="Signature drawing area"></canvas>
</div>
<div class="toolbar">
<div class="swatches">
<button class="swatch active" type="button" data-color="#101010" style="background:#101010" aria-label="Black ink"></button>
<button class="swatch" type="button" data-color="#1d4ed8" style="background:#1d4ed8" aria-label="Blue ink"></button>
</div>
<div class="sizes">
<button class="size-btn" type="button" data-size="2">S</button>
<button class="size-btn active" type="button" data-size="3.5">M</button>
<button class="size-btn" type="button" data-size="6">L</button>
</div>
<div class="tools">
<button class="tool-btn" id="undoBtn" type="button" disabled>Undo</button>
<button class="tool-btn" id="clearBtn" type="button" disabled>Clear</button>
</div>
</div>
<button class="btn" id="sendBtn" type="button" disabled>Send to computer</button>
<div class="progress hidden" id="progressWrap"><span id="progressBar"></span></div>
<p class="hint">Draw your signature above, then send it. It appears in the Sign tool on your computer automatically.</p>
</div>
<div class="card hidden" id="successCard">
<div class="done">
<div class="check">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 13 4 4L19 7"/></svg>
</div>
<h2>Sent!</h2>
<p>Your signature is now on your computer.</p>
<button class="tool-btn" id="againBtn" type="button">Draw another</button>
</div>
</div>
<footer>Stirling PDF &middot; signatures transfer directly to your desktop</footer>
</div>
<script>
(function () {
"use strict";
var API_BASE = new URL(".", window.location.href).href;
var params = new URLSearchParams(window.location.search);
var sessionId = (params.get("session") || "").trim();
var SESSION_RE = /^[A-Za-z0-9-]{8,128}$/;
var els = {
status: document.getElementById("status"),
statusText: document.getElementById("statusText"),
pad: document.getElementById("pad"),
undoBtn: document.getElementById("undoBtn"),
clearBtn: document.getElementById("clearBtn"),
sendBtn: document.getElementById("sendBtn"),
progressWrap: document.getElementById("progressWrap"),
progressBar: document.getElementById("progressBar"),
signer: document.getElementById("signer"),
successCard: document.getElementById("successCard"),
againBtn: document.getElementById("againBtn")
};
var sessionReady = false;
var penColor = "#101010";
var penSize = 3.5;
var strokes = [];
var activeStroke = null;
function setStatus(text, kind) {
els.statusText.textContent = text;
els.status.className = "status" + (kind ? " " + kind : "");
}
function refreshButtons() {
var hasInk = strokes.length > 0;
els.undoBtn.disabled = !hasInk;
els.clearBtn.disabled = !hasInk;
els.sendBtn.disabled = !sessionReady || !hasInk;
}
// ---- drawing ------------------------------------------------------
// Strokes are stored in CSS-pixel space; the backing store is scaled
// by devicePixelRatio so ink lands exactly under the finger.
var ctx = els.pad.getContext("2d");
function resizeCanvas() {
var rect = els.pad.getBoundingClientRect();
var dpr = window.devicePixelRatio || 1;
els.pad.width = Math.max(1, Math.round(rect.width * dpr));
els.pad.height = Math.max(1, Math.round(rect.height * dpr));
redraw();
}
function drawStroke(target, stroke) {
var pts = stroke.points;
if (pts.length === 0) return;
target.strokeStyle = stroke.color;
target.fillStyle = stroke.color;
target.lineWidth = stroke.size;
target.lineCap = "round";
target.lineJoin = "round";
if (pts.length === 1) {
target.beginPath();
target.arc(pts[0].x, pts[0].y, stroke.size / 2, 0, Math.PI * 2);
target.fill();
return;
}
// Quadratic midpoint smoothing turns pointer samples into a pen-like line
target.beginPath();
target.moveTo(pts[0].x, pts[0].y);
for (var i = 1; i < pts.length - 1; i++) {
var midX = (pts[i].x + pts[i + 1].x) / 2;
var midY = (pts[i].y + pts[i + 1].y) / 2;
target.quadraticCurveTo(pts[i].x, pts[i].y, midX, midY);
}
var last = pts[pts.length - 1];
target.lineTo(last.x, last.y);
target.stroke();
}
function redraw() {
var dpr = window.devicePixelRatio || 1;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, els.pad.width / dpr, els.pad.height / dpr);
for (var i = 0; i < strokes.length; i++) drawStroke(ctx, strokes[i]);
if (activeStroke) drawStroke(ctx, activeStroke);
}
function pointFrom(e) {
var rect = els.pad.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
els.pad.addEventListener("pointerdown", function (e) {
if (activeStroke) return; // one stroke at a time
els.pad.setPointerCapture(e.pointerId);
activeStroke = { color: penColor, size: penSize, points: [pointFrom(e)] };
redraw();
});
els.pad.addEventListener("pointermove", function (e) {
if (!activeStroke) return;
var events = e.getCoalescedEvents ? e.getCoalescedEvents() : [e];
var rect = els.pad.getBoundingClientRect();
for (var i = 0; i < events.length; i++) {
activeStroke.points.push({
x: events[i].clientX - rect.left,
y: events[i].clientY - rect.top
});
}
redraw();
});
function endStroke() {
if (!activeStroke) return;
strokes.push(activeStroke);
activeStroke = null;
redraw();
refreshButtons();
}
els.pad.addEventListener("pointerup", endStroke);
els.pad.addEventListener("pointercancel", endStroke);
els.undoBtn.onclick = function () {
strokes.pop();
redraw();
refreshButtons();
};
els.clearBtn.onclick = function () {
strokes = [];
activeStroke = null;
redraw();
refreshButtons();
};
document.querySelectorAll(".swatch").forEach(function (btn) {
btn.onclick = function () {
penColor = btn.getAttribute("data-color");
document.querySelectorAll(".swatch").forEach(function (b) { b.classList.remove("active"); });
btn.classList.add("active");
};
});
document.querySelectorAll(".size-btn").forEach(function (btn) {
btn.onclick = function () {
penSize = parseFloat(btn.getAttribute("data-size"));
document.querySelectorAll(".size-btn").forEach(function (b) { b.classList.remove("active"); });
btn.classList.add("active");
};
});
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
// ---- export + upload ---------------------------------------------
function exportBlob(callback) {
if (strokes.length === 0) { callback(null); return; }
// Crop to the inked region (plus padding) so the signature
// stamps tightly on the desktop side.
var rect = els.pad.getBoundingClientRect();
var PAD = 12;
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
strokes.forEach(function (stroke) {
var reach = stroke.size / 2 + PAD;
stroke.points.forEach(function (p) {
if (p.x - reach < minX) minX = p.x - reach;
if (p.y - reach < minY) minY = p.y - reach;
if (p.x + reach > maxX) maxX = p.x + reach;
if (p.y + reach > maxY) maxY = p.y + reach;
});
});
minX = Math.max(0, minX);
minY = Math.max(0, minY);
maxX = Math.min(rect.width, maxX);
maxY = Math.min(rect.height, maxY);
var dpr = window.devicePixelRatio || 1;
var out = document.createElement("canvas");
out.width = Math.max(1, Math.round((maxX - minX) * dpr));
out.height = Math.max(1, Math.round((maxY - minY) * dpr));
var outCtx = out.getContext("2d");
outCtx.setTransform(dpr, 0, 0, dpr, -minX * dpr, -minY * dpr);
for (var i = 0; i < strokes.length; i++) drawStroke(outCtx, strokes[i]);
out.toBlob(callback, "image/png");
}
els.sendBtn.onclick = function () {
if (!sessionReady || strokes.length === 0) return;
exportBlob(function (blob) {
if (!blob) return;
var form = new FormData();
form.append("files", blob, "signature-draw-" + Date.now() + ".png");
var xhr = new XMLHttpRequest();
xhr.open("POST", API_BASE + "api/v1/mobile-scanner/upload/" + encodeURIComponent(sessionId));
els.sendBtn.disabled = true;
els.progressWrap.classList.remove("hidden");
els.progressBar.style.width = "0%";
xhr.upload.onprogress = function (e) {
if (e.lengthComputable) {
els.progressBar.style.width = Math.round((e.loaded / e.total) * 100) + "%";
}
};
xhr.onload = function () {
els.progressWrap.classList.add("hidden");
if (xhr.status >= 200 && xhr.status < 300) {
els.signer.classList.add("hidden");
els.successCard.classList.remove("hidden");
} else {
var msg = "Sending failed. Please try again.";
if (xhr.status === 403) msg = "Mobile signatures are not enabled on this server.";
else if (xhr.status === 400) msg = "Session expired. Re-scan the QR code.";
setStatus(msg, "err");
refreshButtons();
}
};
xhr.onerror = function () {
els.progressWrap.classList.add("hidden");
setStatus("Network error. Stay on the same Wi-Fi and try again.", "err");
refreshButtons();
};
xhr.send(form);
});
};
els.againBtn.onclick = function () {
strokes = [];
activeStroke = null;
els.successCard.classList.add("hidden");
els.signer.classList.remove("hidden");
resizeCanvas();
refreshButtons();
};
// ---- session ------------------------------------------------------
function init() {
if (!sessionId || !SESSION_RE.test(sessionId)) {
setStatus("Invalid link. Re-scan the QR code on your computer.", "err");
return;
}
setStatus("Connecting…");
fetch(API_BASE + "api/v1/mobile-scanner/validate-session/" + encodeURIComponent(sessionId))
.then(function (res) {
if (res.ok) {
return res.json().then(function (data) {
if (data && data.valid) {
setStatus("Connected to your computer", "ok");
sessionReady = true;
refreshButtons();
} else {
setStatus("Session expired. Re-scan the QR code.", "err");
}
});
}
if (res.status === 403) {
setStatus("Mobile signatures are not enabled on this server.", "err");
} else {
setStatus("Session not found. Re-scan the QR code.", "err");
}
})
.catch(function () {
setStatus("Can't reach your computer. Stay on the same Wi-Fi.", "err");
});
}
init();
})();
</script>
</body>
</html>
@@ -16,6 +16,7 @@ To adjust the DPI resolution for rendering PDF pages:
import argparse
import os
from pdf2image import convert_from_path
from PIL import Image
@@ -149,9 +150,7 @@ def main(pdf_image_path, output_dir, dpi=300, single_images_flag=False):
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Convert a PDF file to WebP images.")
parser.add_argument("pdf_path", help="The path to the input PDF file.")
parser.add_argument(
"output_dir", help="The directory where the WebP images should be saved."
)
parser.add_argument("output_dir", help="The directory where the WebP images should be saved.")
parser.add_argument(
"--dpi",
type=int,
@@ -26,6 +26,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.model.tool.ToolIOSpec;
import stirling.software.common.service.ToolIOParameterDefaults;
/**
* Every document-transforming endpoint must declare its I/O, or it becomes a hole in the
@@ -233,6 +234,32 @@ class ToolIODeclarationCoverageTest {
assertEquals(Set.of("ps", "pcl", "xps"), declared);
}
@Test
void anAbsentParameterResolvesToItsRequestModelDefault() {
// A pipeline step often omits a parameter a case branches on. The default is read from the
// request model, so the output resolves anyway instead of coming back uncertain.
// Auto Rotate never sends dryRun; its default (false) means the JSON branch cannot fire.
assertEquals(
ToolFormat.PDF,
spec("/api/v1/misc/auto-rotate-pdf").resolveOutput(Map.of()).format());
assertTrue(spec("/api/v1/misc/auto-rotate-pdf").resolveOutput(Map.of()).certain());
// Change Permissions posts to add-password with no password fields; both default to blank,
// so the unencrypted branch fires and it is not mistaken for producing an encrypted PDF.
assertEquals(
ToolFormat.PDF,
spec("/api/v1/security/add-password").resolveOutput(Map.of()).format());
assertTrue(spec("/api/v1/security/add-password").resolveOutput(Map.of()).certain());
}
@Test
void aRequiredParameterWithNoDefaultStaysUncertainWhenAbsent() {
// pdf/text branches on outputFormat, which is required with no default. Absent, its output
// is genuinely txt-or-rtf-dependent, so it must remain uncertain rather than assume TEXT.
assertFalse(spec("/api/v1/convert/pdf/text").resolveOutput(Map.of()).certain());
}
@Test
void onlyRemovePasswordAcceptsAnEncryptedDocument() {
assertTrue(
@@ -288,7 +315,11 @@ class ToolIODeclarationCoverageTest {
}
required.add(full);
if (declaration != null) {
declared.put(full, ToolIOSpec.from(declaration));
declared.put(
full,
ToolIOSpec.from(
declaration,
param -> ToolIOParameterDefaults.resolve(method, param)));
}
}
}
@@ -49,6 +49,33 @@ class MobileScannerControllerTest {
when(systemProps.isEnableMobileScanner()).thenReturn(false);
}
// --- shared-endpoint gating: scanner and mobile signature share this API ---
@Test
void createSession_whenOnlyMobileSignatureEnabled_returnsOk() {
// The signature feature must work with the scanner switched off.
when(applicationProperties.getSystem()).thenReturn(systemProps);
when(systemProps.isEnableMobileScanner()).thenReturn(false);
when(systemProps.isEnableMobileSignature()).thenReturn(true);
SessionInfo sessionInfo = new SessionInfo("test-session", 1000L, 601000L, 600000L);
when(mobileScannerService.createSession("test-session")).thenReturn(sessionInfo);
ResponseEntity<Map<String, Object>> response = controller.createSession("test-session");
assertEquals(HttpStatus.OK, response.getStatusCode());
}
@Test
void createSession_whenBothFeaturesDisabled_returnsForbidden() {
when(applicationProperties.getSystem()).thenReturn(systemProps);
when(systemProps.isEnableMobileScanner()).thenReturn(false);
when(systemProps.isEnableMobileSignature()).thenReturn(false);
ResponseEntity<Map<String, Object>> response = controller.createSession("test-session");
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
}
// --- createSession tests ---
@Test
@@ -264,21 +264,6 @@ class GetInfoOnPDFMoreTest {
@DisplayName("error handling")
class Errors {
@Test
@DisplayName("empty file input yields an error response")
void emptyFile() throws Exception {
MockMultipartFile mf =
new MockMultipartFile("fileInput", "x.pdf", "application/pdf", new byte[0]);
PDFFile request = new PDFFile();
request.setFileInput(mf);
ResponseEntity<byte[]> resp = getInfoOnPDF.getPdfInfo(request);
// createErrorResponse returns HTTP 200 with a JSON body carrying an "error" field.
assertThat(resp.getBody()).isNotNull();
JsonNode body = om.readTree(resp.getBody());
assertThat(body.has("error")).isTrue();
assertThat(body.get("error").asText("")).contains("Invalid");
}
@Test
@DisplayName("veraPDF failure is swallowed and a report is still produced")
void veraPdfFailureSwallowed() throws Exception {
@@ -556,24 +556,6 @@ class GetInfoOnPDFTest {
@DisplayName("Validation and Error Handling Tests")
class ValidationErrorTests {
@Test
@DisplayName("Should reject null file")
void testValidation_NullFile() throws IOException {
PDFFile request = new PDFFile();
request.setFileInput(null);
ResponseEntity<byte[]> response = getInfoOnPDF.getPdfInfo(request);
Assertions.assertEquals(
HttpStatus.OK, response.getStatusCode()); // Returns error JSON with 200
String jsonResponse = new String(response.getBody(), StandardCharsets.UTF_8);
JsonNode jsonNode = objectMapper.readTree(jsonResponse);
Assertions.assertTrue(jsonNode.has("error"));
Assertions.assertTrue(
jsonNode.get("error").asText("").contains("PDF file is required"));
}
@Test
@DisplayName("Should reject empty file")
void testValidation_EmptyFile() throws IOException {
@@ -591,64 +573,6 @@ class GetInfoOnPDFTest {
Assertions.assertTrue(jsonNode.has("error"));
}
@Test
@DisplayName("Should reject file that exceeds max size")
void testValidation_TooLargeFile() throws IOException {
MultipartFile largeFile =
new MultipartFile() {
@Override
public String getName() {
return "file";
}
@Override
public String getOriginalFilename() {
return "large.pdf";
}
@Override
public String getContentType() {
return MediaType.APPLICATION_PDF_VALUE;
}
@Override
public boolean isEmpty() {
return false;
}
@Override
public long getSize() {
// Report 101 MB without allocating memory
return 101L * 1024L * 1024L;
}
@Override
public byte[] getBytes() {
return new byte[0];
}
@Override
public java.io.InputStream getInputStream() {
return java.io.InputStream.nullInputStream();
}
@Override
public void transferTo(java.io.File dest) throws IllegalStateException {}
};
PDFFile request = new PDFFile();
request.setFileInput(largeFile);
ResponseEntity<byte[]> response = getInfoOnPDF.getPdfInfo(request);
String jsonResponse = new String(response.getBody(), StandardCharsets.UTF_8);
JsonNode jsonNode = objectMapper.readTree(jsonResponse);
Assertions.assertTrue(jsonNode.has("error"));
Assertions.assertTrue(
jsonNode.get("error").asText("").contains("exceeds maximum allowed size"));
}
}
@Nested
@@ -4,12 +4,24 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.web.servlet.function.EntityResponse;
import org.springframework.web.servlet.function.HandlerFunction;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.ServerRequest;
import org.springframework.web.servlet.function.ServerResponse;
import org.springframework.web.servlet.function.support.RouterFunctionMapping;
import org.springframework.web.util.ServletRequestPathUtils;
import jakarta.servlet.http.HttpServletRequest;
@@ -175,6 +187,83 @@ class ReactRoutingControllerTest {
assertNotNull(response.getBody());
}
// --- deep-link SPA fallback (router function) ---
@Test
void isSpaFallbackRoute_acceptsDeepSpaPaths() {
assertTrue(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/new"));
assertTrue(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/123/runs/456"));
assertTrue(ReactRoutingController.isSpaFallbackRoute("/workflow/sign/some-token"));
assertTrue(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/new/"));
// "pipelines" must not be swallowed by the "pipeline" exclusion
assertTrue(ReactRoutingController.isSpaFallbackRoute("/pipelines"));
}
@Test
void isSpaFallbackRoute_rejectsBackendStaticAndFilePaths() {
assertFalse(ReactRoutingController.isSpaFallbackRoute("/api/v1/some/endpoint"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/pipeline"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/pipeline/anything"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/assets/deep/path"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/file.js"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/branding/sub/logo.png"));
assertFalse(ReactRoutingController.isSpaFallbackRoute("/"));
assertFalse(ReactRoutingController.isSpaFallbackRoute(""));
assertFalse(ReactRoutingController.isSpaFallbackRoute(null));
}
@Test
void spaDeepLinkFallback_servesIndexForDeepRoute() throws Exception {
controller.init();
RouterFunction<ServerResponse> router = routerOf(controller.spaDeepLinkFallbackMapping());
ServerRequest deepRequest = serverRequest("GET", "/processor/pipelines/new");
Optional<HandlerFunction<ServerResponse>> handler = router.route(deepRequest);
assertTrue(handler.isPresent());
ServerResponse response = handler.get().handle(deepRequest);
assertEquals(HttpStatus.OK, response.statusCode());
assertInstanceOf(EntityResponse.class, response);
Object body = ((EntityResponse<?>) response).entity();
assertTrue(body.toString().contains("Stirling PDF"));
}
@Test
void spaDeepLinkFallback_ignoresApiFilesAndNonGet() {
controller.init();
RouterFunction<ServerResponse> router = routerOf(controller.spaDeepLinkFallbackMapping());
assertTrue(router.route(serverRequest("GET", "/api/v1/policies/run")).isEmpty());
assertTrue(router.route(serverRequest("GET", "/branding/sub/logo.png")).isEmpty());
assertTrue(router.route(serverRequest("POST", "/processor/pipelines/new")).isEmpty());
}
@Test
void spaDeepLinkFallback_runsAfterControllersAndBeforeResources() {
controller.init();
int order = controller.spaDeepLinkFallbackMapping().getOrder();
// A catch-all denylist is only safe below every annotated controller; Spring's own
// RouterFunctionMapping sits at -1, which would shadow /v1/api-docs, /error and friends.
assertTrue(order > 0, "SPA fallback must run after annotated controllers");
assertTrue(
order < Ordered.LOWEST_PRECEDENCE - 1,
"SPA fallback must run before the static-resource chain");
}
private static RouterFunction<ServerResponse> routerOf(RouterFunctionMapping mapping) {
@SuppressWarnings("unchecked")
RouterFunction<ServerResponse> router =
(RouterFunction<ServerResponse>) mapping.getRouterFunction();
return router;
}
private static ServerRequest serverRequest(String method, String uri) {
MockHttpServletRequest servletRequest = new MockHttpServletRequest(method, uri);
ServletRequestPathUtils.parseAndCache(servletRequest);
return ServerRequest.create(servletRequest, List.of(new StringHttpMessageConverter()));
}
// --- context path handling ---
@Test
@@ -1,4 +1,4 @@
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -1,4 +1,4 @@
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -1,4 +1,4 @@
package org.apache.pdfbox.examples.util;
package stirling.software.SPDF.utils;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

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