mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0638f8db8d |
+65
-42
@@ -1,52 +1,75 @@
|
||||
# Version control
|
||||
.git/
|
||||
# Node modules and build artifacts
|
||||
node_modules
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
frontend/build
|
||||
frontend/.vite
|
||||
frontend/.tauri
|
||||
frontend/src-tauri/target
|
||||
|
||||
# Gradle build artifacts
|
||||
.gradle
|
||||
build
|
||||
bin
|
||||
target
|
||||
out
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Build outputs
|
||||
build/
|
||||
*/build/
|
||||
**/build/
|
||||
out/
|
||||
target/
|
||||
|
||||
# Gradle caches (local, not what's in the container)
|
||||
.gradle/
|
||||
**/.gradle/
|
||||
|
||||
# Node / frontend
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
.npm/
|
||||
.yarn/
|
||||
|
||||
# IDE and editor
|
||||
.idea/
|
||||
.vscode/
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
*.ipr
|
||||
|
||||
# Logs and temp files
|
||||
# Logs
|
||||
*.log
|
||||
*.tmp
|
||||
*.pid
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
logs
|
||||
|
||||
# Docker itself
|
||||
Dockerfile*
|
||||
.dockerignore
|
||||
|
||||
# CI / CD configs (not needed in build context)
|
||||
.github/
|
||||
.circleci/
|
||||
.gitlab-ci.yml
|
||||
|
||||
# Test reports
|
||||
**/test-results/
|
||||
**/jacoco/
|
||||
|
||||
# Local env
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Java compiled files
|
||||
*.class
|
||||
*.jar
|
||||
*.war
|
||||
*.ear
|
||||
|
||||
# Test reports
|
||||
test-results
|
||||
coverage
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
.dockerignore
|
||||
|
||||
# Temporary files
|
||||
tmp
|
||||
temp
|
||||
*.tmp
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# Runtime database and config files (locked by running app)
|
||||
app/core/configs/**
|
||||
stirling/**
|
||||
stirling-pdf-DB*.mv.db
|
||||
stirling-pdf-DB*.trace.db
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
!README.md
|
||||
docs
|
||||
|
||||
# CI/CD
|
||||
.github
|
||||
.gitlab-ci.yml
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ indent_size = 4
|
||||
max_line_length = 100
|
||||
|
||||
[*.py]
|
||||
indent_size = 4
|
||||
indent_size = 2
|
||||
|
||||
[*.gradle]
|
||||
indent_size = 4
|
||||
|
||||
+8
-5
@@ -2,17 +2,20 @@
|
||||
* @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
|
||||
|
||||
# Backend
|
||||
/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh @balazs-szucs
|
||||
/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
|
||||
|
||||
#V1 frontend
|
||||
/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
|
||||
/app/core/src/main/resources/templates/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
|
||||
|
||||
#V2 frontend
|
||||
/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @balazs-szucs
|
||||
/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87 @balazs-szucs
|
||||
/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle
|
||||
|
||||
#V2 docker
|
||||
/docker/backend/** @Frooodle @Ludy87 @DarioGii
|
||||
/docker/backend/** @Frooodle @Ludy87 @DarioGii @Ludy87
|
||||
/docker/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
|
||||
/docker/compose/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
|
||||
|
||||
|
||||
#GHA (All users)
|
||||
/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87 @balazs-szucs
|
||||
/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# CI Configuration
|
||||
|
||||
## CI Lite Mode
|
||||
|
||||
Skip non-essential CI workflows by setting a repository variable:
|
||||
|
||||
**Settings → Secrets and variables → Actions → Variables → New repository variable**
|
||||
|
||||
- Name: `CI_PROFILE`
|
||||
- Value: `lite`
|
||||
|
||||
Skips resource-intensive builds, releases, and OSS-specific workflows. Useful for deployment-only forks or faster CI runs.
|
||||
+13
-35
@@ -2,28 +2,26 @@ build: &build
|
||||
- build.gradle
|
||||
- app/(common|core|proprietary)/build.gradle
|
||||
|
||||
openapi: &openapi
|
||||
- *build
|
||||
app: &app
|
||||
- app/(common|core|proprietary)/src/main/java/**
|
||||
|
||||
docker: &docker
|
||||
- Dockerfile
|
||||
- Dockerfile.fat
|
||||
- Dockerfile.ultra-lite
|
||||
- ".github/workflows/build.yml"
|
||||
- scripts/init.sh
|
||||
- scripts/init-without-ocr.sh
|
||||
- exampleYmlFiles/**
|
||||
openapi: &openapi
|
||||
- build.gradle
|
||||
- app/(common|core|proprietary)/build.gradle
|
||||
- app/(common|core|proprietary)/src/main/java/**
|
||||
|
||||
project: &project
|
||||
- app/(common|core|proprietary)/src/(main|test)/java/**
|
||||
- *build
|
||||
- "app/(common|core|proprietary)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*"
|
||||
- app/(common|core|proprietary)/build.gradle
|
||||
- 'app/(common|core|proprietary)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*'
|
||||
- exampleYmlFiles/**
|
||||
- gradle/**
|
||||
- libs/**
|
||||
- "testing/**/!(requirements*.txt|requirements*.in)*"
|
||||
- *docker
|
||||
- testing/**
|
||||
- build.gradle
|
||||
- Dockerfile
|
||||
- Dockerfile.fat
|
||||
- Dockerfile.ultra-lite
|
||||
- gradle.properties
|
||||
- gradlew
|
||||
- gradlew.bat
|
||||
@@ -31,30 +29,10 @@ project: &project
|
||||
- settings.gradle
|
||||
- frontend/**
|
||||
- docker/**
|
||||
- scripts/RestartHelper.java
|
||||
- testing/**
|
||||
|
||||
frontend: &frontend
|
||||
- frontend/**
|
||||
- .github/workflows/testdriver.yml
|
||||
- testing/**
|
||||
- docker/**
|
||||
- scripts/translations/*.py
|
||||
- scripts/build-tauri-jlink.bat
|
||||
- scripts/build-tauri-jlink.sh
|
||||
- scripts/convert_cff_to_ttf.py
|
||||
- scripts/harvest_type3_fonts.py
|
||||
- scripts/ignore_translation.toml
|
||||
- scripts/index_type3_catalogue.py
|
||||
- scripts/summarize_type3_signatures.py
|
||||
- scripts/type3_to_cff.py
|
||||
- scripts/update_type3_library.py
|
||||
|
||||
licenses-frontend: &licenses-frontend
|
||||
- ".github/workflows/frontend-backend-licenses-update.yml"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/scripts/generate-licenses.js"
|
||||
|
||||
licenses-backend: &licenses-backend
|
||||
- ".github/workflows/frontend-backend-licenses-update.yml"
|
||||
- *build
|
||||
@@ -1 +1 @@
|
||||
allow-ghsas: GHSA-wrw7-89jp-8q8g
|
||||
allow-ghsas: GHSA-wrw7-89jp-8q8g
|
||||
@@ -1,9 +1,4 @@
|
||||
{
|
||||
"label_changer": [
|
||||
"Frooodle",
|
||||
"Ludy87",
|
||||
"balazs-szucs"
|
||||
],
|
||||
"repo_devs": [
|
||||
"Frooodle",
|
||||
"sf298",
|
||||
@@ -13,8 +8,6 @@
|
||||
"reecebrowne",
|
||||
"DarioGii",
|
||||
"ConnorYoh",
|
||||
"EthanHealy01",
|
||||
"jbrunton96",
|
||||
"balazs-szucs"
|
||||
"EthanHealy01"
|
||||
]
|
||||
}
|
||||
|
||||
+2
-54
@@ -6,70 +6,18 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gradle" # See documentation for possible values
|
||||
directories:
|
||||
- "/" # Location of package manifests
|
||||
- "/app/common"
|
||||
- "/app/core"
|
||||
- "/app/proprietary"
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/" # Location of Dockerfile
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /devTools
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: /docker/backend
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: /docker/embedded
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: /docker/frontend
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /frontend
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: cargo
|
||||
directory: /frontend/src-tauri
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: pip
|
||||
directory: /testing/cucumber
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: cargo
|
||||
directory: /frontend/src-tauri/provisioner
|
||||
schedule:
|
||||
interval: daily
|
||||
|
||||
@@ -46,31 +46,26 @@ labels:
|
||||
- label: 'API'
|
||||
title: '.*openapi.*|.*swagger.*|.*api.*'
|
||||
|
||||
- label: 'v3'
|
||||
base-branch: 'V3'
|
||||
- label: 'v2'
|
||||
base-branch: 'V2'
|
||||
|
||||
- label: 'Translation'
|
||||
files:
|
||||
- 'frontend/public/locales/[a-zA-Z]{2}-[a-zA-Z\-]{2,7}/translation.toml'
|
||||
- 'app/core/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}.properties'
|
||||
- 'scripts/ignore_translation.toml'
|
||||
- 'scripts/remove_translation_keys.sh'
|
||||
- 'scripts/replace_translation_line.sh'
|
||||
- 'scripts/translations/.*'
|
||||
- '.github/scripts/check_language_toml.py'
|
||||
- 'scripts/counter_translation_v3.py'
|
||||
- 'app/core/src/main/resources/templates/fragments/languages.html'
|
||||
- '.github/scripts/check_language_properties.py'
|
||||
|
||||
- label: 'Front End'
|
||||
files:
|
||||
- 'app/core/src/main/resources/templates/.*'
|
||||
- 'app/proprietary/src/main/resources/templates/.*'
|
||||
- 'app/core/src/main/resources/static/.*'
|
||||
- 'app/proprietary/src/main/resources/static/.*'
|
||||
- 'app/core/src/main/java/stirling/software/SPDF/controller/web/.*'
|
||||
- 'app/core/src/main/java/stirling/software/SPDF/UI/.*'
|
||||
- 'app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/.*'
|
||||
- 'frontend/**'
|
||||
- 'frontend/.*'
|
||||
- 'frontend/**/.*'
|
||||
|
||||
- label: 'Tauri'
|
||||
files:
|
||||
- 'frontend/src-tauri/**'
|
||||
- 'frontend/src-tauri/.*'
|
||||
|
||||
- label: 'Java'
|
||||
files:
|
||||
|
||||
@@ -84,9 +84,6 @@
|
||||
- name: "v2"
|
||||
color: "FFFF00"
|
||||
description: "Issues or pull requests related to the v2 branch"
|
||||
- name: "v3"
|
||||
color: "FFA500"
|
||||
description: "Issues or pull requests related to the v3 branch"
|
||||
- name: "wontfix"
|
||||
description: "This will not be worked on"
|
||||
color: "FFFFFF"
|
||||
@@ -187,17 +184,3 @@
|
||||
- name: "codex"
|
||||
color: "ededed"
|
||||
description: "chatgpt AI generated code"
|
||||
- name: "break-change"
|
||||
color: "FF0000"
|
||||
description: "This PR introduces a breaking API change."
|
||||
- name: "Rust"
|
||||
color: "DEA584"
|
||||
description: "Pull requests that update Rust code"
|
||||
from_name: "rust"
|
||||
- name: "Tauri"
|
||||
color: "24C8FF"
|
||||
description: "Pull requests that update Tauri code"
|
||||
from_name: "tauri"
|
||||
- name: "license-review-required"
|
||||
color: "EDEDED"
|
||||
description: "This PR requires a license review"
|
||||
|
||||
@@ -27,10 +27,6 @@ Closes #(issue_number)
|
||||
- [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed)
|
||||
- [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only)
|
||||
|
||||
### Translations (if applicable)
|
||||
|
||||
- [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
|
||||
|
||||
### UI Changes (if applicable)
|
||||
|
||||
- [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR)
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
changelog:
|
||||
categories:
|
||||
- title: Breaking Changes
|
||||
labels:
|
||||
- break-change
|
||||
|
||||
- title: Bug Fixes
|
||||
labels:
|
||||
- Bug
|
||||
|
||||
@@ -11,16 +11,15 @@ adjusting the format.
|
||||
Usage:
|
||||
python check_language_toml.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
|
||||
"""
|
||||
|
||||
# Sample for Windows:
|
||||
# python .github/scripts/check_language_toml.py --reference-file frontend/public/locales/en-GB/translation.toml --branch "" --files frontend/public/locales/de-DE/translation.toml frontend/public/locales/fr-FR/translation.toml
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import glob
|
||||
import os
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import json
|
||||
import tomllib # Python 3.11+ (stdlib)
|
||||
import tomli_w # For writing TOML files
|
||||
|
||||
@@ -39,8 +38,7 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
|
||||
duplicates = []
|
||||
|
||||
# Load TOML file
|
||||
file_path = Path(file_path)
|
||||
with file_path.open("rb") as file:
|
||||
with open(file_path, 'rb') as file:
|
||||
data = tomllib.load(file)
|
||||
|
||||
def process_dict(obj, current_prefix=""):
|
||||
@@ -59,8 +57,8 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
|
||||
return duplicates
|
||||
|
||||
|
||||
# Maximum size for TOML files (e.g., 1 MB)
|
||||
MAX_FILE_SIZE = 1000 * 1024
|
||||
# Maximum size for TOML files (e.g., 500 KB)
|
||||
MAX_FILE_SIZE = 500 * 1024
|
||||
|
||||
|
||||
def parse_toml_file(file_path):
|
||||
@@ -69,8 +67,7 @@ def parse_toml_file(file_path):
|
||||
:param file_path: Path to the TOML file.
|
||||
:return: Dictionary with flattened keys.
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
with file_path.open("rb") as file:
|
||||
with open(file_path, 'rb') as file:
|
||||
data = tomllib.load(file)
|
||||
|
||||
def flatten_dict(d, parent_key="", sep="."):
|
||||
@@ -113,8 +110,7 @@ def write_toml_file(file_path, updated_properties):
|
||||
"""
|
||||
nested_data = unflatten_dict(updated_properties)
|
||||
|
||||
file_path = Path(file_path)
|
||||
with file_path.open("wb") as file:
|
||||
with open(file_path, "wb") as file:
|
||||
tomli_w.dump(nested_data, file)
|
||||
|
||||
|
||||
@@ -125,23 +121,18 @@ def update_missing_keys(reference_file, file_list, branch=""):
|
||||
:param file_list: List of translation files to update.
|
||||
:param branch: Branch where the files are located.
|
||||
"""
|
||||
reference_file = Path(reference_file)
|
||||
reference_properties = parse_toml_file(reference_file)
|
||||
branch_path = Path(branch) if branch else Path()
|
||||
|
||||
for file_path in file_list:
|
||||
file_path = Path(file_path)
|
||||
language_dir = file_path.parent.name
|
||||
reference_lang_dir = reference_file.parent.name
|
||||
basename_current_file = os.path.basename(os.path.join(branch, file_path))
|
||||
if (
|
||||
language_dir == reference_lang_dir
|
||||
or file_path.suffix != ".toml"
|
||||
or file_path.parents[1].name != "locales"
|
||||
basename_current_file == os.path.basename(reference_file)
|
||||
or not file_path.endswith(".toml")
|
||||
or not os.path.dirname(file_path).endswith("locales")
|
||||
):
|
||||
print(f"Skipping file: {file_path}")
|
||||
continue
|
||||
|
||||
current_properties = parse_toml_file(branch_path / file_path)
|
||||
current_properties = parse_toml_file(os.path.join(branch, file_path))
|
||||
updated_properties = {}
|
||||
|
||||
for ref_key, ref_value in reference_properties.items():
|
||||
@@ -152,7 +143,7 @@ def update_missing_keys(reference_file, file_list, branch=""):
|
||||
# Add missing key with reference value
|
||||
updated_properties[ref_key] = ref_value
|
||||
|
||||
write_toml_file(branch_path / file_path, updated_properties)
|
||||
write_toml_file(os.path.join(branch, file_path), updated_properties)
|
||||
|
||||
|
||||
def check_for_missing_keys(reference_file, file_list, branch):
|
||||
@@ -160,17 +151,14 @@ def check_for_missing_keys(reference_file, file_list, branch):
|
||||
|
||||
|
||||
def read_toml_keys(file_path):
|
||||
file_path = Path(file_path)
|
||||
if file_path.is_file():
|
||||
if os.path.isfile(file_path) and os.path.exists(file_path):
|
||||
return parse_toml_file(file_path)
|
||||
return {}
|
||||
|
||||
|
||||
def check_for_differences(reference_file, file_list, branch, actor):
|
||||
reference_branch = branch
|
||||
reference_file = Path(reference_file)
|
||||
basename_reference_file = reference_file.name
|
||||
branch_path = Path(branch) if branch else Path()
|
||||
basename_reference_file = os.path.basename(reference_file)
|
||||
|
||||
report = []
|
||||
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
|
||||
@@ -184,44 +172,39 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
if len(file_list) == 1:
|
||||
file_arr = file_list[0].split()
|
||||
|
||||
base_dir = Path.cwd() / "frontend" / "public" / "locales"
|
||||
base_dir = os.path.abspath(
|
||||
os.path.join(os.getcwd(), "frontend", "public", "locales")
|
||||
)
|
||||
|
||||
for file_path in file_arr:
|
||||
file_path = Path(file_path)
|
||||
file_normpath = file_path
|
||||
absolute_path = file_normpath.resolve()
|
||||
|
||||
basename_current_file = (branch_path / file_normpath).name
|
||||
locale_dir = file_normpath.parent.name
|
||||
report.append(f"#### 📃 **File Check:** `{locale_dir}/{basename_current_file}`")
|
||||
file_normpath = os.path.normpath(file_path)
|
||||
absolute_path = os.path.abspath(file_normpath)
|
||||
|
||||
# Verify that file is within the expected directory
|
||||
if not absolute_path.is_relative_to(base_dir):
|
||||
has_differences = True
|
||||
report.append(
|
||||
f"\n⚠️ Unsafe file found: `{locale_dir}/{basename_current_file}`\n\n---\n"
|
||||
)
|
||||
continue
|
||||
if not absolute_path.startswith(base_dir):
|
||||
raise ValueError(f"Unsafe file found: {file_normpath}")
|
||||
|
||||
# Verify file size before processing
|
||||
if (branch_path / file_normpath).stat().st_size > MAX_FILE_SIZE:
|
||||
has_differences = True
|
||||
report.append(
|
||||
f"\n⚠️ The file `{locale_dir}/{basename_current_file}` is too large and could pose a security risk.\n\n---\n"
|
||||
if os.path.getsize(os.path.join(branch, file_normpath)) > MAX_FILE_SIZE:
|
||||
raise ValueError(
|
||||
f"The file {file_normpath} is too large and could pose a security risk."
|
||||
)
|
||||
continue
|
||||
|
||||
if basename_current_file == basename_reference_file and locale_dir == "en-GB":
|
||||
continue
|
||||
basename_current_file = os.path.basename(os.path.join(branch, file_normpath))
|
||||
locale_dir = os.path.basename(os.path.dirname(file_normpath))
|
||||
|
||||
if (
|
||||
file_normpath.suffix != ".toml"
|
||||
or basename_current_file != "translation.toml"
|
||||
basename_current_file == basename_reference_file
|
||||
and locale_dir == "en-GB"
|
||||
):
|
||||
continue
|
||||
|
||||
if not file_normpath.endswith(".toml") or basename_current_file != "translation.toml":
|
||||
continue
|
||||
|
||||
only_reference_file = False
|
||||
current_keys = read_toml_keys(branch_path / file_path)
|
||||
report.append(f"#### 📃 **File Check:** `{locale_dir}/{basename_current_file}`")
|
||||
current_keys = read_toml_keys(os.path.join(branch, file_path))
|
||||
reference_key_count = len(reference_keys)
|
||||
current_key_count = len(current_keys)
|
||||
|
||||
@@ -259,37 +242,20 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
report.append(
|
||||
f" - **_Extra keys in `{locale_dir}/{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**."
|
||||
)
|
||||
report.append("")
|
||||
report.append(" Use the following command to remove them:")
|
||||
report.append(
|
||||
f" `python scripts/translations/translation_merger.py {locale_dir} remove-unused`"
|
||||
)
|
||||
report.append("")
|
||||
if extra_keys_list:
|
||||
report.append(
|
||||
f" - **_Missing keys in `{locale_dir}/{basename_current_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_reference_file}`_**."
|
||||
)
|
||||
report.append("")
|
||||
report.append(" Use the following command to add them:")
|
||||
report.append(
|
||||
f" `python scripts/translations/translation_merger.py {locale_dir} add-missing`"
|
||||
)
|
||||
report.append("")
|
||||
|
||||
if missing_keys_list or extra_keys_list:
|
||||
report.append(
|
||||
" See: https://github.com/Stirling-Tools/Stirling-PDF/tree/main/scripts/translations#2-translation_mergerpy"
|
||||
)
|
||||
else:
|
||||
report.append("2. **Test Status:** ✅ **_Passed_**")
|
||||
|
||||
if find_duplicate_keys(branch_path / file_normpath):
|
||||
if find_duplicate_keys(os.path.join(branch, file_normpath)):
|
||||
has_differences = True
|
||||
output = "\n".join(
|
||||
[
|
||||
f" - `{key}`: first at {first}, duplicate at `{duplicate}`"
|
||||
for key, first, duplicate in find_duplicate_keys(
|
||||
branch_path / file_normpath
|
||||
os.path.join(branch, file_normpath)
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -322,9 +288,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
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,
|
||||
|
||||
@@ -6,4 +6,3 @@ pillow
|
||||
unoserver
|
||||
opencv-python-headless
|
||||
pre-commit
|
||||
brotli @ git+https://github.com/google/brotli.git@028fb5a23661f123017c060daa546b55cf4bde29
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,33 +4,33 @@
|
||||
#
|
||||
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' --strip-extras '.github\scripts\requirements_pre_commit.in'
|
||||
#
|
||||
cfgv==3.5.0 \
|
||||
--hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \
|
||||
--hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132
|
||||
cfgv==3.4.0 \
|
||||
--hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \
|
||||
--hash=sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560
|
||||
# via pre-commit
|
||||
distlib==0.4.0 \
|
||||
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
|
||||
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
|
||||
# via virtualenv
|
||||
filelock==3.20.3 \
|
||||
--hash=sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1 \
|
||||
--hash=sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1
|
||||
filelock==3.19.1 \
|
||||
--hash=sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58 \
|
||||
--hash=sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d
|
||||
# via virtualenv
|
||||
identify==2.6.16 \
|
||||
--hash=sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0 \
|
||||
--hash=sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980
|
||||
identify==2.6.15 \
|
||||
--hash=sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757 \
|
||||
--hash=sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf
|
||||
# via pre-commit
|
||||
nodeenv==1.10.0 \
|
||||
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
|
||||
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
|
||||
nodeenv==1.9.1 \
|
||||
--hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \
|
||||
--hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9
|
||||
# via pre-commit
|
||||
platformdirs==4.5.1 \
|
||||
--hash=sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda \
|
||||
--hash=sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31
|
||||
platformdirs==4.4.0 \
|
||||
--hash=sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85 \
|
||||
--hash=sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf
|
||||
# via virtualenv
|
||||
pre-commit==4.5.1 \
|
||||
--hash=sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77 \
|
||||
--hash=sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61
|
||||
pre-commit==4.3.0 \
|
||||
--hash=sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8 \
|
||||
--hash=sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16
|
||||
# via -r .github/scripts/requirements_pre_commit.in
|
||||
pyyaml==6.0.3 \
|
||||
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
|
||||
@@ -107,7 +107,7 @@ pyyaml==6.0.3 \
|
||||
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
|
||||
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
|
||||
# via pre-commit
|
||||
virtualenv==20.36.1 \
|
||||
--hash=sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f \
|
||||
--hash=sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba
|
||||
virtualenv==20.34.0 \
|
||||
--hash=sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026 \
|
||||
--hash=sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a
|
||||
# via pre-commit
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
tomlkit
|
||||
tomli-w
|
||||
|
||||
@@ -4,11 +4,7 @@
|
||||
#
|
||||
# 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.14.0 \
|
||||
--hash=sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680 \
|
||||
--hash=sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064
|
||||
tomlkit==0.13.3 \
|
||||
--hash=sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1 \
|
||||
--hash=sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0
|
||||
# via -r .github/scripts/requirements_sync_readme.in
|
||||
|
||||
@@ -11,10 +11,6 @@ on:
|
||||
allow_fork:
|
||||
description: "Allow deploying fork PR?"
|
||||
required: false
|
||||
type: choice
|
||||
options:
|
||||
- "true"
|
||||
- "false"
|
||||
default: "false"
|
||||
|
||||
permissions:
|
||||
@@ -35,18 +31,18 @@ jobs:
|
||||
pr_ref: ${{ steps.resolve.outputs.ref }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Resolve PR info
|
||||
id: resolve
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
let prNumber = context.eventName === 'workflow_dispatch'
|
||||
? parseInt(context.payload.inputs.pr, 10)
|
||||
? parseInt(process.env.INPUT_PR, 10)
|
||||
: context.payload.number;
|
||||
|
||||
if (!Number.isInteger(prNumber)) { core.setFailed('Invalid PR number'); return; }
|
||||
@@ -56,6 +52,7 @@ jobs:
|
||||
core.setOutput('repository', pr.head.repo.full_name);
|
||||
core.setOutput('ref', pr.head.ref);
|
||||
core.setOutput('is_fork', String(pr.head.repo.fork));
|
||||
core.setOutput('base_ref', pr.base.ref);
|
||||
core.setOutput('author', pr.user.login);
|
||||
core.setOutput('state', pr.state);
|
||||
|
||||
@@ -68,6 +65,10 @@ jobs:
|
||||
IS_FORK: ${{ steps.resolve.outputs.is_fork }}
|
||||
# nur bei workflow_dispatch gesetzt:
|
||||
ALLOW_FORK_INPUT: ${{ inputs.allow_fork }}
|
||||
# für Auto-PR-Logik:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
PR_BASE: ${{ steps.resolve.outputs.base_ref }}
|
||||
PR_AUTHOR: ${{ steps.resolve.outputs.author }}
|
||||
run: |
|
||||
set -e
|
||||
@@ -88,8 +89,14 @@ jobs:
|
||||
else
|
||||
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs")
|
||||
is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done
|
||||
if [ "$is_auth" = true ]; then
|
||||
if [ "$PR_BASE" = "V2" ] && [ "$is_auth" = true ]; then
|
||||
should=true
|
||||
else
|
||||
title_has_v2=false; echo "$PR_TITLE" | grep -qiE 'v2|version.?2|version.?two' && title_has_v2=true
|
||||
branch_has_kw=false; echo "$PR_BRANCH" | grep -qiE 'v2|react' && branch_has_kw=true
|
||||
if [ "$is_auth" = true ] && { [ "$title_has_v2" = true ] || [ "$branch_has_kw" = true ]; }; then
|
||||
should=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -111,12 +118,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout main repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: main
|
||||
@@ -132,13 +139,13 @@ jobs:
|
||||
|
||||
- name: Add deployment started comment
|
||||
id: deployment-started
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = ${{ needs.check-pr.outputs.pr_number }};
|
||||
|
||||
|
||||
// Delete previous V2 deployment comments to avoid clutter
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
@@ -161,26 +168,26 @@ jobs:
|
||||
comment_id: comment.id
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Create new deployment started comment
|
||||
const { data: newComment } = await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment for approved V2 contributors._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.`
|
||||
body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment triggered by V2/version2 keywords in the PR title or V2/React keywords in the branch name._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.`
|
||||
});
|
||||
return newComment.id;
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: ${{ needs.check-pr.outputs.pr_repository }}
|
||||
ref: ${{ needs.check-pr.outputs.pr_ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0 # Fetch full history for commit hash detection
|
||||
fetch-depth: 0 # Fetch full history for commit hash detection
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
@@ -189,58 +196,93 @@ jobs:
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Get commit hash for app
|
||||
id: commit-hash
|
||||
- name: Get commit hashes for frontend and backend
|
||||
id: commit-hashes
|
||||
run: |
|
||||
# Get last commit that touched the application code
|
||||
APP_HASH=$(git log -1 --format="%H" -- . 2>/dev/null || echo "")
|
||||
if [ -z "$APP_HASH" ]; then
|
||||
APP_HASH="no-changes"
|
||||
# Get last commit that touched the frontend folder, docker/frontend, or docker/compose
|
||||
FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "")
|
||||
if [ -z "$FRONTEND_HASH" ]; then
|
||||
FRONTEND_HASH="no-frontend-changes"
|
||||
fi
|
||||
|
||||
echo "App hash: $APP_HASH"
|
||||
echo "app_hash=$APP_HASH" >> $GITHUB_OUTPUT
|
||||
|
||||
# Short hash for tags
|
||||
if [ "$APP_HASH" = "no-changes" ]; then
|
||||
echo "app_short=no-changes" >> $GITHUB_OUTPUT
|
||||
# Get last commit that touched backend code, docker/backend, or docker/compose
|
||||
BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
|
||||
if [ -z "$BACKEND_HASH" ]; then
|
||||
BACKEND_HASH="no-backend-changes"
|
||||
fi
|
||||
|
||||
echo "Frontend hash: $FRONTEND_HASH"
|
||||
echo "Backend hash: $BACKEND_HASH"
|
||||
|
||||
echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
|
||||
echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
|
||||
|
||||
# Short hashes for tags
|
||||
if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
|
||||
echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "app_short=${APP_HASH:0:8}" >> $GITHUB_OUTPUT
|
||||
echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
|
||||
echo "backend_short=no-backend" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Check if image exists
|
||||
id: check-image
|
||||
- name: Check if frontend image exists
|
||||
id: check-frontend
|
||||
run: |
|
||||
if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }} >/dev/null 2>&1; then
|
||||
if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "Image already exists, skipping build"
|
||||
echo "Frontend image already exists, skipping build"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
echo "Image needs to be built"
|
||||
echo "Frontend image needs to be built"
|
||||
fi
|
||||
|
||||
- name: Build and push V2 image
|
||||
if: steps.check-image.outputs.exists == 'false'
|
||||
- name: Check if backend image exists
|
||||
id: check-backend
|
||||
run: |
|
||||
if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "Backend image already exists, skipping build"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
echo "Backend image needs to be built"
|
||||
fi
|
||||
|
||||
- name: Build and push V2 frontend image
|
||||
if: steps.check-frontend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
file: ./docker/frontend/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-pdf-latest
|
||||
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push V2 backend image
|
||||
if: steps.check-backend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/backend/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Deploy V2 to VPS
|
||||
@@ -248,16 +290,17 @@ jobs:
|
||||
run: |
|
||||
# Use same port strategy as regular PRs - just the PR number
|
||||
V2_PORT=${{ needs.check-pr.outputs.pr_number }}
|
||||
|
||||
# Create docker-compose for V2 with unified embedded image
|
||||
BACKEND_PORT=$((V2_PORT + 10000)) # Backend on higher port to avoid conflicts
|
||||
|
||||
# Create docker-compose for V2 with separate frontend and backend
|
||||
cat > docker-compose.yml << EOF
|
||||
version: '3.3'
|
||||
services:
|
||||
stirling-pdf-v2:
|
||||
container_name: stirling-pdf-v2-pr-${{ needs.check-pr.outputs.pr_number }}
|
||||
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
|
||||
stirling-pdf-v2-backend:
|
||||
container_name: stirling-pdf-v2-backend-pr-${{ needs.check-pr.outputs.pr_number }}
|
||||
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
|
||||
ports:
|
||||
- "${V2_PORT}:8080"
|
||||
- "${BACKEND_PORT}:8080" # Backend API port
|
||||
volumes:
|
||||
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/data:/usr/share/tessdata:rw
|
||||
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/config:/configs:rw
|
||||
@@ -269,7 +312,7 @@ jobs:
|
||||
SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}"
|
||||
SYSTEM_DEFAULTLOCALE: en-GB
|
||||
UI_APPNAME: "Stirling-PDF V2 PR#${{ needs.check-pr.outputs.pr_number }}"
|
||||
UI_HOMEDESCRIPTION: "V2 PR#${{ needs.check-pr.outputs.pr_number }} - Embedded Architecture"
|
||||
UI_HOMEDESCRIPTION: "V2 PR#${{ needs.check-pr.outputs.pr_number }} - Frontend/Backend Split Architecture"
|
||||
UI_APPNAMENAVBAR: "V2 PR#${{ needs.check-pr.outputs.pr_number }}"
|
||||
SYSTEM_MAXFILESIZE: "100"
|
||||
METRICS_ENABLED: "true"
|
||||
@@ -277,12 +320,23 @@ jobs:
|
||||
SWAGGER_SERVER_URL: "https://${V2_PORT}.ssl.stirlingpdf.cloud"
|
||||
baseUrl: "https://${V2_PORT}.ssl.stirlingpdf.cloud"
|
||||
restart: on-failure:5
|
||||
|
||||
stirling-pdf-v2-frontend:
|
||||
container_name: stirling-pdf-v2-frontend-pr-${{ needs.check-pr.outputs.pr_number }}
|
||||
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
|
||||
ports:
|
||||
- "${V2_PORT}:80" # Frontend port (same as regular PRs)
|
||||
environment:
|
||||
VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:${BACKEND_PORT}"
|
||||
depends_on:
|
||||
- stirling-pdf-v2-backend
|
||||
restart: on-failure:5
|
||||
EOF
|
||||
|
||||
# Deploy to VPS
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose-v2.yml
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose-v2.yml
|
||||
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
|
||||
# Create V2 PR-specific directories
|
||||
mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs}
|
||||
|
||||
@@ -292,15 +346,15 @@ jobs:
|
||||
# Stop any existing container and clean up
|
||||
cd /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}
|
||||
docker-compose down --remove-orphans 2>/dev/null || true
|
||||
|
||||
|
||||
# Start the new container
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
|
||||
|
||||
# Clean up unused Docker resources to save space
|
||||
docker system prune -af --volumes || true
|
||||
|
||||
# Clean up old images (older than 2 weeks)
|
||||
# Clean up old backend/frontend images (older than 2 weeks)
|
||||
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
|
||||
ENDSSH
|
||||
|
||||
@@ -309,14 +363,14 @@ jobs:
|
||||
|
||||
- name: Post V2 deployment URL to PR
|
||||
if: success()
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = ${{ needs.check-pr.outputs.pr_number }};
|
||||
const v2Port = ${{ steps.deploy.outputs.v2_port }};
|
||||
|
||||
|
||||
// Delete the "deploying..." comment since we're posting the final result
|
||||
const deploymentStartedId = ${{ steps.deployment-started.outputs.result }};
|
||||
if (deploymentStartedId) {
|
||||
@@ -331,16 +385,16 @@ jobs:
|
||||
console.log(`Could not delete deployment started comment: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`;
|
||||
|
||||
const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${v2Port}`;
|
||||
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
|
||||
|
||||
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
|
||||
`Your V2 PR with embedded architecture has been deployed!\n\n` +
|
||||
`Your V2 PR with the new frontend/backend split architecture has been deployed!\n\n` +
|
||||
`🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` +
|
||||
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
|
||||
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
|
||||
`🔄 **Auto-deployed** for approved V2 contributors.`;
|
||||
`🔄 **Auto-deployed** because PR title or branch name contains V2/version2/React keywords.`;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
@@ -359,12 +413,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
@@ -376,25 +430,25 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Clean up V2 deployment comments
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = ${{ github.event.pull_request.number }};
|
||||
|
||||
|
||||
// Find and delete V2 deployment comments
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber
|
||||
});
|
||||
|
||||
|
||||
const v2Comments = comments.filter(c =>
|
||||
c.body?.includes("## 🚀 V2 Auto-Deployment Complete!") &&
|
||||
c.user?.type === "Bot"
|
||||
);
|
||||
|
||||
|
||||
for (const comment of v2Comments) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
@@ -407,12 +461,12 @@ jobs:
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Cleanup V2 deployment
|
||||
run: |
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << 'ENDSSH'
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
|
||||
if [ -d "/stirling/V2-PR-${{ github.event.pull_request.number }}" ]; then
|
||||
echo "Found V2 PR directory, proceeding with cleanup..."
|
||||
|
||||
@@ -426,8 +480,9 @@ jobs:
|
||||
# Remove V2 PR-specific directories
|
||||
rm -rf /stirling/V2-PR-${{ github.event.pull_request.number }}
|
||||
|
||||
# Clean up V2 container by name (in case compose cleanup missed it)
|
||||
docker rm -f stirling-pdf-v2-pr-${{ github.event.pull_request.number }} || true
|
||||
# Clean up V2 containers by name (in case compose cleanup missed them)
|
||||
docker rm -f stirling-pdf-v2-frontend-pr-${{ github.event.pull_request.number }} || true
|
||||
docker rm -f stirling-pdf-v2-backend-pr-${{ github.event.pull_request.number }} || true
|
||||
|
||||
echo "V2 cleanup completed"
|
||||
else
|
||||
|
||||
@@ -25,7 +25,8 @@ jobs:
|
||||
github.event.comment.user.login == 'frooodle' ||
|
||||
github.event.comment.user.login == 'sf298' ||
|
||||
github.event.comment.user.login == 'Ludy87' ||
|
||||
github.event.comment.user.login == 'balazs-szucs' ||
|
||||
github.event.comment.user.login == 'LaserKaspar' ||
|
||||
github.event.comment.user.login == 'sbplat' ||
|
||||
github.event.comment.user.login == 'reecebrowne' ||
|
||||
github.event.comment.user.login == 'DarioGii' ||
|
||||
github.event.comment.user.login == 'EthanHealy01' ||
|
||||
@@ -40,12 +41,12 @@ jobs:
|
||||
enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
@@ -128,12 +129,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
@@ -145,22 +146,17 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "25"
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Run Gradle Command
|
||||
run: |
|
||||
if [ "${{ needs.check-comment.outputs.disable_security }}" == "true" ]; then
|
||||
@@ -168,18 +164,15 @@ jobs:
|
||||
else
|
||||
export DISABLE_ADDITIONAL_FEATURES=false
|
||||
fi
|
||||
./gradlew build
|
||||
./gradlew clean build
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
@@ -190,8 +183,6 @@ jobs:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-pdf-latest
|
||||
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }}
|
||||
build-args: VERSION_TAG=alpha
|
||||
platforms: linux/amd64
|
||||
@@ -199,7 +190,7 @@ jobs:
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Deploy to VPS
|
||||
@@ -261,9 +252,9 @@ jobs:
|
||||
EOF
|
||||
|
||||
# Then copy the file and execute commands
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose.yml
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose.yml
|
||||
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
|
||||
# Create PR-specific directories
|
||||
mkdir -p /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/{data,config,logs}
|
||||
|
||||
@@ -345,7 +336,7 @@ jobs:
|
||||
const prNumber = ${{ needs.check-comment.outputs.pr_number }};
|
||||
const securityStatus = process.env.security_status || "Security Disabled";
|
||||
|
||||
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${prNumber}`;
|
||||
const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${prNumber}`;
|
||||
const commentBody = `## 🚀 PR Test Deployment\n\n` +
|
||||
`Your PR has been deployed for testing!\n\n` +
|
||||
`🔗 **Test URL:** [${deploymentUrl}](${deploymentUrl})\n` +
|
||||
@@ -366,149 +357,3 @@ jobs:
|
||||
rm -f ../private.key docker-compose.yml
|
||||
echo "Cleanup complete."
|
||||
continue-on-error: true
|
||||
|
||||
handle-label-commands:
|
||||
if: ${{ github.event.issue.pull_request != null }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Apply label commands
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { comment, issue } = context.payload;
|
||||
const commentBody = comment?.body ?? '';
|
||||
if (!commentBody.includes('::label::')) {
|
||||
core.info('No label commands detected in comment.');
|
||||
return;
|
||||
}
|
||||
|
||||
const configPath = path.join(process.env.GITHUB_WORKSPACE, '.github', 'config', 'repo_devs.json');
|
||||
const repoDevsConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
const label_changer = (repoDevsConfig.label_changer || []).map((login) => login.toLowerCase());
|
||||
|
||||
const commenter = (comment?.user?.login || '').toLowerCase();
|
||||
if (!label_changer.includes(commenter)) {
|
||||
core.info(`User ${commenter} is not authorized to manage labels.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const labelsConfigPath = path.join(process.env.GITHUB_WORKSPACE, '.github', 'labels.yml');
|
||||
const labelsFile = fs.readFileSync(labelsConfigPath, 'utf8');
|
||||
|
||||
const labelNameMap = new Map();
|
||||
for (const match of labelsFile.matchAll(/-\s+name:\s*(?:"([^"]+)"|'([^']+)'|([^\n]+))/g)) {
|
||||
const labelName = (match[1] ?? match[2] ?? match[3] ?? '').trim();
|
||||
|
||||
if (!labelName) {
|
||||
continue;
|
||||
}
|
||||
const normalized = labelName.toLowerCase();
|
||||
if (!labelNameMap.has(normalized)) {
|
||||
labelNameMap.set(normalized, labelName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!labelNameMap.size) {
|
||||
core.warning('No labels could be read from .github/labels.yml; aborting label commands.');
|
||||
return;
|
||||
}
|
||||
|
||||
let allowedLabelNames = new Set(labelNameMap.values());
|
||||
|
||||
const labelsToAdd = new Set();
|
||||
const labelsToRemove = new Set();
|
||||
const commandRegex = /^(\w+)::(label)::"([^"]+)"/gim;
|
||||
let match;
|
||||
while ((match = commandRegex.exec(commentBody)) !== null) {
|
||||
core.info(`Found label command: ${match[0]} (action: ${match[1]}, label: ${match[2]}, labelName: ${match[3]})`);
|
||||
const action = match[1].toLowerCase();
|
||||
const labelName = match[3].trim();
|
||||
|
||||
if (!labelName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = labelName.toLowerCase();
|
||||
const resolvedLabelName = labelNameMap.get(normalized);
|
||||
if (action === 'add') {
|
||||
if (!resolvedLabelName) {
|
||||
core.warning(`Label "${labelName}" is not defined in .github/labels.yml and cannot be added.`);
|
||||
continue;
|
||||
}
|
||||
if (!allowedLabelNames.has(resolvedLabelName)) {
|
||||
core.warning(`Label "${resolvedLabelName}" is not allowed for add commands and will be skipped.`);
|
||||
continue;
|
||||
}
|
||||
labelsToAdd.add(resolvedLabelName);
|
||||
} else if (action === 'rm') {
|
||||
const labelToRemove = resolvedLabelName ?? labelName;
|
||||
if (!resolvedLabelName) {
|
||||
core.warning(`Label "${labelName}" is not defined in .github/labels.yml; attempting to remove as provided.`);
|
||||
}
|
||||
labelsToRemove.add(labelToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
const addLabels = Array.from(labelsToAdd);
|
||||
const removeLabels = Array.from(labelsToRemove);
|
||||
|
||||
if (!addLabels.length && !removeLabels.length) {
|
||||
core.info('No valid label commands found after parsing.');
|
||||
return;
|
||||
}
|
||||
|
||||
const issueParams = {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
};
|
||||
|
||||
if (addLabels.length) {
|
||||
core.info(`Adding labels: ${addLabels.join(', ')}`);
|
||||
await github.rest.issues.addLabels({
|
||||
...issueParams,
|
||||
labels: addLabels,
|
||||
});
|
||||
}
|
||||
|
||||
for (const labelName of removeLabels) {
|
||||
core.info(`Removing label: ${labelName}`);
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
...issueParams,
|
||||
name: labelName,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.warning(`Label "${labelName}" was not present on the pull request.`);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: comment.id,
|
||||
});
|
||||
core.info('Processed label commands and deleted the comment.');
|
||||
|
||||
@@ -8,7 +8,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
SERVER_IP: ${{ secrets.NEW_VPS_IP }} # Add this to your GitHub secrets
|
||||
SERVER_IP: ${{ secrets.VPS_IP }} # Add this to your GitHub secrets
|
||||
CLEANUP_PERFORMED: "false" # Add flag to track if cleanup occurred
|
||||
|
||||
jobs:
|
||||
@@ -21,12 +21,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
@@ -96,18 +96,19 @@ jobs:
|
||||
const hasDeploymentComment = deploymentComments.length > 0;
|
||||
core.setOutput('present', (hasLabel || hasDeploymentComment) ? 'true' : 'false');
|
||||
|
||||
|
||||
- name: Set up SSH
|
||||
if: steps.remove-label-comment.outputs.present == 'true'
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Cleanup PR deployment
|
||||
if: steps.remove-label-comment.outputs.present == 'true'
|
||||
id: cleanup
|
||||
run: |
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << 'ENDSSH'
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
|
||||
if [ -d "/stirling/PR-${{ github.event.pull_request.number }}" ]; then
|
||||
echo "Found PR directory, proceeding with cleanup..."
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
name: AI Engine CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
engine:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
defaults:
|
||||
run:
|
||||
working-directory: engine
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: make install
|
||||
|
||||
- name: Run fixers
|
||||
# Ignore errors here because we're going to add comments for them in the following steps before actually failing
|
||||
run: make fix || true
|
||||
|
||||
- name: Check for fixer changes
|
||||
id: fixer_changes
|
||||
run: |
|
||||
if git diff --quiet; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Post fixer suggestions
|
||||
if: steps.fixer_changes.outputs.changed == 'true' && github.event_name == 'pull_request'
|
||||
uses: reviewdog/action-suggester@v1
|
||||
continue-on-error: true
|
||||
with:
|
||||
tool_name: engine-make-fix
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
filter_mode: file
|
||||
fail_level: any
|
||||
level: info
|
||||
|
||||
- name: Comment on fixer suggestions
|
||||
if: steps.fixer_changes.outputs.changed == 'true' && github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: "The Python code in your PR has formatting/linting issues. Consider running `make fix` locally or setting up your editor's Ruff integration to auto-format and lint your files as you go, or commit the suggested changes on this PR.",
|
||||
});
|
||||
|
||||
- name: Verify fixer changes are committed
|
||||
if: steps.fixer_changes.outputs.changed == 'true'
|
||||
run: |
|
||||
if ! git diff --exit-code; then
|
||||
echo "Fixes are out of date."
|
||||
echo "Apply the reviewdog suggestions or run 'make fix' from engine/ and commit the updated files."
|
||||
git --no-pager diff --stat
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run linting
|
||||
run: make lint
|
||||
|
||||
- name: Run type checking
|
||||
run: make typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: make test
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
types: [opened, edited]
|
||||
branches: [main]
|
||||
|
||||
permissions: # required for secure-repo hardening
|
||||
permissions: # required for secure-repo hardening
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
@@ -19,11 +19,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
- name: AI PR Title Analysis
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
id: ai-title-analysis
|
||||
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
|
||||
uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1.2.8
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt-file: ".github/config/system-prompt.txt"
|
||||
|
||||
@@ -2,9 +2,6 @@ name: "Auto Pull Request Labeler V2"
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
branches:
|
||||
- main
|
||||
- V3
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -16,11 +13,11 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
@@ -29,7 +26,7 @@ jobs:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: srvaroa/labeler@bf262763a8a8e191f5847873aecc0f29df84f957 # v1.14.0
|
||||
- uses: srvaroa/labeler@0a20eccb8c94a1ee0bed5f16859aece1c45c3e55 # v1.13.0
|
||||
with:
|
||||
config_path: .github/labeler-config-srvaroa.yml
|
||||
use_local_config: false
|
||||
|
||||
+57
-194
@@ -2,7 +2,7 @@ name: Build and Test Workflow
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
branches: ["main", "V2", "V2-gha"]
|
||||
workflow_dispatch:
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
@@ -27,19 +27,18 @@ jobs:
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
build: ${{ steps.changes.outputs.build }}
|
||||
app: ${{ steps.changes.outputs.app }}
|
||||
project: ${{ steps.changes.outputs.project }}
|
||||
openapi: ${{ steps.changes.outputs.openapi }}
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: changes
|
||||
with:
|
||||
filters: .github/config/.files.yaml
|
||||
@@ -52,45 +51,29 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
jdk-version: [21, 25]
|
||||
jdk-version: [17, 21]
|
||||
spring-security: [true, false]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK ${{ matrix.jdk-version }}
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: ${{ matrix.jdk-version }}
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.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@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
gradle-version: 8.14
|
||||
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
|
||||
run: ./gradlew build -PnoSpotless
|
||||
run: ./gradlew clean build -PnoSpotless
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.spring-security }}
|
||||
|
||||
- name: Check Test Reports Exist
|
||||
if: always()
|
||||
run: |
|
||||
@@ -108,14 +91,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Upload Test Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
|
||||
path: |
|
||||
app/**/build/reports/jacoco/test
|
||||
app/**/build/reports/tests/
|
||||
app/**/build/test-results/
|
||||
app/**/build/reports/problems/
|
||||
@@ -123,86 +104,56 @@ jobs:
|
||||
retention-days: 3
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Add coverage to PR with spring security ${{ matrix.spring-security }} and JDK ${{ matrix.jdk-version }}
|
||||
id: jacoco
|
||||
uses: madrapps/jacoco-report@50d3aff4548aa991e6753342d9ba291084e63848 # v1.7.2
|
||||
with:
|
||||
paths: |
|
||||
${{ github.workspace }}/**/build/reports/jacoco/test/jacocoTestReport.xml
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
min-coverage-overall: 10
|
||||
min-coverage-changed-files: 0
|
||||
comment-type: summary
|
||||
|
||||
check-generateOpenApiDocs:
|
||||
if: needs.files-changed.outputs.openapi == 'true'
|
||||
needs: [files-changed]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "25"
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.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@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
- name: Generate OpenAPI documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
|
||||
- name: Upload OpenAPI Documentation
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: openapi-docs
|
||||
path: ./SwaggerDoc.json
|
||||
|
||||
frontend-validation:
|
||||
if: needs.files-changed.outputs.frontend == 'true'
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install frontend dependencies
|
||||
run: cd frontend && npm ci
|
||||
- name: Type-check frontend
|
||||
run: cd frontend && npm run prep && npm run typecheck:all
|
||||
run: cd frontend && npm run prebuild && npm run typecheck:all
|
||||
- name: Lint frontend
|
||||
run: cd frontend && npm run lint
|
||||
- name: Build frontend
|
||||
@@ -210,7 +161,7 @@ jobs:
|
||||
- name: Run frontend tests
|
||||
run: cd frontend && npm run test -- --run
|
||||
- name: Upload frontend build artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: frontend-build
|
||||
path: frontend/dist/
|
||||
@@ -222,49 +173,23 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- 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/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "25"
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.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@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: check the licenses for compatibility
|
||||
# NOTE: --no-parallel is intentional here. Running the checkLicense task in parallel with other
|
||||
# Gradle tasks has been observed to cause intermittent failures with the dependency license
|
||||
# checking plugin on this Gradle version. Disabling parallel execution trades some build speed
|
||||
# for more reliable, deterministic license checks. If upgrading Gradle or the plugin, consider
|
||||
# re-evaluating whether this flag is still required before removing it.
|
||||
run: ./gradlew checkLicense --no-parallel
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
run: ./gradlew clean checkLicense
|
||||
|
||||
- name: FAILED - check the licenses for compatibility
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
@@ -288,47 +213,24 @@ jobs:
|
||||
# )
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
checks: write
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "25"
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
~/.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@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
|
||||
- name: Expose GitHub runtime for Buildx cache
|
||||
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Install Docker Compose
|
||||
run: |
|
||||
@@ -336,16 +238,15 @@ jobs:
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
cache: 'pip' # caching pip dependencies
|
||||
cache-dependency-path: ./testing/cucumber/requirements.txt
|
||||
|
||||
- name: Pip requirements
|
||||
run: |
|
||||
pip install --require-hashes -r ./testing/cucumber/requirements.txt
|
||||
pip install behave-html-formatter
|
||||
|
||||
- name: Run Docker Compose Tests
|
||||
run: |
|
||||
@@ -353,28 +254,6 @@ jobs:
|
||||
chmod +x ./testing/test.sh
|
||||
chmod +x ./testing/test_disabledEndpoints.sh
|
||||
./testing/test.sh
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
|
||||
- name: Upload Cucumber Report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: cucumber-report
|
||||
path: testing/cucumber/report.html
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Cucumber Test Report
|
||||
if: always()
|
||||
uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0
|
||||
with:
|
||||
name: Cucumber Tests
|
||||
path: testing/cucumber/junit/*.xml
|
||||
reporter: java-junit
|
||||
fail-on-error: false
|
||||
|
||||
test-build-docker-images:
|
||||
if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true'
|
||||
@@ -386,21 +265,18 @@ jobs:
|
||||
include:
|
||||
- docker-rev: docker/embedded/Dockerfile
|
||||
artifact-suffix: Dockerfile
|
||||
cache-scope: stirling-pdf-latest
|
||||
- docker-rev: docker/embedded/Dockerfile.ultra-lite
|
||||
artifact-suffix: Dockerfile.ultra-lite
|
||||
cache-scope: stirling-pdf-ultra-lite
|
||||
- docker-rev: docker/embedded/Dockerfile.fat
|
||||
artifact-suffix: Dockerfile.fat
|
||||
cache-scope: stirling-pdf-fat
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Free disk space on runner
|
||||
run: |
|
||||
@@ -409,42 +285,29 @@ jobs:
|
||||
docker system prune -af || true
|
||||
echo "Disk space after cleanup:" && df -h
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "25"
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependency artifacts
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
path: |
|
||||
~/.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@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Build application
|
||||
run: ./gradlew build
|
||||
run: ./gradlew clean build
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Build ${{ matrix.docker-rev }}
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
@@ -453,15 +316,15 @@ jobs:
|
||||
context: .
|
||||
file: ./${{ matrix.docker-rev }}
|
||||
push: false
|
||||
cache-from: type=gha,scope=${{ matrix.cache-scope }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.cache-scope }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Upload Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: reports-docker-${{ matrix.artifact-suffix }}
|
||||
path: |
|
||||
|
||||
@@ -7,8 +7,6 @@ on:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- "frontend/public/locales/*/translation.toml"
|
||||
- ".github/scripts/check_language_toml.py"
|
||||
- ".github/workflows/check_toml.yml"
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
concurrency:
|
||||
@@ -27,12 +25,12 @@ jobs:
|
||||
pull-requests: write # Allow writing to pull requests
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout main branch first
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
@@ -196,12 +194,13 @@ jobs:
|
||||
core.exportVariable("REFERENCE_FILE", referenceFilePath);
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
|
||||
run: |
|
||||
pip install tomli-w
|
||||
|
||||
- name: Run Python script to check files
|
||||
id: run-check
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
#disable for now
|
||||
#on:
|
||||
# push:
|
||||
# branches: ["main"]
|
||||
# pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
# branches: ["main"]
|
||||
# schedule:
|
||||
# - cron: "0 0 * * 1"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ["java"]
|
||||
# CodeQL supports [ $supported-codeql-languages ]
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -17,13 +17,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: "Dependency Review"
|
||||
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
|
||||
with:
|
||||
config-file: "./.github/config/dependency-review-config.yml"
|
||||
config-file: './.github/config/dependency-review-config.yml'
|
||||
|
||||
@@ -18,15 +18,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Get commit hashes for frontend and backend
|
||||
id: commit-hashes
|
||||
@@ -36,26 +36,26 @@ jobs:
|
||||
if [ -z "$FRONTEND_HASH" ]; then
|
||||
FRONTEND_HASH="no-frontend-changes"
|
||||
fi
|
||||
|
||||
|
||||
# Get last commit that touched backend code, docker/backend, or docker/compose
|
||||
BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
|
||||
if [ -z "$BACKEND_HASH" ]; then
|
||||
BACKEND_HASH="no-backend-changes"
|
||||
fi
|
||||
|
||||
|
||||
echo "Frontend hash: $FRONTEND_HASH"
|
||||
echo "Backend hash: $BACKEND_HASH"
|
||||
|
||||
|
||||
echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
|
||||
echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
# Short hashes for tags
|
||||
if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
|
||||
echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
|
||||
if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
|
||||
echo "backend_short=no-backend" >> $GITHUB_OUTPUT
|
||||
else
|
||||
@@ -84,52 +84,51 @@ jobs:
|
||||
echo "Backend image needs to be built"
|
||||
fi
|
||||
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Build and push frontend image
|
||||
if: steps.check-frontend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/frontend/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-v2-frontend
|
||||
cache-to: type=gha,mode=max,scope=stirling-v2-frontend
|
||||
tags: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
|
||||
- name: Build and push backend image
|
||||
if: steps.check-backend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/backend/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-v2-backend
|
||||
cache-to: type=gha,mode=max,scope=stirling-v2-backend
|
||||
tags: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-latest
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
chmod 600 ../private.key
|
||||
|
||||
|
||||
- name: Deploy to VPS on port 3000
|
||||
run: |
|
||||
export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml
|
||||
|
||||
|
||||
cat > $UNIQUE_NAME << EOF
|
||||
version: '3.3'
|
||||
services:
|
||||
@@ -155,24 +154,24 @@ jobs:
|
||||
SWAGGER_SERVER_URL: "https://demo.stirlingpdf.cloud"
|
||||
baseUrl: "https://demo.stirlingpdf.cloud"
|
||||
restart: on-failure:5
|
||||
|
||||
|
||||
frontend:
|
||||
container_name: stirling-v2-frontend
|
||||
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
|
||||
ports:
|
||||
- "3000:80"
|
||||
environment:
|
||||
VITE_API_BASE_URL: "http://${{ secrets.NEW_VPS_HOST }}:13000"
|
||||
VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:13000"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: on-failure:5
|
||||
EOF
|
||||
|
||||
|
||||
# Copy to remote with unique name
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/$UNIQUE_NAME
|
||||
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/$UNIQUE_NAME
|
||||
|
||||
# SSH and rename/move atomically to avoid interference
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
|
||||
mkdir -p /stirling/V2/{data,config,logs}
|
||||
mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml
|
||||
cd /stirling/V2
|
||||
@@ -187,3 +186,4 @@ jobs:
|
||||
if: always()
|
||||
run: |
|
||||
rm -f ../private.key
|
||||
|
||||
|
||||
@@ -1,523 +0,0 @@
|
||||
name: License Report Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
files-changed:
|
||||
name: detect what files changed
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
licenses-frontend: ${{ steps.changes.outputs.licenses-frontend }}
|
||||
licenses-backend: ${{ steps.changes.outputs.licenses-backend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
filters: .github/config/.files.yaml
|
||||
|
||||
generate-frontend-license-report:
|
||||
if: needs.files-changed.outputs.licenses-frontend == 'true'
|
||||
name: Generate Frontend License Report
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR head (default)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)) && github.actor != 'dependabot[bot]'
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout BASE branch (safe script)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: base
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
env:
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true"
|
||||
run: npm ci --ignore-scripts --audit=false --fund=false
|
||||
|
||||
- name: Generate frontend license report (internal PR)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
working-directory: frontend
|
||||
env:
|
||||
PR_IS_FORK: "false"
|
||||
run: npm run generate-licenses
|
||||
|
||||
- name: Generate frontend license report (fork PRs, pinned)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
env:
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true"
|
||||
working-directory: frontend
|
||||
run: |
|
||||
mkdir -p src/assets
|
||||
npx --yes license-report --only=prod --output=json > src/assets/3rdPartyLicenses.json
|
||||
|
||||
- name: Postprocess with project script (BASE version)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
env:
|
||||
PR_IS_FORK: "true"
|
||||
run: |
|
||||
node base/frontend/scripts/generate-licenses.js \
|
||||
--input frontend/src/assets/3rdPartyLicenses.json
|
||||
|
||||
- name: Copy postprocessed artifacts back (fork PRs)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
run: |
|
||||
mkdir -p frontend/src/assets
|
||||
if [ -f "base/frontend/src/assets/3rdPartyLicenses.json" ]; then
|
||||
cp base/frontend/src/assets/3rdPartyLicenses.json frontend/src/assets/3rdPartyLicenses.json
|
||||
fi
|
||||
if [ -f "base/frontend/src/assets/license-warnings.json" ]; then
|
||||
cp base/frontend/src/assets/license-warnings.json frontend/src/assets/license-warnings.json
|
||||
fi
|
||||
|
||||
- name: Check for license warnings
|
||||
run: |
|
||||
if [ -f "frontend/src/assets/license-warnings.json" ]; then
|
||||
echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# PR Event: Check licenses and comment on PR
|
||||
- name: Delete previous license check comments
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.issue.number;
|
||||
|
||||
// Get all comments on the PR
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
// Filter for license check comments
|
||||
const licenseComments = comments.filter(comment =>
|
||||
comment.body.includes('## ✅ Frontend License Check Passed') ||
|
||||
comment.body.includes('## ❌ Frontend License Check Failed')
|
||||
);
|
||||
|
||||
// Delete old license check comments
|
||||
for (const comment of licenseComments) {
|
||||
console.log(`Deleting old license check comment: ${comment.id}`);
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: comment.id
|
||||
});
|
||||
}
|
||||
|
||||
- name: Summarize results (fork PRs)
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) || github.actor == 'dependabot[bot]'
|
||||
run: |
|
||||
{
|
||||
echo "## Frontend License Check"
|
||||
echo ""
|
||||
if [ "${LICENSE_WARNINGS_EXIST}" = "true" ]; then
|
||||
echo "❌ **Failed** – incompatible or unknown licenses found."
|
||||
if [ -f "frontend/src/assets/license-warnings.json" ]; then
|
||||
echo ""
|
||||
echo "### Warnings"
|
||||
jq -r '.warnings[] | "- \(.message)"' frontend/src/assets/license-warnings.json || true
|
||||
fi
|
||||
else
|
||||
echo "✅ **Passed** – no license warnings detected."
|
||||
fi
|
||||
echo ""
|
||||
echo "_Note: This is a fork PR. PR comments are disabled; use this summary._"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Comment on PR - License Check Results
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.issue.number;
|
||||
const hasWarnings = process.env.LICENSE_WARNINGS_EXIST === 'true';
|
||||
|
||||
let commentBody;
|
||||
|
||||
if (hasWarnings) {
|
||||
// Read warnings file to get specific issues
|
||||
const fs = require('fs');
|
||||
let warningDetails = '';
|
||||
try {
|
||||
const warnings = JSON.parse(fs.readFileSync('frontend/src/assets/license-warnings.json', 'utf8'));
|
||||
warningDetails = warnings.warnings.map(w => `- ${w.message}`).join('\n');
|
||||
} catch (e) {
|
||||
warningDetails = 'Unable to read warning details';
|
||||
}
|
||||
|
||||
commentBody = `## ❌ Frontend License Check Failed
|
||||
|
||||
The frontend license check has detected compatibility warnings that require review:
|
||||
|
||||
${warningDetails}
|
||||
|
||||
**Action Required:** Please review these licenses to ensure they are acceptable for your use case before merging.
|
||||
|
||||
_This check will fail the PR until license issues are resolved._`;
|
||||
} else {
|
||||
commentBody = `## ✅ Frontend License Check Passed
|
||||
|
||||
All frontend licenses have been validated and no compatibility warnings were detected.
|
||||
|
||||
The frontend license report has been updated successfully.`;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: commentBody
|
||||
});
|
||||
|
||||
- name: Fail workflow if license warnings exist (PR only)
|
||||
if: github.event_name == 'pull_request' && env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
run: |
|
||||
echo "❌ License warnings detected. Failing the workflow."
|
||||
exit 1
|
||||
|
||||
# Push Event: Commit license files and create PR
|
||||
- name: Commit changes (Push only)
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
git add frontend/src/assets/3rdPartyLicenses.json
|
||||
# Note: Do NOT commit license-warnings.json - it's only for PR review
|
||||
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
|
||||
|
||||
- name: Prepare PR body (Push only)
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
PR_BODY="Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
|
||||
|
||||
This PR updates the frontend license report based on changes to package.json dependencies."
|
||||
|
||||
if [ "${{ env.LICENSE_WARNINGS_EXIST }}" = "true" ]; then
|
||||
PR_BODY="$PR_BODY
|
||||
|
||||
## ⚠️ License Compatibility Warnings
|
||||
|
||||
The following licenses may require review for corporate compatibility:
|
||||
|
||||
$(cat frontend/src/assets/license-warnings.json | jq -r '.warnings[].message')
|
||||
|
||||
Please review these licenses to ensure they are acceptable for your use case."
|
||||
fi
|
||||
|
||||
echo "PR_BODY<<EOF" >> $GITHUB_ENV
|
||||
echo "$PR_BODY" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Create Pull Request (Push only)
|
||||
id: cpr
|
||||
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Update Frontend 3rd Party Licenses"
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: update-frontend-3rd-party-licenses
|
||||
base: main
|
||||
title: "Update Frontend 3rd Party Licenses"
|
||||
body: ${{ env.PR_BODY }}
|
||||
labels: Licenses,github-actions,frontend
|
||||
draft: false
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
|
||||
- name: Enable Pull Request Automerge (Push only)
|
||||
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'false'
|
||||
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
- name: Add review required label (Push only)
|
||||
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
run: gh pr edit "${{ steps.cpr.outputs.pull-request-number }}" --add-label "license-review-required"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
generate-backend-license-report:
|
||||
if: needs.files-changed.outputs.licenses-backend == 'true'
|
||||
needs: files-changed
|
||||
name: Generate Backend License Report
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)) && github.actor != 'dependabot[bot]'
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Check licenses and generate report
|
||||
id: license-check
|
||||
run: |
|
||||
# NOTE: --no-parallel is intentional here. Running the license-checking tasks in parallel has
|
||||
# previously caused intermittent concurrency issues in CI (e.g. flaky failures in the license
|
||||
# plugin/Gradle when multiple projects are evaluated concurrently). Disabling parallelism trades
|
||||
# some build speed for more reliable license reports. If the underlying issues are resolved in
|
||||
# future Gradle or plugin versions, this flag can be reconsidered.
|
||||
./gradlew checkLicense generateLicenseReport --no-parallel || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: false
|
||||
STIRLING_PDF_DESKTOP_UI: true
|
||||
|
||||
- name: Check for license compatibility issues
|
||||
run: |
|
||||
if [ -f build/reports/dependency-license/dependencies-without-allowed-license.json ] && \
|
||||
jq '.dependenciesWithoutAllowedLicenses | length > 0' build/reports/dependency-license/dependencies-without-allowed-license.json | grep -q true; then
|
||||
echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
|
||||
fi
|
||||
if: always()
|
||||
|
||||
- name: Upload artifact on license issues
|
||||
if: env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: backend-dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
|
||||
- name: Move license file
|
||||
if: env.LICENSE_CHECK_FAILED != 'true' && env.LICENSE_WARNINGS_EXIST == 'false'
|
||||
run: |
|
||||
mkdir -p app/core/src/main/resources/static
|
||||
cp build/reports/dependency-license/index.json app/core/src/main/resources/static/3rdPartyLicenses.json
|
||||
|
||||
- name: Delete previous backend license check comments
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.issue.number;
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
const backendLicenseComments = comments.filter(comment =>
|
||||
comment.body.includes('## ✅ Backend License Check Passed') ||
|
||||
comment.body.includes('## ❌ Backend License Check Failed')
|
||||
);
|
||||
|
||||
for (const comment of backendLicenseComments) {
|
||||
console.log(`Deleting old backend license comment: ${comment.id}`);
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: comment.id
|
||||
});
|
||||
}
|
||||
|
||||
- name: Comment on PR - Backend License Check Results
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const hasWarnings = process.env.LICENSE_WARNINGS_EXIST === 'true';
|
||||
const fs = require('fs');
|
||||
let warningDetails = '';
|
||||
|
||||
if (hasWarnings) {
|
||||
try {
|
||||
const warningsFile = 'build/reports/dependency-license/dependencies-without-allowed-license.json';
|
||||
if (fs.existsSync(warningsFile)) {
|
||||
const data = JSON.parse(fs.readFileSync(warningsFile, 'utf8'));
|
||||
if (data.length > 0) {
|
||||
warningDetails = data.map(dep => `- **${dep.moduleName}@${dep.moduleVersion}** – ${dep.moduleLicenses.map(l => l.licenseName).join(', ')}`).join('\n');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
warningDetails = 'Unable to parse warning details.';
|
||||
}
|
||||
}
|
||||
|
||||
let commentBody;
|
||||
if (hasWarnings) {
|
||||
commentBody = `## ❌ Backend License Check Failed
|
||||
|
||||
The backend license check has detected dependencies with incompatible or unallowed licenses:
|
||||
|
||||
${warningDetails || 'See uploaded artifact for details.'}
|
||||
|
||||
**Action Required:** Please review these licenses and resolve before merging.
|
||||
|
||||
_This check will fail the PR until license issues are resolved._`;
|
||||
} else {
|
||||
commentBody = `## ✅ Backend License Check Passed
|
||||
|
||||
All backend dependencies have valid and allowed licenses.
|
||||
|
||||
The backend license report has been updated successfully.`;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: commentBody
|
||||
});
|
||||
|
||||
- name: Fail workflow if license warnings exist (PR only)
|
||||
if: github.event_name == 'pull_request' && env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
run: |
|
||||
echo "❌ Backend license warnings detected. Failing the workflow."
|
||||
exit 1
|
||||
|
||||
- name: Commit changes (push only)
|
||||
if: github.event_name == 'push' && env.LICENSE_WARNINGS_EXIST == 'false'
|
||||
run: |
|
||||
git config user.name "${{ steps.setup-bot.outputs.committer }}"
|
||||
git config user.email "${{ steps.setup-bot.outputs.committer-email || 'bot@github.com' }}"
|
||||
git add app/core/src/main/resources/static/3rdPartyLicenses.json
|
||||
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
|
||||
|
||||
- name: Prepare PR body (push only)
|
||||
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
|
||||
run: |
|
||||
PR_BODY="Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
|
||||
|
||||
This PR updates the backend license report based on dependency changes."
|
||||
|
||||
if [ "${{ env.LICENSE_WARNINGS_EXIST }}" = "true" ]; then
|
||||
PR_BODY="$PR_BODY
|
||||
|
||||
## ⚠️ License Compatibility Warnings
|
||||
|
||||
Incompatible licenses detected – manual review required before merge."
|
||||
fi
|
||||
echo "PR_BODY<<EOF" >> $GITHUB_ENV
|
||||
echo "$PR_BODY" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Create Pull Request (push only)
|
||||
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Update Backend 3rd Party Licenses"
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: update-backend-3rd-party-licenses
|
||||
base: main
|
||||
title: "Update Backend 3rd Party Licenses"
|
||||
body: ${{ env.PR_BODY }}
|
||||
labels: Licenses,github-actions,backend
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
|
||||
- name: Enable Pull Request Automerge (push only, no warnings)
|
||||
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'false'
|
||||
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
- name: Add review required label (push only, with warnings)
|
||||
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
run: gh pr edit "${{ steps.cpr.outputs.pull-request-number }}" --add-label "license-review-required"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
@@ -0,0 +1,282 @@
|
||||
name: Frontend License Report Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- V2
|
||||
paths:
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/scripts/generate-licenses.js"
|
||||
pull_request:
|
||||
branches:
|
||||
- V2
|
||||
paths:
|
||||
- ".github/workflows/frontend-licenses-update.yml"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/scripts/generate-licenses.js"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
generate-frontend-license-report:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR head (default)
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout BASE branch (safe script)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: base
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
env:
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true"
|
||||
run: npm ci --ignore-scripts --audit=false --fund=false
|
||||
|
||||
- name: Generate frontend license report (internal PR)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
working-directory: frontend
|
||||
env:
|
||||
PR_IS_FORK: "false"
|
||||
run: npm run generate-licenses
|
||||
|
||||
- name: Generate frontend license report (fork PRs, pinned)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
env:
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true"
|
||||
working-directory: frontend
|
||||
run: |
|
||||
mkdir -p src/assets
|
||||
npx --yes license-report --only=prod --output=json > src/assets/3rdPartyLicenses.json
|
||||
|
||||
- name: Postprocess with project script (BASE version)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
env:
|
||||
PR_IS_FORK: "true"
|
||||
run: |
|
||||
node base/frontend/scripts/generate-licenses.js \
|
||||
--input frontend/src/assets/3rdPartyLicenses.json
|
||||
|
||||
- name: Copy postprocessed artifacts back (fork PRs)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
run: |
|
||||
mkdir -p frontend/src/assets
|
||||
if [ -f "base/frontend/src/assets/3rdPartyLicenses.json" ]; then
|
||||
cp base/frontend/src/assets/3rdPartyLicenses.json frontend/src/assets/3rdPartyLicenses.json
|
||||
fi
|
||||
if [ -f "base/frontend/src/assets/license-warnings.json" ]; then
|
||||
cp base/frontend/src/assets/license-warnings.json frontend/src/assets/license-warnings.json
|
||||
fi
|
||||
|
||||
- name: Check for license warnings
|
||||
run: |
|
||||
if [ -f "frontend/src/assets/license-warnings.json" ]; then
|
||||
echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# PR Event: Check licenses and comment on PR
|
||||
- name: Delete previous license check comments
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.issue.number;
|
||||
|
||||
// Get all comments on the PR
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
// Filter for license check comments
|
||||
const licenseComments = comments.filter(comment =>
|
||||
comment.body.includes('## ✅ Frontend License Check Passed') ||
|
||||
comment.body.includes('## ❌ Frontend License Check Failed')
|
||||
);
|
||||
|
||||
// Delete old license check comments
|
||||
for (const comment of licenseComments) {
|
||||
console.log(`Deleting old license check comment: ${comment.id}`);
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: comment.id
|
||||
});
|
||||
}
|
||||
|
||||
- name: Summarize results (fork PRs)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
run: |
|
||||
{
|
||||
echo "## Frontend License Check"
|
||||
echo ""
|
||||
if [ "${LICENSE_WARNINGS_EXIST}" = "true" ]; then
|
||||
echo "❌ **Failed** – incompatible or unknown licenses found."
|
||||
if [ -f "frontend/src/assets/license-warnings.json" ]; then
|
||||
echo ""
|
||||
echo "### Warnings"
|
||||
jq -r '.warnings[] | "- \(.message)"' frontend/src/assets/license-warnings.json || true
|
||||
fi
|
||||
else
|
||||
echo "✅ **Passed** – no license warnings detected."
|
||||
fi
|
||||
echo ""
|
||||
echo "_Note: This is a fork PR. PR comments are disabled; use this summary._"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Comment on PR - License Check Results
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.issue.number;
|
||||
const hasWarnings = process.env.LICENSE_WARNINGS_EXIST === 'true';
|
||||
|
||||
let commentBody;
|
||||
|
||||
if (hasWarnings) {
|
||||
// Read warnings file to get specific issues
|
||||
const fs = require('fs');
|
||||
let warningDetails = '';
|
||||
try {
|
||||
const warnings = JSON.parse(fs.readFileSync('frontend/src/assets/license-warnings.json', 'utf8'));
|
||||
warningDetails = warnings.warnings.map(w => `- ${w.message}`).join('\n');
|
||||
} catch (e) {
|
||||
warningDetails = 'Unable to read warning details';
|
||||
}
|
||||
|
||||
commentBody = `## ❌ Frontend License Check Failed
|
||||
|
||||
The frontend license check has detected compatibility warnings that require review:
|
||||
|
||||
${warningDetails}
|
||||
|
||||
**Action Required:** Please review these licenses to ensure they are acceptable for your use case before merging.
|
||||
|
||||
_This check will fail the PR until license issues are resolved._`;
|
||||
} else {
|
||||
commentBody = `## ✅ Frontend License Check Passed
|
||||
|
||||
All frontend licenses have been validated and no compatibility warnings were detected.
|
||||
|
||||
The frontend license report has been updated successfully.`;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: commentBody
|
||||
});
|
||||
|
||||
- name: Fail workflow if license warnings exist (PR only)
|
||||
if: github.event_name == 'pull_request' && env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
run: |
|
||||
echo "❌ License warnings detected. Failing the workflow."
|
||||
exit 1
|
||||
|
||||
# Push Event: Commit license files and create PR
|
||||
- name: Commit changes (Push only)
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
git add frontend/src/assets/3rdPartyLicenses.json
|
||||
# Note: Do NOT commit license-warnings.json - it's only for PR review
|
||||
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
|
||||
|
||||
- name: Prepare PR body (Push only)
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
PR_BODY="Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
|
||||
|
||||
This PR updates the frontend license report based on changes to package.json dependencies."
|
||||
|
||||
if [ "${{ env.LICENSE_WARNINGS_EXIST }}" = "true" ]; then
|
||||
PR_BODY="$PR_BODY
|
||||
|
||||
## ⚠️ License Compatibility Warnings
|
||||
|
||||
The following licenses may require review for corporate compatibility:
|
||||
|
||||
$(cat frontend/src/assets/license-warnings.json | jq -r '.warnings[].message')
|
||||
|
||||
Please review these licenses to ensure they are acceptable for your use case."
|
||||
fi
|
||||
|
||||
echo "PR_BODY<<EOF" >> $GITHUB_ENV
|
||||
echo "$PR_BODY" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Create Pull Request (Push only)
|
||||
id: cpr
|
||||
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Update Frontend 3rd Party Licenses"
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: update-frontend-3rd-party-licenses
|
||||
base: V2
|
||||
title: "Update Frontend 3rd Party Licenses"
|
||||
body: ${{ env.PR_BODY }}
|
||||
labels: Licenses,github-actions,frontend
|
||||
draft: false
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
|
||||
- name: Enable Pull Request Automerge (Push only)
|
||||
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'false'
|
||||
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
- name: Add review required label (Push only)
|
||||
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
run: gh pr edit "${{ steps.cpr.outputs.pull-request-number }}" --add-label "license-review-required"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
@@ -0,0 +1,105 @@
|
||||
name: License Report Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "build.gradle"
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
# or a pull request is updated.
|
||||
# It helps to save resources and time by ensuring that only the latest commit is built and tested
|
||||
# This is particularly useful for long-running jobs that may take a while to complete.
|
||||
# The `group` is set to a combination of the workflow name, event name, and branch name.
|
||||
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
|
||||
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
generate-license-report:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Check licenses for compatibility
|
||||
run: ./gradlew clean checkLicense
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: false
|
||||
STIRLING_PDF_DESKTOP_UI: true
|
||||
|
||||
- name: Upload artifact on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
retention-days: 3
|
||||
|
||||
- name: Move and rename license file
|
||||
run: |
|
||||
mv build/reports/dependency-license/index.json app/core/src/main/resources/static/3rdPartyLicenses.json
|
||||
|
||||
- name: Commit changes
|
||||
run: |
|
||||
git add app/core/src/main/resources/static/3rdPartyLicenses.json
|
||||
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
if: env.CHANGES_DETECTED == 'true'
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Update 3rd Party Licenses"
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: update-3rd-party-licenses
|
||||
title: "Update 3rd Party Licenses"
|
||||
body: |
|
||||
Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
|
||||
labels: Licenses,github-actions
|
||||
draft: false
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
|
||||
- name: Enable Pull Request Automerge
|
||||
if: steps.cpr.outputs.pull-request-operation == 'created'
|
||||
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
@@ -15,12 +15,12 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Run Labeler
|
||||
uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 # v5.3.0
|
||||
|
||||
@@ -21,6 +21,8 @@ on:
|
||||
- windows
|
||||
- macos
|
||||
- linux
|
||||
push:
|
||||
branches: [main, V2, V2-demo, V2-master]
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
@@ -36,32 +38,21 @@ jobs:
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
java-version: "25"
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
@@ -71,10 +62,6 @@ jobs:
|
||||
VERSION=$(./gradlew printVersion --quiet | tail -1)
|
||||
echo "Extracted version: $VERSION"
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
|
||||
- name: Determine build matrix
|
||||
id: set-matrix
|
||||
@@ -119,37 +106,33 @@ jobs:
|
||||
file_suffix: "-server"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
java-version: "25"
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.variant.build_frontend == true
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Build JAR
|
||||
run: ./gradlew build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
run: ./gradlew clean build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.variant.disable_security }}
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
@@ -162,7 +145,7 @@ jobs:
|
||||
cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar
|
||||
|
||||
- name: Upload JAR artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: jar${{ matrix.variant.file_suffix }}
|
||||
path: ./jar-dist/*.jar
|
||||
@@ -179,12 +162,12 @@ jobs:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
@@ -193,36 +176,31 @@ jobs:
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
java-version: "25"
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Build Java backend with JLink
|
||||
working-directory: ./
|
||||
shell: bash
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
echo "🔧 Building Stirling-PDF JAR..."
|
||||
./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Find the built JAR
|
||||
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
|
||||
@@ -286,20 +264,17 @@ jobs:
|
||||
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
|
||||
echo "📊 Custom JRE size: $RUNTIME_SIZE"
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
run: npm install
|
||||
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
|
||||
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
|
||||
uses: digicert/ssm-code-signing@v1.1.0
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
@@ -407,7 +382,7 @@ jobs:
|
||||
echo "Certificate imported successfully."
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@51a9f1156b33df106d827c3a78f8f894946c5faa # v0.5.25
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
@@ -419,9 +394,8 @@ jobs:
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
|
||||
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
|
||||
# Only enable Windows signing in Tauri when on release or V2-master
|
||||
SIGN: ${{ (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
CI: true
|
||||
@@ -542,7 +516,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
@@ -556,30 +530,30 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download all Tauri artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
pattern: Stirling-PDF-*
|
||||
path: ./artifacts/tauri
|
||||
|
||||
- name: Download JAR artifact (default)
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: jar
|
||||
path: ./artifacts/jars
|
||||
|
||||
- name: Download JAR artifact (with login)
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: jar-with-login
|
||||
path: ./artifacts/jars
|
||||
|
||||
- name: Download JAR artifact (server only)
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: jar-server
|
||||
path: ./artifacts/jars
|
||||
@@ -588,7 +562,7 @@ jobs:
|
||||
run: ls -R ./artifacts
|
||||
|
||||
- name: Upload binaries to Release
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
||||
with:
|
||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -21,15 +21,14 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
@@ -39,43 +38,27 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip" # caching pip dependencies
|
||||
cache: 'pip' # caching pip dependencies
|
||||
cache-dependency-path: ./.github/scripts/requirements_pre_commit.txt
|
||||
|
||||
- name: Run Pre-Commit Hooks
|
||||
run: |
|
||||
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_pre_commit.txt
|
||||
|
||||
- name: Run Pre-Commit
|
||||
run: |
|
||||
pre-commit run ruff --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run ruff-format --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run codespell --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run gitleaks --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run end-of-file-fixer --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run trailing-whitespace --all-files -c .pre-commit-config.yaml
|
||||
- run: pre-commit run --all-files -c .pre-commit-config.yaml
|
||||
continue-on-error: true
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "25"
|
||||
java-version: 17
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
run: ./gradlew clean build
|
||||
|
||||
- name: git add
|
||||
run: |
|
||||
@@ -84,7 +67,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
if: env.CHANGES_DETECTED == 'true'
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: ":file_folder: pre-commit"
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
name: Push Docker Image - V2 Branch
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- V2-master
|
||||
- alljavadocker
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
# or a pull request is updated.
|
||||
# It helps to save resources and time by ensuring that only the latest commit is built and tested
|
||||
# This is particularly useful for long-running jobs that may take a while to complete.
|
||||
# The `group` is set to a combination of the workflow name, event name, and branch name.
|
||||
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
|
||||
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
push:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-24.04-8core
|
||||
permissions:
|
||||
packages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate tags for latest (V2-master branch - production)
|
||||
id: meta
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Generate tags for latest (alljavadocker branch - test)
|
||||
id: meta-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/alljavadocker'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Unified Dockerfile (latest variant)
|
||||
id: build-push-latest
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta.outputs.tags || steps.meta-test.outputs.tags }}
|
||||
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta.outputs.labels || steps.meta-test.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign regular images
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-latest.outputs.digest }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --yes \
|
||||
--key env://COSIGN_PRIVATE_KEY \
|
||||
"${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags for latest-fat (V2-master branch - production)
|
||||
id: meta-fat
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
|
||||
type=raw,value=latest-fat
|
||||
|
||||
- name: Generate tags for latest-fat (alljavadocker branch - test)
|
||||
id: meta-fat-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/alljavadocker'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
|
||||
type=raw,value=latest-fat
|
||||
|
||||
- name: Build and push Unified Dockerfile (fat variant)
|
||||
id: build-push-fat
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile.fat
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-fat.outputs.tags || steps.meta-fat-test.outputs.tags }}
|
||||
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-fat.outputs.labels || steps.meta-fat-test.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign fat images
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-fat.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags for ultra-lite (V2-master branch - production)
|
||||
id: meta-lite
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
|
||||
type=raw,value=latest-ultra-lite
|
||||
|
||||
- name: Generate tags for ultra-lite (alljavadocker branch - test)
|
||||
id: meta-lite-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/alljavadocker'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
|
||||
type=raw,value=latest-ultra-lite
|
||||
|
||||
- name: Build and push Unified Dockerfile (ultra-lite variant)
|
||||
id: build-push-lite
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile.ultra-lite
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-lite.outputs.tags || steps.meta-lite-test.outputs.tags }}
|
||||
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-lite.outputs.labels || steps.meta-lite-test.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign ultra-lite images
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-lite.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
@@ -6,8 +6,6 @@ on:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
- V2-master
|
||||
- testMain
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
@@ -27,86 +25,72 @@ permissions:
|
||||
jobs:
|
||||
push:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-24.04-8core
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "25"
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
- name: Run Gradle Command
|
||||
run: ./gradlew clean build
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/master'
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate tags for latest
|
||||
- name: Generate tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
@@ -114,20 +98,20 @@ jobs:
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/testMain' }}
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
|
||||
- name: Build and push Unified Dockerfile (latest variant)
|
||||
id: build-push-latest
|
||||
- name: Build and push main Dockerfile
|
||||
id: build-push-regular
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-pdf-latest
|
||||
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
@@ -136,9 +120,9 @@ jobs:
|
||||
sbom: true
|
||||
|
||||
- name: Sign regular images
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
if: github.ref == 'refs/heads/master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-latest.outputs.digest }}
|
||||
DIGEST: ${{ steps.build-push-regular.outputs.digest }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
@@ -149,10 +133,10 @@ jobs:
|
||||
"${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags for latest-fat
|
||||
id: meta-fat
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
- name: Generate tags ultra-lite
|
||||
id: meta2
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
@@ -160,76 +144,62 @@ jobs:
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
|
||||
- name: Build and push Unified Dockerfile (fat variant)
|
||||
- name: Build and push Dockerfile-ultra-lite
|
||||
id: build-push-lite
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile.ultra-lite
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ steps.meta2.outputs.tags }}
|
||||
labels: ${{ steps.meta2.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Generate tags fat
|
||||
id: meta3
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push main Dockerfile fat
|
||||
id: build-push-fat
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile.fat
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-pdf-fat
|
||||
cache-to: type=gha,mode=max,scope=stirling-pdf-fat
|
||||
tags: ${{ steps.meta-fat.outputs.tags }}
|
||||
labels: ${{ steps.meta-fat.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ steps.meta3.outputs.tags }}
|
||||
labels: ${{ steps.meta3.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign fat images
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
if: github.ref == 'refs/heads/master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-fat.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags for ultra-lite
|
||||
id: meta-lite
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
|
||||
|
||||
- name: Build and push Unified Dockerfile (ultra-lite variant)
|
||||
id: build-push-lite
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile.ultra-lite
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-pdf-ultra-lite
|
||||
cache-to: type=gha,mode=max,scope=stirling-pdf-ultra-lite
|
||||
tags: ${{ steps.meta-lite.outputs.tags }}
|
||||
labels: ${{ steps.meta-lite.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign ultra-lite images
|
||||
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-lite.outputs.tags }}
|
||||
TAGS: ${{ steps.meta3.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
name: Release Artifacts
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
platform:
|
||||
description: "Platform to build (windows, macos, linux, or all)"
|
||||
required: true
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- windows
|
||||
- macos
|
||||
- linux
|
||||
push:
|
||||
branches: [main, V2, V2-demo]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
determine-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: |
|
||||
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Determine build matrix
|
||||
id: set-matrix
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
case "${{ github.event.inputs.platform }}" in
|
||||
"windows")
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"macos")
|
||||
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"linux")
|
||||
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
*)
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# For push events, build all platforms
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: determine-matrix
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Build Java backend with JLink
|
||||
working-directory: ./
|
||||
shell: bash
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
echo "🔧 Building Stirling-PDF JAR..."
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Find the built JAR
|
||||
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
|
||||
echo "✅ Built JAR: $STIRLING_JAR"
|
||||
|
||||
# Create Tauri directories
|
||||
mkdir -p ./frontend/src-tauri/libs
|
||||
mkdir -p ./frontend/src-tauri/runtime
|
||||
|
||||
# Copy JAR to Tauri libs
|
||||
cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
|
||||
echo "✅ JAR copied to Tauri libs"
|
||||
|
||||
# Analyze JAR dependencies for jlink modules
|
||||
echo "🔍 Analyzing JAR dependencies..."
|
||||
if command -v jdeps &> /dev/null; then
|
||||
DETECTED_MODULES=$(jdeps --print-module-deps --ignore-missing-deps "$STIRLING_JAR" 2>/dev/null || echo "")
|
||||
if [ -n "$DETECTED_MODULES" ]; then
|
||||
echo "📋 jdeps detected modules: $DETECTED_MODULES"
|
||||
MODULES="$DETECTED_MODULES,java.compiler,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
else
|
||||
echo "⚠️ jdeps analysis failed, using predefined modules"
|
||||
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ jdeps not available, using predefined modules"
|
||||
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
fi
|
||||
|
||||
# Create custom JRE with jlink
|
||||
echo "🔧 Creating custom JRE with jlink..."
|
||||
echo "📋 Using modules: $MODULES"
|
||||
|
||||
# Remove any existing JRE
|
||||
rm -rf ./frontend/src-tauri/runtime/jre
|
||||
|
||||
# Create the custom JRE
|
||||
jlink \
|
||||
--add-modules "$MODULES" \
|
||||
--strip-debug \
|
||||
--compress=2 \
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output ./frontend/src-tauri/runtime/jre
|
||||
|
||||
if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
|
||||
echo "❌ Failed to create JLink runtime"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test the bundled runtime
|
||||
if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then
|
||||
RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
|
||||
echo "✅ Custom JRE created successfully: $RUNTIME_VERSION"
|
||||
else
|
||||
echo "❌ Custom JRE executable not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Calculate runtime size
|
||||
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
|
||||
echo "📊 Custom JRE size: $RUNTIME_SIZE"
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm install
|
||||
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
uses: digicert/ssm-code-signing@v1.1.0
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
|
||||
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
|
||||
- name: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
|
||||
# Decode client certificate
|
||||
$certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}")
|
||||
$certPath = "D:\Certificate_pkcs12.p12"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Set environment variables
|
||||
echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
|
||||
echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV
|
||||
echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV
|
||||
echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV
|
||||
echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV
|
||||
|
||||
# Get PKCS11 config path from DigiCert action
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if ($pkcs11Config) {
|
||||
Write-Host "Found PKCS11_CONFIG: $pkcs11Config"
|
||||
echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV
|
||||
} else {
|
||||
Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path"
|
||||
$defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg"
|
||||
if (Test-Path $defaultPath) {
|
||||
Write-Host "Found config at default path: $defaultPath"
|
||||
echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV
|
||||
} else {
|
||||
Write-Host "Warning: Could not find PKCS11 config file"
|
||||
}
|
||||
}
|
||||
|
||||
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
|
||||
- name: Import Windows Code Signing Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
shell: powershell
|
||||
run: |
|
||||
if ($env:WINDOWS_CERTIFICATE) {
|
||||
Write-Host "Importing Windows Code Signing Certificate..."
|
||||
|
||||
# Decode base64 certificate and save to file
|
||||
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
|
||||
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Import certificate to CurrentUser\My store
|
||||
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
|
||||
|
||||
# Extract and set thumbprint as environment variable
|
||||
$thumbprint = $cert.Thumbprint
|
||||
Write-Host "Certificate imported with thumbprint: $thumbprint"
|
||||
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
|
||||
|
||||
# Clean up certificate file
|
||||
Remove-Item $certPath
|
||||
|
||||
Write-Host "Windows certificate import completed."
|
||||
} else {
|
||||
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
echo "Importing Apple Developer Certificate..."
|
||||
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
|
||||
# Create temporary keychain
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
# Import certificate
|
||||
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
# Clean up
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application")
|
||||
echo "Certificate Info: $CERT_INFO"
|
||||
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
|
||||
echo "Certificate ID: $CERT_ID"
|
||||
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
|
||||
echo "Certificate imported successfully."
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
|
||||
# Only enable Windows signing in Tauri when on main
|
||||
SIGN: ${{ github.ref == 'refs/heads/main' && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
CI: true
|
||||
with:
|
||||
projectPath: ./frontend
|
||||
tauriScript: npx tauri
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
# Sign with DigiCert KeyLocker (post-build)
|
||||
- name: Sign Windows binaries with DigiCert KeyLocker
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "=== DigiCert KeyLocker Signing ==="
|
||||
|
||||
# Test smctl connectivity first
|
||||
Write-Host "Testing smctl connection..."
|
||||
$healthCheck = & smctl healthcheck 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "[SUCCESS] Connected to DigiCert KeyLocker"
|
||||
} else {
|
||||
Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker"
|
||||
Write-Host $healthCheck
|
||||
exit 1
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# Sync certificates to Windows certificate store
|
||||
Write-Host "Syncing certificates to Windows certificate store..."
|
||||
$syncOutput = & smctl windows certsync 2>&1
|
||||
Write-Host "Cert sync result: $syncOutput"
|
||||
Write-Host ""
|
||||
|
||||
# Find only the files we need to sign (not build scripts)
|
||||
$filesToSign = @()
|
||||
|
||||
# Main application executable
|
||||
$mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue
|
||||
if ($mainExe) { $filesToSign += $mainExe }
|
||||
|
||||
# MSI installer
|
||||
$msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
|
||||
$filesToSign += $msiFiles
|
||||
|
||||
if ($filesToSign.Count -eq 0) {
|
||||
Write-Host "[ERROR] No files found to sign"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($filesToSign.Count) files to sign:"
|
||||
foreach ($f in $filesToSign) { Write-Host " - $($f.Name)" }
|
||||
Write-Host ""
|
||||
|
||||
$signedCount = 0
|
||||
foreach ($file in $filesToSign) {
|
||||
Write-Host "Signing: $($file.Name)"
|
||||
|
||||
# Get PKCS11 config file path (set by DigiCert action)
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if (-not $pkcs11Config) {
|
||||
Write-Host "[ERROR] PKCS11_CONFIG environment variable not set"
|
||||
Write-Host "DigiCert KeyLocker action may not have run correctly"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Using PKCS11 config: $pkcs11Config"
|
||||
|
||||
# Try signing with certificate fingerprint first (if available)
|
||||
$fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}"
|
||||
if ($fingerprint -and $fingerprint -ne "") {
|
||||
Write-Host "Attempting to sign with certificate fingerprint..."
|
||||
$output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
} else {
|
||||
Write-Host "No fingerprint provided, using keypair alias..."
|
||||
# Use smctl to sign with keypair alias
|
||||
$output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
}
|
||||
|
||||
Write-Host "Exit code: $exitCode"
|
||||
Write-Host "Output: $output"
|
||||
|
||||
# Check if output contains "FAILED" even with exit code 0
|
||||
if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") {
|
||||
Write-Host ""
|
||||
Write-Host "[ERROR] Signing failed for $($file.Name)"
|
||||
Write-Host "[ERROR] smctl returned success but output indicates failure"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Host "[ERROR] Failed to sign $($file.Name)"
|
||||
Write-Host "Full error output:"
|
||||
Write-Host $output
|
||||
exit 1
|
||||
}
|
||||
|
||||
$signedCount++
|
||||
Write-Host "[SUCCESS] Signed: $($file.Name)"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
Write-Host "=== Summary ==="
|
||||
Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully"
|
||||
|
||||
- name: Rename artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ./dist
|
||||
cd ./frontend/src-tauri/target
|
||||
|
||||
# Find and rename artifacts based on platform
|
||||
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
|
||||
find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \;
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
|
||||
else
|
||||
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
|
||||
fi
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
retention-days: 30
|
||||
|
||||
sign_verify:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: success()
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
name: [windows-x86_64, macos-aarch64, macos-x86_64, linux-x86_64]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
|
||||
- name: Generate key pair
|
||||
run: cosign generate-key-pair
|
||||
|
||||
- name: Sign and generate attestations
|
||||
shell: bash
|
||||
run: |
|
||||
# Sign all artifacts for this platform
|
||||
for file in *; do
|
||||
if [ -f "$file" ] && [[ ! "$file" =~ \.(sig|intoto\.jsonl)$ ]]; then
|
||||
echo "Signing: $file"
|
||||
|
||||
# Sign the artifact
|
||||
cosign sign-blob \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-signature "${file}.sig" \
|
||||
"$file"
|
||||
|
||||
# Generate attestation
|
||||
cosign attest-blob \
|
||||
--predicate - \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-attestation "${file}.intoto.jsonl" \
|
||||
"$file"
|
||||
|
||||
# Verify the signature
|
||||
cosign verify-blob \
|
||||
--key ./cosign.pub \
|
||||
--signature "${file}.sig" \
|
||||
"$file"
|
||||
|
||||
echo "✅ Signed and verified: $file"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Upload signed artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}-signed
|
||||
path: |
|
||||
*
|
||||
!cosign.key
|
||||
!cosign.pub
|
||||
retention-days: 30
|
||||
|
||||
release:
|
||||
needs: [determine-matrix, build, sign_verify]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download all signed artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
pattern: Stirling-PDF-*-signed
|
||||
path: ./artifacts
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R ./artifacts
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
||||
with:
|
||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||
generate_release_notes: true
|
||||
files: ./artifacts/**/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
@@ -35,12 +35,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||
# format to the repository Actions tab.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
@@ -75,6 +75,6 @@ jobs:
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v3.29.5
|
||||
uses: github/codeql-action/upload-sarif@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.29.5
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Run Sonarqube
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request_target:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
# or a pull request is updated.
|
||||
# It helps to save resources and time by ensuring that only the latest commit is built and tested
|
||||
# This is particularly useful for long-running jobs that may take a while to complete.
|
||||
# The `group` is set to a combination of the workflow name, event name, and branch name.
|
||||
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
|
||||
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
sonarqube:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Build and analyze with Gradle
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
DISABLE_ADDITIONAL_FEATURES: false
|
||||
STIRLING_PDF_DESKTOP_UI: true
|
||||
run: |
|
||||
./gradlew clean build sonar \
|
||||
-Dsonar.projectKey=Stirling-Tools_Stirling-PDF \
|
||||
-Dsonar.organization=stirling-tools \
|
||||
-Dsonar.host.url=https://sonarcloud.io \
|
||||
-Dsonar.login=${SONAR_TOKEN} \
|
||||
-Dsonar.log.level=DEBUG \
|
||||
--info
|
||||
|
||||
- name: Upload Problems Report on Failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: gradle-problems-report
|
||||
path: build/reports/problems/problems-report.html
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload Sonar Logs on Failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: sonar-logs
|
||||
path: |
|
||||
.scannerwork/report-task.txt
|
||||
build/sonar/
|
||||
retention-days: 7
|
||||
@@ -17,12 +17,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: 30 days stale issues
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 30
|
||||
|
||||
@@ -27,22 +27,19 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "25"
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Generate Swagger documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
@@ -50,19 +47,12 @@ jobs:
|
||||
- name: Upload Swagger Documentation to SwaggerHub
|
||||
run: ./gradlew swaggerhubUpload
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
SWAGGERHUB_API_KEY: ${{ secrets.SWAGGERHUB_API_KEY }}
|
||||
SWAGGERHUB_USER: "Frooodle"
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
|
||||
- name: Set API version as published and default on SwaggerHub
|
||||
run: |
|
||||
|
||||
@@ -5,11 +5,9 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- syncLangTest
|
||||
paths:
|
||||
- "build.gradle"
|
||||
- "app/common/build.gradle"
|
||||
- "app/core/build.gradle"
|
||||
- "app/proprietary/build.gradle"
|
||||
- "README.md"
|
||||
- "frontend/public/locales/*/translation.toml"
|
||||
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
|
||||
@@ -35,13 +33,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
@@ -51,27 +47,27 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt -r ./.github/scripts/requirements_pre_commit.txt
|
||||
run: |
|
||||
pip install tomli-w
|
||||
|
||||
- name: Sync translation TOML files
|
||||
run: |
|
||||
python .github/scripts/check_language_toml.py --reference-file "frontend/public/locales/en-GB/translation.toml" --branch main
|
||||
|
||||
- name: pre-commit run
|
||||
run: |
|
||||
pre-commit run toml-sort-fix --all-files
|
||||
|
||||
- name: Commit translation files
|
||||
run: |
|
||||
git add frontend/public/locales/*/translation.toml
|
||||
git diff --staged --quiet || git commit -m ":memo: Sync translation files (TOML)" || echo "No changes detected"
|
||||
|
||||
- name: Install README dependencies
|
||||
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
|
||||
|
||||
- name: Sync README.md
|
||||
run: |
|
||||
python scripts/counter_translation_v3.py
|
||||
@@ -83,7 +79,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
if: always()
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: Update files
|
||||
@@ -125,4 +121,4 @@ jobs:
|
||||
add-paths: |
|
||||
README.md
|
||||
frontend/public/locales/*/translation.toml
|
||||
scripts/ignore_translation.toml
|
||||
scripts/ignore_translation.toml
|
||||
@@ -14,20 +14,17 @@ on:
|
||||
- macos
|
||||
- linux
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
branches: [main, V2, V2-tauri-windows]
|
||||
paths:
|
||||
- "frontend/src-tauri/**"
|
||||
- "frontend/src/desktop/**"
|
||||
- "frontend/tsconfig.desktop.json"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/vite.config.ts"
|
||||
- ".github/workflows/tauri-build.yml"
|
||||
- 'frontend/src-tauri/**'
|
||||
- 'frontend/src/desktop/**'
|
||||
- 'frontend/tsconfig.desktop.json'
|
||||
- '.github/workflows/tauri-build.yml'
|
||||
push:
|
||||
branches: [main, V2]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
determine-matrix:
|
||||
@@ -36,49 +33,29 @@ jobs:
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Determine build matrix
|
||||
id: set-matrix
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
run: |
|
||||
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}'
|
||||
MACOS_ARM='{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"}'
|
||||
MACOS_INTEL='{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}'
|
||||
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}'
|
||||
|
||||
# Resolve requested platform (non-dispatch events always build all)
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
PLATFORM="${{ github.event.inputs.platform }}"
|
||||
case "${{ github.event.inputs.platform }}" in
|
||||
"windows")
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"macos")
|
||||
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"linux")
|
||||
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
*)
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
else
|
||||
PLATFORM="all"
|
||||
# For PR/push events, build all platforms
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Build candidate list
|
||||
case "$PLATFORM" in
|
||||
windows) ENTRIES=("$WINDOWS") ;;
|
||||
macos) ENTRIES=("$MACOS_ARM" "$MACOS_INTEL") ;;
|
||||
linux) ENTRIES=("$LINUX") ;;
|
||||
*) ENTRIES=("$WINDOWS" "$MACOS_ARM" "$MACOS_INTEL" "$LINUX") ;;
|
||||
esac
|
||||
|
||||
# Drop macOS entries when Apple certificate secret is unavailable
|
||||
if [ -z "$APPLE_CERTIFICATE" ]; then
|
||||
echo "⚠️ APPLE_CERTIFICATE secret not available - skipping macOS builds"
|
||||
FILTERED=()
|
||||
for entry in "${ENTRIES[@]}"; do
|
||||
[[ "$entry" != *'"macos'* ]] && FILTERED+=("$entry")
|
||||
done
|
||||
ENTRIES=("${FILTERED[@]}")
|
||||
fi
|
||||
|
||||
JOINED=$(IFS=','; echo "${ENTRIES[*]}")
|
||||
echo "matrix={\"include\":[$JOINED]}" >> $GITHUB_OUTPUT
|
||||
|
||||
build:
|
||||
needs: determine-matrix
|
||||
strategy:
|
||||
@@ -88,15 +65,14 @@ jobs:
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
@@ -105,50 +81,45 @@ jobs:
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
java-version: "25"
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Build Java backend with JLink
|
||||
working-directory: ./
|
||||
shell: bash
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
echo "🔧 Building Stirling-PDF JAR..."
|
||||
# STIRLING_PDF_DESKTOP_UI=false ./gradlew bootJar --no-daemon
|
||||
./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
# STIRLING_PDF_DESKTOP_UI=false ./gradlew clean bootJar --no-daemon
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Find the built JAR
|
||||
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
|
||||
echo "✅ Built JAR: $STIRLING_JAR"
|
||||
|
||||
|
||||
# Create Tauri directories
|
||||
mkdir -p ./frontend/src-tauri/libs
|
||||
mkdir -p ./frontend/src-tauri/runtime
|
||||
|
||||
|
||||
# Copy JAR to Tauri libs
|
||||
cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
|
||||
echo "✅ JAR copied to Tauri libs"
|
||||
|
||||
|
||||
# Analyze JAR dependencies for jlink modules
|
||||
echo "🔍 Analyzing JAR dependencies..."
|
||||
if command -v jdeps &> /dev/null; then
|
||||
@@ -164,14 +135,14 @@ jobs:
|
||||
echo "⚠️ jdeps not available, using predefined modules"
|
||||
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
fi
|
||||
|
||||
|
||||
# Create custom JRE with jlink (always rebuild)
|
||||
echo "🔧 Creating custom JRE with jlink..."
|
||||
echo "📋 Using modules: $MODULES"
|
||||
|
||||
|
||||
# Remove any existing JRE
|
||||
rm -rf ./frontend/src-tauri/runtime/jre
|
||||
|
||||
|
||||
# Create the custom JRE
|
||||
jlink \
|
||||
--add-modules "$MODULES" \
|
||||
@@ -180,12 +151,12 @@ jobs:
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output ./frontend/src-tauri/runtime/jre
|
||||
|
||||
|
||||
if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
|
||||
echo "❌ Failed to create JLink runtime"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Test the bundled runtime
|
||||
if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then
|
||||
RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
|
||||
@@ -194,25 +165,22 @@ jobs:
|
||||
echo "❌ Custom JRE executable not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Calculate runtime size
|
||||
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
|
||||
echo "📊 Custom JRE size: $RUNTIME_SIZE"
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
run: npm install
|
||||
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
|
||||
uses: digicert/ssm-code-signing@v1.1.0
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
@@ -287,7 +255,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && env.APPLE_CERTIFICATE != ''
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -308,7 +276,7 @@ jobs:
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && env.APPLE_CERTIFICATE != ''
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
@@ -331,7 +299,7 @@ jobs:
|
||||
ls -la /usr/bin/hd* || echo "No hd* tools found"
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@51a9f1156b33df106d827c3a78f8f894946c5faa # v0.5.25
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
@@ -343,9 +311,8 @@ jobs:
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
|
||||
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
|
||||
# Only enable Windows signing in Tauri when on main
|
||||
SIGN: ${{ github.ref == 'refs/heads/main' && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
CI: true
|
||||
@@ -540,6 +507,7 @@ jobs:
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
|
||||
else
|
||||
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
|
||||
@@ -606,12 +574,12 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
retention-days: 7
|
||||
|
||||
|
||||
- name: Verify build artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -627,8 +595,8 @@ jobs:
|
||||
fi
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
echo "Checking for macOS artifacts..."
|
||||
find . -name "*.dmg" | head -5
|
||||
if [ $(find . -name "*.dmg" | wc -l) -eq 0 ]; then
|
||||
find . -name "*.dmg" -o -name "*.app" | head -5
|
||||
if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then
|
||||
echo "❌ No macOS artifacts found"
|
||||
exit 1
|
||||
fi
|
||||
@@ -659,110 +627,11 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
pr-comment:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && needs.build.result == 'success'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Post/Update PR Comment with Download Links
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = context.issue.number;
|
||||
const runId = context.runId;
|
||||
|
||||
// Fetch artifacts for this workflow run
|
||||
const { data: artifactsList } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner,
|
||||
repo,
|
||||
run_id: runId
|
||||
});
|
||||
|
||||
// Map of expected artifact names to display info
|
||||
const artifactMap = {
|
||||
'Stirling-PDF-windows-x86_64': { icon: '🪟', platform: 'Windows x64', files: '.exe, .msi' },
|
||||
'Stirling-PDF-macos-aarch64': { icon: '🍎', platform: 'macOS ARM64', files: '.dmg' },
|
||||
'Stirling-PDF-macos-x86_64': { icon: '🍎', platform: 'macOS Intel', files: '.dmg' },
|
||||
'Stirling-PDF-linux-x86_64': { icon: '🐧', platform: 'Linux x64', files: '.deb, .AppImage' }
|
||||
};
|
||||
|
||||
let commentBody = `## 📦 Tauri Desktop Builds Ready!\n\n`;
|
||||
commentBody += `The desktop applications have been built and are ready for testing.\n\n`;
|
||||
commentBody += `### Download Artifacts:\n\n`;
|
||||
|
||||
// Add links for each found artifact
|
||||
let foundArtifacts = 0;
|
||||
for (const artifact of artifactsList.artifacts) {
|
||||
const info = artifactMap[artifact.name];
|
||||
if (info) {
|
||||
foundArtifacts++;
|
||||
// GitHub doesn't provide direct download URLs via API, but we can link to the artifact on the Actions page
|
||||
const artifactUrl = `https://github.com/${owner}/${repo}/actions/runs/${runId}/artifacts/${artifact.id}`;
|
||||
commentBody += `${info.icon} **${info.platform}**: [Download ${artifact.name}](${artifactUrl}) `;
|
||||
commentBody += `(${info.files}) - ${(artifact.size_in_bytes / 1024 / 1024).toFixed(1)} MB\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundArtifacts === 0) {
|
||||
commentBody += `⚠️ **Warning**: No artifacts found in workflow run.\n`;
|
||||
commentBody += `[View workflow run](https://github.com/${owner}/${repo}/actions/runs/${runId})\n`;
|
||||
}
|
||||
|
||||
commentBody += `\n---\n`;
|
||||
commentBody += `_Built from commit ${context.sha.substring(0, 7)}_\n`;
|
||||
commentBody += `_Artifacts expire in 7 days_`;
|
||||
|
||||
// Find existing comment
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber
|
||||
});
|
||||
|
||||
const botComment = comments.find(comment =>
|
||||
comment.user.type === 'Bot' &&
|
||||
comment.body.includes('📦 Tauri Desktop Builds Ready!')
|
||||
);
|
||||
|
||||
if (botComment) {
|
||||
// Update existing comment
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: botComment.id,
|
||||
body: commentBody
|
||||
});
|
||||
console.log('Updated existing comment');
|
||||
} else {
|
||||
// Create new comment
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: commentBody
|
||||
});
|
||||
console.log('Created new comment');
|
||||
}
|
||||
|
||||
report:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Report build results
|
||||
run: |
|
||||
if [ "${{ needs.build.result }}" = "success" ]; then
|
||||
|
||||
@@ -25,34 +25,31 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
run: ./gradlew clean build
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
@@ -61,7 +58,7 @@ jobs:
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
@@ -72,8 +69,6 @@ jobs:
|
||||
context: .
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-pdf-latest
|
||||
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64
|
||||
@@ -81,7 +76,7 @@ jobs:
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Deploy to VPS
|
||||
@@ -112,9 +107,9 @@ jobs:
|
||||
restart: on-failure:5
|
||||
EOF
|
||||
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose.yml
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose.yml
|
||||
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << EOF
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << EOF
|
||||
mkdir -p /stirling/test-${{ github.sha }}/{data,config,logs}
|
||||
mv /tmp/docker-compose.yml /stirling/test-${{ github.sha }}/docker-compose.yml
|
||||
cd /stirling/test-${{ github.sha }}
|
||||
@@ -130,15 +125,10 @@ jobs:
|
||||
outputs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: changes
|
||||
with:
|
||||
filters: ".github/config/.files.yaml"
|
||||
@@ -150,16 +140,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
cache: "npm"
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Run TestDriver.ai
|
||||
@@ -171,7 +161,7 @@ jobs:
|
||||
npm install
|
||||
npm run build
|
||||
npm install dashcam-chrome --save
|
||||
Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.NEW_VPS_HOST }}:1337"
|
||||
Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.VPS_HOST }}:1337"
|
||||
Start-Sleep -Seconds 20
|
||||
prompt: |
|
||||
1. /run testing/testdriver/test.yml
|
||||
@@ -186,20 +176,20 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Cleanup deployment
|
||||
if: always()
|
||||
run: |
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << EOF
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << EOF
|
||||
cd /stirling/test-${{ github.sha }}
|
||||
docker-compose down
|
||||
cd /stirling
|
||||
|
||||
+2
-5
@@ -43,8 +43,6 @@ app/core/src/main/resources/static/og_images/
|
||||
app/core/src/main/resources/static/samples/
|
||||
app/core/src/main/resources/static/manifest-classic.json
|
||||
app/core/src/main/resources/static/robots.txt
|
||||
app/core/src/main/resources/static/pdfium/
|
||||
app/core/src/main/resources/static/vendor/
|
||||
# Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc.
|
||||
|
||||
# Gradle
|
||||
@@ -149,7 +147,6 @@ app/proprietary/build
|
||||
common/build
|
||||
proprietary/build
|
||||
stirling-pdf/build
|
||||
frontend/src-tauri/provisioner/target
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
@@ -158,7 +155,6 @@ __pycache__/
|
||||
|
||||
# Virtual environments
|
||||
.env*
|
||||
!.env*.example
|
||||
.venv*
|
||||
env*/
|
||||
venv*/
|
||||
@@ -215,7 +211,7 @@ id_ed25519.pub
|
||||
.mypy_cache
|
||||
.pytest_cache
|
||||
.ipynb_checkpoints
|
||||
.build-cache
|
||||
|
||||
|
||||
|
||||
**/jcef-bundle/
|
||||
@@ -245,3 +241,4 @@ docs/type3/signatures/
|
||||
|
||||
# Type3 sample PDFs (development only)
|
||||
**/type3/samples/
|
||||
|
||||
|
||||
+5
-11
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.14
|
||||
rev: v0.12.7
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
@@ -16,17 +16,17 @@ repos:
|
||||
hooks:
|
||||
- id: codespell
|
||||
args:
|
||||
- --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist
|
||||
- --ignore-words-list=thirdParty,tabEl,tabEls
|
||||
- --skip="./.*,*.csv,*.json,*.ambr"
|
||||
- --quiet-level=2
|
||||
files: \.(html|css|js|py|md)$
|
||||
exclude: (.vscode|.devcontainer|app/core/src/main/resources|app/proprietary/src/main/resources|frontend/public/vendor|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
|
||||
exclude: (.vscode|.devcontainer|app/core/src/main/resources|app/proprietary/src/main/resources|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.30.0
|
||||
rev: v8.28.0
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
files: ^.*(\.js|\.java|\.py|\.yml)$
|
||||
@@ -34,12 +34,6 @@ repos:
|
||||
- id: trailing-whitespace
|
||||
files: ^.*(\.js|\.java|\.py|\.yml)$
|
||||
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$)
|
||||
- repo: https://github.com/pappasam/toml-sort
|
||||
rev: v0.24.3
|
||||
hooks:
|
||||
- id: toml-sort-fix
|
||||
files: frontend/public/locales/.*\.toml$
|
||||
args: ['--in-place', '--all', '--ignore-case']
|
||||
# - repo: https://github.com/thibaudcolas/pre-commit-stylelint
|
||||
# rev: v16.21.1
|
||||
# hooks:
|
||||
|
||||
Vendored
+1
-1
@@ -13,7 +13,7 @@
|
||||
"vscjava.vscode-spring-boot-dashboard", // Spring Boot dashboard for managing and visualizing Spring Boot applications
|
||||
"EditorConfig.EditorConfig", // EditorConfig support for maintaining consistent coding styles
|
||||
"ms-azuretools.vscode-docker", // Docker extension for Visual Studio Code
|
||||
"GitHub.copilot-chat", // GitHub Copilot AI pair programmer for Visual Studio Code
|
||||
"GitHub.copilot", // GitHub Copilot AI pair programmer for Visual Studio Code
|
||||
"GitHub.vscode-pull-request-github", // GitHub Pull Requests extension for Visual Studio Code
|
||||
"charliermarsh.ruff", // Ruff code formatter for Python to follow the Ruff Style Guide
|
||||
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
|
||||
|
||||
+12
-4
@@ -1,6 +1,6 @@
|
||||
# Adding New React Tools to Stirling PDF
|
||||
|
||||
This guide covers how to add new PDF tools to the React frontend.
|
||||
This guide covers how to add new PDF tools to the React frontend, either by migrating existing Thymeleaf templates or creating entirely new tools.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -188,7 +188,7 @@ import { use[ToolName]Tips } from "../components/tooltips/use[ToolName]Tips";
|
||||
|
||||
const [ToolName] = (props: BaseToolProps) => {
|
||||
const tips = use[ToolName]Tips();
|
||||
|
||||
|
||||
// In your steps array:
|
||||
steps: [
|
||||
{
|
||||
@@ -257,14 +257,22 @@ Update translation files. **Important: Only update `en-GB` files** - other langu
|
||||
- Add `options.*` keys if your tool has settings with descriptions
|
||||
|
||||
**Tooltip Writing Guidelines:**
|
||||
- **Use simple, everyday language** - avoid technical terms like "converts interactive elements"
|
||||
- **Use simple, everyday language** - avoid technical terms like "converts interactive elements"
|
||||
- **Focus on benefits** - explain what the user gains, not how it works internally
|
||||
- **Use concrete examples** - "text boxes become regular text" vs "form fields are flattened"
|
||||
- **Answer user questions** - "What does this do?", "When should I use this?", "What's this option for?"
|
||||
- **Keep descriptions concise** - 1-2 sentences maximum per section
|
||||
- **Use bullet points** for multiple benefits or features
|
||||
|
||||
## 6. Testing Your Tool
|
||||
## 6. Migration from Thymeleaf
|
||||
When migrating existing Thymeleaf templates:
|
||||
|
||||
1. **Identify Form Parameters**: Look at the original `<form>` inputs to determine parameter structure
|
||||
2. **Extract Translation Keys**: Find `#{key.name}` references and add them to JSON translations (For many tools these translations will already exist but some parts will be missing)
|
||||
3. **Map API Endpoint**: Note the `th:action` URL for the operation hook
|
||||
4. **Preserve Functionality**: Ensure all original form behaviour is replicated which is applicable to V2 react UI
|
||||
|
||||
## 7. Testing Your Tool
|
||||
- Verify tool appears in UI with correct icon and description
|
||||
- Test with various file sizes and types
|
||||
- Confirm translations work
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to AI Agents when working with code in this repository.
|
||||
|
||||
## Common Development Commands
|
||||
|
||||
### Build and Test
|
||||
- **Build project**: `./gradlew clean build`
|
||||
- **Run locally**: `./gradlew bootRun`
|
||||
- **Full test suite**: `./test.sh` (builds all Docker variants and runs comprehensive tests)
|
||||
- **Code formatting**: `./gradlew spotlessApply` (runs automatically before compilation)
|
||||
|
||||
### Docker Development
|
||||
- **Build ultra-lite**: `docker build -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite .`
|
||||
- **Build standard**: `docker build -t stirlingtools/stirling-pdf:latest -f ./Dockerfile .`
|
||||
- **Build fat version**: `docker build -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .`
|
||||
- **Example compose files**: Located in `exampleYmlFiles/` directory
|
||||
|
||||
### Security Mode Development
|
||||
Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security features during development. This is required for testing the full version locally.
|
||||
|
||||
### Python Development
|
||||
Development for the AI engine happens in the `engine/` folder. It's built with Langchain and Pydantic and allows for the creation and editing of PDF documents. The frontend calls the Python via Java as a proxy.
|
||||
|
||||
- Python version is 3.13; use modern Python features (type aliases, pattern matching, dataclasses, etc.) where they help clarity.
|
||||
- Write fully type-correct code; keep pyright clean and avoid `Any` unless strictly necessary.
|
||||
- JSON handling: deserialize into fully typed Pydantic models as early as possible, and serialize back from Pydantic models as late as possible.
|
||||
- Use Makefile commands for Python work:
|
||||
- From `engine/`: `make check` to lint, type-check, test, etc. and `make fix` to fix easily fixable linting & formatting issues.
|
||||
- The project structure is defined in `engine/pyproject.toml`. Any new dependencies should be listed appropriately there, followed by running `make install`.
|
||||
- Prefer using classes to nesting functions, and make other similar architectural decisions to improve testability. Do not nest classes or functions unless specifically required to for the code construct (like a decorator).
|
||||
- All environment variables used within the code must begin with the `STIRLING_` prefix in order to keep them unique and easier to find.
|
||||
|
||||
### Frontend Development
|
||||
- **Frontend dev server**: `cd frontend && npm run dev` (requires backend on localhost:8080)
|
||||
- **Tech Stack**: Vite + React + TypeScript + Mantine UI + TailwindCSS
|
||||
- **Proxy Configuration**: Vite proxies `/api/*` calls to backend (localhost:8080)
|
||||
- **Build Process**: DO NOT run build scripts manually - builds are handled by CI/CD pipelines
|
||||
- **Package Installation**: DO NOT run npm install commands - package management handled separately
|
||||
- **Deployment Options**:
|
||||
- **Desktop App**: `npm run tauri-build` (native desktop application)
|
||||
- **Web Server**: `npm run build` then serve dist/ folder
|
||||
- **Development**: `npm run tauri-dev` for desktop dev mode
|
||||
|
||||
#### Environment Variables
|
||||
- All `VITE_*` variables must be declared in the appropriate example file:
|
||||
- `frontend/config/.env.example` — core, proprietary, and shared vars
|
||||
- `frontend/config/.env.saas.example` — SaaS-only vars
|
||||
- `frontend/config/.env.desktop.example` — desktop (Tauri)-only vars
|
||||
- Never use `|| 'hardcoded-fallback'` inline — put defaults in the example files
|
||||
- `npm run prep` / `prep:saas` / `prep:desktop` auto-create the env files from examples on first run, and error if any required keys are missing
|
||||
- These prep scripts run automatically at the start of all `dev*`, `build*`, and `tauri*` commands
|
||||
- See `frontend/README.md#environment-variables` for full documentation
|
||||
|
||||
#### Import Paths - CRITICAL
|
||||
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
|
||||
|
||||
For a broader explanation of the frontend layering and override architecture, see [frontend/DeveloperGuide.md](frontend/DeveloperGuide.md).
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use @app/* for all imports
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
import { useFileContext } from "@app/contexts/FileContext";
|
||||
import { FileContext } from "@app/contexts/FileContext";
|
||||
|
||||
// ❌ WRONG - Do not use @core/* or @proprietary/* in normal code
|
||||
import { AppLayout } from "@core/components/AppLayout";
|
||||
import { useFileContext } from "@proprietary/contexts/FileContext";
|
||||
```
|
||||
|
||||
**Only use explicit aliases when:**
|
||||
- Building layer-specific override that wraps a lower layer's component
|
||||
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
|
||||
|
||||
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
|
||||
|
||||
#### Component Override Pattern (Stub/Shadow)
|
||||
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
|
||||
|
||||
**How it works:**
|
||||
1. Core defines stub component (returns null or no-op)
|
||||
2. Desktop/proprietary overrides with same path/name
|
||||
3. Core imports via `@app/*` - higher layer "shadows" core in those builds
|
||||
4. No `@ts-ignore`, no `isTauri()` checks, no runtime conditionals!
|
||||
|
||||
**Example - Desktop-specific footer:**
|
||||
|
||||
```typescript
|
||||
// core/components/rightRail/RightRailFooterExtensions.tsx (stub)
|
||||
interface RightRailFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) {
|
||||
return null; // Stub - does nothing in web builds
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation)
|
||||
import { Box } from '@mantine/core';
|
||||
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
|
||||
|
||||
interface RightRailFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
|
||||
return (
|
||||
<Box className={className}>
|
||||
<BackendHealthIndicator />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// core/components/shared/RightRail.tsx (usage - works in ALL builds)
|
||||
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
|
||||
|
||||
export function RightRail() {
|
||||
return (
|
||||
<div>
|
||||
{/* In web builds: renders nothing (stub returns null) */}
|
||||
{/* In desktop builds: renders BackendHealthIndicator */}
|
||||
<RightRailFooterExtensions className="right-rail-footer" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Build resolution:**
|
||||
- **Core build**: `@app/*` → `core/*` → Gets stub (returns null)
|
||||
- **Desktop build**: `@app/*` → `desktop/*` → Gets real implementation (shadows core)
|
||||
|
||||
**Benefits:**
|
||||
- No runtime checks or feature flags
|
||||
- Type-safe across all builds
|
||||
- Clean, readable code
|
||||
- Build-time optimization (dead code elimination)
|
||||
|
||||
#### Multi-Tool Workflow Architecture
|
||||
Frontend designed for **stateful document processing**:
|
||||
- Users upload PDFs once, then chain tools (split → merge → compress → view)
|
||||
- File state and processing results persist across tool switches
|
||||
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
|
||||
|
||||
#### FileContext - Central State Management
|
||||
**Location**: `frontend/src/core/contexts/FileContext.tsx`
|
||||
- **Active files**: Currently loaded PDFs and their variants
|
||||
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
|
||||
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
|
||||
- **IndexedDB persistence**: File storage with thumbnail caching
|
||||
- **Preview system**: Tools can preview results (e.g., Split → Viewer → back to Split) without context pollution
|
||||
|
||||
**Critical**: All file operations go through FileContext. Don't bypass with direct file handling.
|
||||
|
||||
#### Processing Services
|
||||
- **enhancedPDFProcessingService**: Background PDF parsing and manipulation
|
||||
- **thumbnailGenerationService**: Web Worker-based with main-thread fallback
|
||||
- **fileStorage**: IndexedDB with LRU cache management
|
||||
|
||||
#### Memory Management Strategy
|
||||
**Why manual cleanup exists**: Large PDFs (up to 100GB+) through multiple tools accumulate:
|
||||
- PDF.js documents that need explicit .destroy() calls
|
||||
- Blob URLs from tool outputs that need revocation
|
||||
- Web Workers that need termination
|
||||
Without cleanup: browser crashes with memory leaks.
|
||||
|
||||
#### Tool Development
|
||||
|
||||
**Architecture**: Modular hook-based system with clear separation of concerns:
|
||||
|
||||
- **useToolOperation** (`frontend/src/core/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
|
||||
- Coordinates all tool operations with consistent interface
|
||||
- Integrates with FileContext for operation tracking
|
||||
- Handles validation, error handling, and UI state management
|
||||
|
||||
- **Supporting Hooks**:
|
||||
- **useToolState**: UI state management (loading, progress, error, files)
|
||||
- **useToolApiCalls**: HTTP requests and file processing
|
||||
- **useToolResources**: Blob URLs, thumbnails, ZIP downloads
|
||||
|
||||
- **Utilities**:
|
||||
- **toolErrorHandler**: Standardized error extraction and i18n support
|
||||
- **toolResponseProcessor**: API response handling (single/zip/custom)
|
||||
- **toolOperationTracker**: FileContext integration utilities
|
||||
|
||||
**Three Tool Patterns**:
|
||||
|
||||
**Pattern 1: Single-File Tools** (Individual processing)
|
||||
- Backend processes one file per API call
|
||||
- Set `multiFileEndpoint: false`
|
||||
- Examples: Compress, Rotate
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'compress',
|
||||
endpoint: '/api/v1/misc/compress-pdf',
|
||||
buildFormData: (params, file: File) => { /* single file */ },
|
||||
multiFileEndpoint: false,
|
||||
});
|
||||
```
|
||||
|
||||
**Pattern 2: Multi-File Tools** (Batch processing)
|
||||
- Backend accepts `MultipartFile[]` arrays in single API call
|
||||
- Set `multiFileEndpoint: true`
|
||||
- Examples: Split, Merge, Overlay
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'split',
|
||||
endpoint: '/api/v1/general/split-pages',
|
||||
buildFormData: (params, files: File[]) => { /* all files */ },
|
||||
multiFileEndpoint: true,
|
||||
filePrefix: 'split_',
|
||||
});
|
||||
```
|
||||
|
||||
**Pattern 3: Complex Tools** (Custom processing)
|
||||
- Tools with complex routing logic or non-standard processing
|
||||
- Provide `customProcessor` for full control
|
||||
- Examples: Convert, OCR
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'convert',
|
||||
customProcessor: async (params, files) => { /* custom logic */ },
|
||||
});
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- **No Timeouts**: Operations run until completion (supports 100GB+ files)
|
||||
- **Consistent**: All tools follow same pattern and interface
|
||||
- **Maintainable**: Single responsibility hooks, easy to test and modify
|
||||
- **i18n Ready**: Built-in internationalization support
|
||||
- **Type Safe**: Full TypeScript support with generic interfaces
|
||||
- **Memory Safe**: Automatic resource cleanup and blob URL management
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Project Structure
|
||||
- **Backend**: Spring Boot application
|
||||
- **Frontend**: React-based SPA in `/frontend` directory
|
||||
- **File Storage**: IndexedDB for client-side file persistence and thumbnails
|
||||
- **Internationalization**: JSON-based translations (converted from backend .properties)
|
||||
- **PDF Processing**: PDFBox for core PDF operations, LibreOffice for conversions, PDF.js for client-side rendering
|
||||
- **Security**: Spring Security with optional authentication (controlled by `DOCKER_ENABLE_SECURITY`)
|
||||
- **Configuration**: YAML-based configuration with environment variable overrides
|
||||
|
||||
### Controller Architecture
|
||||
- **API Controllers** (`src/main/java/.../controller/api/`): REST endpoints for PDF operations
|
||||
- Organized by function: converters, security, misc, pipeline
|
||||
- Follow pattern: `@RestController` + `@RequestMapping("/api/v1/...")`
|
||||
|
||||
### Key Components
|
||||
- **SPDFApplication.java**: Main application class with desktop UI and browser launching logic
|
||||
- **ConfigInitializer**: Handles runtime configuration and settings files
|
||||
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
|
||||
- **Security Layer**: Authentication, authorization, and user management (when enabled)
|
||||
|
||||
### Frontend Directory Structure
|
||||
The frontend is organized with a clear separation of concerns:
|
||||
|
||||
- **`frontend/src/core/`**: Main application code (shared, production-ready components)
|
||||
- **`core/components/`**: React components organized by feature
|
||||
- `core/components/tools/`: Individual PDF tool implementations
|
||||
- `core/components/viewer/`: PDF viewer components
|
||||
- `core/components/pageEditor/`: Page manipulation UI
|
||||
- `core/components/tooltips/`: Help tooltips for tools
|
||||
- `core/components/shared/`: Reusable UI components
|
||||
- **`core/contexts/`**: React Context providers
|
||||
- `FileContext.tsx`: Central file state management
|
||||
- `file/`: File reducer and selectors
|
||||
- `toolWorkflow/`: Tool workflow state
|
||||
- **`core/hooks/`**: Custom React hooks
|
||||
- `hooks/tools/`: Tool-specific operation hooks (one directory per tool)
|
||||
- `hooks/tools/shared/`: Shared hook utilities (useToolOperation, etc.)
|
||||
- **`core/constants/`**: Application constants and configuration
|
||||
- **`core/data/`**: Static data (tool taxonomy, etc.)
|
||||
- **`core/services/`**: Business logic services (PDF processing, storage, etc.)
|
||||
|
||||
- **`frontend/src/desktop/`**: Desktop-specific (Tauri) code
|
||||
- **`frontend/src/proprietary/`**: Proprietary/licensed features
|
||||
- **`frontend/src-tauri/`**: Tauri (Rust) native desktop application code
|
||||
- **`frontend/public/`**: Static assets served directly
|
||||
- `public/locales/`: Translation JSON files
|
||||
|
||||
### Component Architecture
|
||||
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
|
||||
- **Internationalization**:
|
||||
- Backend: `messages_*.properties` files
|
||||
- Frontend: JSON files in `frontend/public/locales/` (converted from .properties)
|
||||
- Conversion Script: `scripts/convert_properties_to_json.py`
|
||||
|
||||
### Configuration Modes
|
||||
- **Ultra-lite**: Basic PDF operations only
|
||||
- **Standard**: Full feature set
|
||||
- **Fat**: Pre-downloaded dependencies for air-gapped environments
|
||||
- **Security Mode**: Adds authentication, user management, and enterprise features
|
||||
|
||||
### Testing Strategy
|
||||
- **Integration Tests**: Cucumber tests in `testing/cucumber/`
|
||||
- **Docker Testing**: `test.sh` validates all Docker variants
|
||||
- **Manual Testing**: No unit tests currently - relies on UI and API testing
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Local Development**:
|
||||
- Backend: `./gradlew bootRun` (runs on localhost:8080)
|
||||
- Frontend: `cd frontend && npm run dev` (runs on localhost:5173, proxies to backend)
|
||||
2. **Docker Testing**: Use `./test.sh` before submitting PRs
|
||||
3. **Code Style**: Spotless enforces Google Java Format automatically
|
||||
4. **Translations**:
|
||||
- Backend: Use helper scripts in `/scripts` for multi-language updates
|
||||
- Frontend: Update JSON files in `frontend/public/locales/` or use conversion script
|
||||
5. **Documentation**: API docs auto-generated and available at `/swagger-ui/index.html`
|
||||
|
||||
## Frontend Architecture Status
|
||||
|
||||
- **Core Status**: React SPA architecture complete with multi-tool workflow support
|
||||
- **State Management**: FileContext handles all file operations and tool navigation
|
||||
- **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+)
|
||||
- **Tool Integration**: Modular hook architecture with `useToolOperation` orchestrator
|
||||
- Individual hooks: `useToolState`, `useToolApiCalls`, `useToolResources`
|
||||
- Utilities: `toolErrorHandler`, `toolResponseProcessor`, `toolOperationTracker`
|
||||
- Pattern: Each tool creates focused operation hook, UI consumes state/actions
|
||||
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
|
||||
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
|
||||
|
||||
## Translation Rules
|
||||
|
||||
- **CRITICAL**: Always update translations in `en-GB` only, never `en-US`
|
||||
- Translation files are located in `frontend/public/locales/`
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Java Version**: Minimum JDK 21, supports and recommends JDK 25
|
||||
- **Lombok**: Used extensively - ensure IDE plugin is installed
|
||||
- **File Persistence**:
|
||||
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
|
||||
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
|
||||
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
|
||||
- **Import Paths**: ALWAYS use `@app/*` for imports - never use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer
|
||||
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
|
||||
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
|
||||
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
|
||||
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
|
||||
- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation)
|
||||
- **Adding Tools**: See `ADDING_TOOLS.md` for complete guide to creating new PDF tools
|
||||
|
||||
## Communication Style
|
||||
- Be direct and to the point
|
||||
- No apologies or conversational filler
|
||||
- Answer questions directly without preamble
|
||||
- Explain reasoning concisely when asked
|
||||
- Avoid unnecessary elaboration
|
||||
|
||||
## Decision Making
|
||||
- Ask clarifying questions before making assumptions
|
||||
- Stop and ask when uncertain about project-specific details
|
||||
- Confirm approach before making structural changes
|
||||
- Request guidance on preferences (cross-platform vs specific tools, etc.)
|
||||
- Verify understanding of requirements before proceeding
|
||||
@@ -0,0 +1,228 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Common Development Commands
|
||||
|
||||
### Build and Test
|
||||
- **Build project**: `./gradlew clean build`
|
||||
- **Run locally**: `./gradlew bootRun`
|
||||
- **Full test suite**: `./test.sh` (builds all Docker variants and runs comprehensive tests)
|
||||
- **Code formatting**: `./gradlew spotlessApply` (runs automatically before compilation)
|
||||
|
||||
### Docker Development
|
||||
- **Build ultra-lite**: `docker build -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite .`
|
||||
- **Build standard**: `docker build -t stirlingtools/stirling-pdf:latest -f ./Dockerfile .`
|
||||
- **Build fat version**: `docker build -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .`
|
||||
- **Example compose files**: Located in `exampleYmlFiles/` directory
|
||||
|
||||
### Security Mode Development
|
||||
Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security features during development. This is required for testing the full version locally.
|
||||
|
||||
### Frontend Development
|
||||
- **Frontend dev server**: `cd frontend && npm run dev` (requires backend on localhost:8080)
|
||||
- **Tech Stack**: Vite + React + TypeScript + Mantine UI + TailwindCSS
|
||||
- **Proxy Configuration**: Vite proxies `/api/*` calls to backend (localhost:8080)
|
||||
- **Build Process**: DO NOT run build scripts manually - builds are handled by CI/CD pipelines
|
||||
- **Package Installation**: DO NOT run npm install commands - package management handled separately
|
||||
- **Deployment Options**:
|
||||
- **Desktop App**: `npm run tauri-build` (native desktop application)
|
||||
- **Web Server**: `npm run build` then serve dist/ folder
|
||||
- **Development**: `npm run tauri-dev` for desktop dev mode
|
||||
|
||||
#### Multi-Tool Workflow Architecture
|
||||
Frontend designed for **stateful document processing**:
|
||||
- Users upload PDFs once, then chain tools (split → merge → compress → view)
|
||||
- File state and processing results persist across tool switches
|
||||
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
|
||||
|
||||
#### FileContext - Central State Management
|
||||
**Location**: `src/contexts/FileContext.tsx`
|
||||
- **Active files**: Currently loaded PDFs and their variants
|
||||
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
|
||||
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
|
||||
- **IndexedDB persistence**: File storage with thumbnail caching
|
||||
- **Preview system**: Tools can preview results (e.g., Split → Viewer → back to Split) without context pollution
|
||||
|
||||
**Critical**: All file operations go through FileContext. Don't bypass with direct file handling.
|
||||
|
||||
#### Processing Services
|
||||
- **enhancedPDFProcessingService**: Background PDF parsing and manipulation
|
||||
- **thumbnailGenerationService**: Web Worker-based with main-thread fallback
|
||||
- **fileStorage**: IndexedDB with LRU cache management
|
||||
|
||||
#### Memory Management Strategy
|
||||
**Why manual cleanup exists**: Large PDFs (up to 100GB+) through multiple tools accumulate:
|
||||
- PDF.js documents that need explicit .destroy() calls
|
||||
- Blob URLs from tool outputs that need revocation
|
||||
- Web Workers that need termination
|
||||
Without cleanup: browser crashes with memory leaks.
|
||||
|
||||
#### Tool Development
|
||||
|
||||
**Architecture**: Modular hook-based system with clear separation of concerns:
|
||||
|
||||
- **useToolOperation** (`frontend/src/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
|
||||
- Coordinates all tool operations with consistent interface
|
||||
- Integrates with FileContext for operation tracking
|
||||
- Handles validation, error handling, and UI state management
|
||||
|
||||
- **Supporting Hooks**:
|
||||
- **useToolState**: UI state management (loading, progress, error, files)
|
||||
- **useToolApiCalls**: HTTP requests and file processing
|
||||
- **useToolResources**: Blob URLs, thumbnails, ZIP downloads
|
||||
|
||||
- **Utilities**:
|
||||
- **toolErrorHandler**: Standardized error extraction and i18n support
|
||||
- **toolResponseProcessor**: API response handling (single/zip/custom)
|
||||
- **toolOperationTracker**: FileContext integration utilities
|
||||
|
||||
**Three Tool Patterns**:
|
||||
|
||||
**Pattern 1: Single-File Tools** (Individual processing)
|
||||
- Backend processes one file per API call
|
||||
- Set `multiFileEndpoint: false`
|
||||
- Examples: Compress, Rotate
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'compress',
|
||||
endpoint: '/api/v1/misc/compress-pdf',
|
||||
buildFormData: (params, file: File) => { /* single file */ },
|
||||
multiFileEndpoint: false,
|
||||
});
|
||||
```
|
||||
|
||||
**Pattern 2: Multi-File Tools** (Batch processing)
|
||||
- Backend accepts `MultipartFile[]` arrays in single API call
|
||||
- Set `multiFileEndpoint: true`
|
||||
- Examples: Split, Merge, Overlay
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'split',
|
||||
endpoint: '/api/v1/general/split-pages',
|
||||
buildFormData: (params, files: File[]) => { /* all files */ },
|
||||
multiFileEndpoint: true,
|
||||
filePrefix: 'split_',
|
||||
});
|
||||
```
|
||||
|
||||
**Pattern 3: Complex Tools** (Custom processing)
|
||||
- Tools with complex routing logic or non-standard processing
|
||||
- Provide `customProcessor` for full control
|
||||
- Examples: Convert, OCR
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'convert',
|
||||
customProcessor: async (params, files) => { /* custom logic */ },
|
||||
});
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- **No Timeouts**: Operations run until completion (supports 100GB+ files)
|
||||
- **Consistent**: All tools follow same pattern and interface
|
||||
- **Maintainable**: Single responsibility hooks, easy to test and modify
|
||||
- **i18n Ready**: Built-in internationalization support
|
||||
- **Type Safe**: Full TypeScript support with generic interfaces
|
||||
- **Memory Safe**: Automatic resource cleanup and blob URL management
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Project Structure
|
||||
- **Backend**: Spring Boot application with Thymeleaf templating
|
||||
- **Frontend**: React-based SPA in `/frontend` directory (Thymeleaf templates fully replaced)
|
||||
- **File Storage**: IndexedDB for client-side file persistence and thumbnails
|
||||
- **Internationalization**: JSON-based translations (converted from backend .properties)
|
||||
- **PDF Processing**: PDFBox for core PDF operations, LibreOffice for conversions, PDF.js for client-side rendering
|
||||
- **Security**: Spring Security with optional authentication (controlled by `DOCKER_ENABLE_SECURITY`)
|
||||
- **Configuration**: YAML-based configuration with environment variable overrides
|
||||
|
||||
### Controller Architecture
|
||||
- **API Controllers** (`src/main/java/.../controller/api/`): REST endpoints for PDF operations
|
||||
- Organized by function: converters, security, misc, pipeline
|
||||
- Follow pattern: `@RestController` + `@RequestMapping("/api/v1/...")`
|
||||
- **Web Controllers** (`src/main/java/.../controller/web/`): Serve Thymeleaf templates
|
||||
- Pattern: `@Controller` + return template names
|
||||
|
||||
### Key Components
|
||||
- **SPDFApplication.java**: Main application class with desktop UI and browser launching logic
|
||||
- **ConfigInitializer**: Handles runtime configuration and settings files
|
||||
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
|
||||
- **Security Layer**: Authentication, authorization, and user management (when enabled)
|
||||
|
||||
### Component Architecture
|
||||
- **React Components**: Located in `frontend/src/components/` and `frontend/src/tools/`
|
||||
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
|
||||
- **Internationalization**:
|
||||
- Backend: `messages_*.properties` files
|
||||
- Frontend: JSON files in `frontend/public/locales/` (converted from .properties)
|
||||
- Conversion Script: `scripts/convert_properties_to_json.py`
|
||||
|
||||
### Configuration Modes
|
||||
- **Ultra-lite**: Basic PDF operations only
|
||||
- **Standard**: Full feature set
|
||||
- **Fat**: Pre-downloaded dependencies for air-gapped environments
|
||||
- **Security Mode**: Adds authentication, user management, and enterprise features
|
||||
|
||||
### Testing Strategy
|
||||
- **Integration Tests**: Cucumber tests in `testing/cucumber/`
|
||||
- **Docker Testing**: `test.sh` validates all Docker variants
|
||||
- **Manual Testing**: No unit tests currently - relies on UI and API testing
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Local Development**:
|
||||
- Backend: `./gradlew bootRun` (runs on localhost:8080)
|
||||
- Frontend: `cd frontend && npm run dev` (runs on localhost:5173, proxies to backend)
|
||||
2. **Docker Testing**: Use `./test.sh` before submitting PRs
|
||||
3. **Code Style**: Spotless enforces Google Java Format automatically
|
||||
4. **Translations**:
|
||||
- Backend: Use helper scripts in `/scripts` for multi-language updates
|
||||
- Frontend: Update JSON files in `frontend/public/locales/` or use conversion script
|
||||
5. **Documentation**: API docs auto-generated and available at `/swagger-ui/index.html`
|
||||
|
||||
## Frontend Architecture Status
|
||||
|
||||
- **Core Status**: React SPA architecture complete with multi-tool workflow support
|
||||
- **State Management**: FileContext handles all file operations and tool navigation
|
||||
- **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+)
|
||||
- **Tool Integration**: Modular hook architecture with `useToolOperation` orchestrator
|
||||
- Individual hooks: `useToolState`, `useToolApiCalls`, `useToolResources`
|
||||
- Utilities: `toolErrorHandler`, `toolResponseProcessor`, `toolOperationTracker`
|
||||
- Pattern: Each tool creates focused operation hook, UI consumes state/actions
|
||||
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
|
||||
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
|
||||
|
||||
## Translation Rules
|
||||
|
||||
- **CRITICAL**: Always update translations in `en-GB` only, never `en-US`
|
||||
- Translation files are located in `frontend/public/locales/`
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21
|
||||
- **Lombok**: Used extensively - ensure IDE plugin is installed
|
||||
- **Desktop Mode**: Set `STIRLING_PDF_DESKTOP_UI=true` for desktop application mode
|
||||
- **File Persistence**:
|
||||
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
|
||||
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
|
||||
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
|
||||
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
|
||||
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
|
||||
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
|
||||
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
|
||||
- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation)
|
||||
- **Adding Tools**: See `ADDING_TOOLS.md` for complete guide to creating new PDF tools
|
||||
|
||||
## Communication Style
|
||||
- Be direct and to the point
|
||||
- No apologies or conversational filler
|
||||
- Answer questions directly without preamble
|
||||
- Explain reasoning concisely when asked
|
||||
- Avoid unnecessary elaboration
|
||||
|
||||
## Decision Making
|
||||
- Ask clarifying questions before making assumptions
|
||||
- Stop and ask when uncertain about project-specific details
|
||||
- Confirm approach before making structural changes
|
||||
- Request guidance on preferences (cross-platform vs specific tools, etc.)
|
||||
- Verify understanding of requirements before proceeding
|
||||
+165
-6
@@ -2,7 +2,7 @@
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
Stirling-PDF is a robust, locally hosted, web-based PDF manipulation tool. **Stirling 2.0** represents a complete frontend rewrite with a modern React SPA (Single Page Application).
|
||||
Stirling-PDF is a robust, locally hosted, web-based PDF manipulation tool. **Stirling 2.0** represents a complete frontend rewrite, replacing the legacy Thymeleaf-based UI with a modern React SPA (Single Page Application).
|
||||
|
||||
This guide focuses on developing for Stirling 2.0, including both the React frontend and Spring Boot backend development workflows.
|
||||
|
||||
@@ -11,7 +11,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
|
||||
**Stirling 2.0** is built using:
|
||||
|
||||
**Backend:**
|
||||
- Spring Boot (Java 21+, JDK 25 recommended)
|
||||
- Spring Boot (Java 17+, JDK 21 recommended)
|
||||
- PDFBox for core PDF operations
|
||||
- LibreOffice for document conversions
|
||||
- qpdf for PDF optimization
|
||||
@@ -38,13 +38,16 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
|
||||
- PDF file association support
|
||||
- Self-contained JRE bundling with JLink
|
||||
|
||||
**Legacy (reference only during development):**
|
||||
- Thymeleaf templates (being completely replaced in 2.0)
|
||||
|
||||
## 3. Development Environment Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker
|
||||
- Git
|
||||
- Java JDK 21 or later (JDK 25 recommended)
|
||||
- Java JDK 17 or later (JDK 21 recommended)
|
||||
- Node.js 18+ and npm (required for frontend development)
|
||||
- Gradle 7.0 or later (Included within the repo)
|
||||
- Rust and Cargo (required for Tauri desktop app development)
|
||||
@@ -59,7 +62,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
|
||||
cd Stirling-PDF
|
||||
```
|
||||
|
||||
2. Install Docker and JDK 21 (or JDK 25 recommended) if not already installed.
|
||||
2. Install Docker and JDK17 if not already installed.
|
||||
|
||||
3. Install a recommended Java IDE such as Eclipse, IntelliJ, or VSCode
|
||||
1. Only VSCode
|
||||
@@ -97,6 +100,9 @@ Stirling 2.0 uses client-side file storage:
|
||||
- **PDF.js**: Handles client-side PDF rendering and processing
|
||||
- **URL Parameters**: Support for deep linking and tool state persistence
|
||||
|
||||
### Legacy Code Reference
|
||||
The existing Thymeleaf templates remain in the codebase during development as reference material but will be completely removed for the 2.0 release.
|
||||
|
||||
### Tauri Desktop App Development
|
||||
Stirling-PDF can be packaged as a cross-platform desktop application using Tauri with PDF file association support and bundled JRE.
|
||||
See [the frontend README](frontend/README.md#tauri) for build instructions.
|
||||
@@ -148,6 +154,7 @@ Stirling-PDF/
|
||||
│ │ │ ├── css/
|
||||
│ │ │ ├── js/
|
||||
│ │ │ └── pdfjs/
|
||||
│ │ └── templates/ # Legacy Thymeleaf templates (reference only)
|
||||
│ └── test/
|
||||
├── testing/ # Cucumber and integration tests
|
||||
│ └── cucumber/ # Cucumber test files
|
||||
@@ -302,6 +309,7 @@ For quick iterations and development of Java backend, JavaScript, and UI compone
|
||||
- RESTful API endpoints
|
||||
- JavaScript functionality
|
||||
- User interface components and styling
|
||||
- Thymeleaf templates
|
||||
|
||||
To run Stirling-PDF locally:
|
||||
|
||||
@@ -393,7 +401,7 @@ Remember to test your changes thoroughly to ensure they don't break any existing
|
||||
|
||||
### React Component Development (Stirling 2.0)
|
||||
|
||||
For Stirling 2.0, new features are built as React components:
|
||||
For Stirling 2.0, new features are built as React components instead of Thymeleaf templates:
|
||||
|
||||
#### Creating a New Tool Component
|
||||
|
||||
@@ -440,6 +448,61 @@ For Stirling 2.0, new features are built as React components:
|
||||
3. **Register in Tool Picker:**
|
||||
Update the tool picker component to include the new tool with proper routing and URL parameter support.
|
||||
|
||||
### Legacy Reference: Overview of Thymeleaf
|
||||
|
||||
Thymeleaf is a server-side Java HTML template engine. It is used in Stirling-PDF to render dynamic web pages. Thymeleaf integrates heavily with Spring Boot.
|
||||
|
||||
### Thymeleaf overview
|
||||
|
||||
In Stirling-PDF, Thymeleaf is used to create HTML templates that are rendered on the server side. These templates are located in the `stirling-pdf/src/main/resources/templates` directory. Thymeleaf templates use a combination of HTML and special Thymeleaf attributes to dynamically generate content.
|
||||
|
||||
Some examples of this are:
|
||||
|
||||
```html
|
||||
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
||||
```
|
||||
or
|
||||
```html
|
||||
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||
```
|
||||
|
||||
Where it uses the `th:block`, `th:` indicating it's a special Thymeleaf element to be used server-side in generating the HTML, and block being the actual element type.
|
||||
In this case, we are inserting the `navbar` entry within the `fragments/navbar.html` fragment into the `th:block` element.
|
||||
|
||||
They can be more complex, such as:
|
||||
|
||||
```html
|
||||
<th:block th:insert="~{fragments/common :: head(title=#{pageExtracter.title}, header=#{pageExtracter.header})}"></th:block>
|
||||
```
|
||||
|
||||
Which is the same as above but passes the parameters title and header into the fragment `common.html` to be used in its HTML generation.
|
||||
|
||||
Thymeleaf can also be used to loop through objects or pass things from the Java side into the HTML side.
|
||||
|
||||
```java
|
||||
@GetMapping
|
||||
public String newFeaturePage(Model model) {
|
||||
model.addAttribute("exampleData", exampleData);
|
||||
return "new-feature";
|
||||
}
|
||||
```
|
||||
|
||||
In the above example, if exampleData is a list of plain java objects of class Person and within it, you had id, name, age, etc. You can reference it like so
|
||||
|
||||
```html
|
||||
<tbody>
|
||||
<!-- Use th:each to iterate over the list -->
|
||||
<tr th:each="person : ${exampleData}">
|
||||
<td th:text="${person.id}"></td>
|
||||
<td th:text="${person.name}"></td>
|
||||
<td th:text="${person.age}"></td>
|
||||
<td th:text="${person.email}"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
```
|
||||
|
||||
This would generate n entries of tr for each person in exampleData
|
||||
|
||||
### Adding a New Feature to the Backend (API)
|
||||
|
||||
1. **Create a New Controller:**
|
||||
@@ -464,7 +527,7 @@ For Stirling 2.0, new features are built as React components:
|
||||
@GetMapping
|
||||
@Operation(summary = "New Feature", description = "This is a new feature endpoint.")
|
||||
public String newFeature() {
|
||||
return "NewFeatureResponse";
|
||||
return "NewFeatureResponse"; // This refers to the NewFeatureResponse.html template presenting the user with the generated html from that file when they navigate to /api/v1/new-feature
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -519,6 +582,91 @@ For Stirling 2.0, new features are built as React components:
|
||||
}
|
||||
```
|
||||
|
||||
### Adding a New Feature to the Frontend (UI)
|
||||
|
||||
1. **Create a New Thymeleaf Template:**
|
||||
- Create a new HTML file in the `stirling-pdf/src/main/resources/templates` directory.
|
||||
- Use Thymeleaf attributes to dynamically generate content.
|
||||
- Use `extract-page.html` as a base example for the HTML template, which is useful to ensure importing of the general layout, navbar, and footer.
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
|
||||
<head>
|
||||
<th:block th:insert="~{fragments/common :: head(title=#{newFeature.title}, header=#{newFeature.header})}"></th:block>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="page-container">
|
||||
<div id="content-wrap">
|
||||
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
||||
<br><br>
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 bg-card">
|
||||
<div class="tool-header">
|
||||
<span class="material-symbols-rounded tool-header-icon organize">upload</span>
|
||||
<span class="tool-header-text" th:text="#{newFeature.header}"></span>
|
||||
</div>
|
||||
<form th:action="@{'/api/v1/new-feature'}" method="post" enctype="multipart/form-data">
|
||||
<div th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='application/pdf')}"></div>
|
||||
<input type="hidden" id="customMode" name="customMode" value="">
|
||||
<div class="mb-3">
|
||||
<label for="featureInput" th:text="#{newFeature.prompt}"></label>
|
||||
<input type="text" class="form-control" id="featureInput" name="featureInput" th:placeholder="#{newFeature.placeholder}" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submitBtn" class="btn btn-primary" th:text="#{newFeature.submit}"></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
2. **Create a New Controller for the UI:**
|
||||
- Create a new Java class in the `stirling-pdf/src/main/java/stirling/software/SPDF/controller/ui` directory.
|
||||
- Annotate the class with `@Controller` and `@RequestMapping` to define the UI endpoint.
|
||||
|
||||
```java
|
||||
package stirling.software.SPDF.controller.ui;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import stirling.software.SPDF.service.NewFeatureService;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/new-feature")
|
||||
public class NewFeatureUIController {
|
||||
|
||||
@Autowired
|
||||
private NewFeatureService newFeatureService;
|
||||
|
||||
@GetMapping
|
||||
public String newFeaturePage(Model model) {
|
||||
model.addAttribute("newFeatureData", newFeatureService.getNewFeatureData());
|
||||
return "new-feature";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Update the Navigation Bar:**
|
||||
- Add a link to the new feature page in the navigation bar.
|
||||
- Update the `stirling-pdf/src/main/resources/templates/fragments/navbar.html` file.
|
||||
|
||||
```html
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" th:href="@{'/new-feature'}">New Feature</a>
|
||||
</li>
|
||||
```
|
||||
|
||||
## Adding New Translations to Existing Language Files in Stirling-PDF
|
||||
|
||||
When adding a new feature or modifying existing ones in Stirling-PDF, you'll need to add new translation entries to the existing language files. Here's a step-by-step guide:
|
||||
@@ -548,4 +696,15 @@ pdfSplitter.input.pages=Enter page numbers to split
|
||||
|
||||
Add these entries to the default GB language file and any others you wish, translating the values as appropriate for each language.
|
||||
|
||||
### 3. Use Translations in Thymeleaf Templates
|
||||
|
||||
In your Thymeleaf templates, use the `#{key}` syntax to reference the new translations:
|
||||
|
||||
```html
|
||||
<h1 th:text="#{pdfSplitter.title}">PDF Splitter</h1>
|
||||
<p th:text="#{pdfSplitter.description}">Split your PDF into multiple documents</p>
|
||||
<input type="text" th:placeholder="#{pdfSplitter.input.pages}">
|
||||
<button th:text="#{pdfSplitter.button.split}">Split PDF</button>
|
||||
```
|
||||
|
||||
Remember, never hard-code text in your templates or Java code. Always use translation keys to ensure proper localization.
|
||||
|
||||
@@ -6,14 +6,10 @@ Portions of this software are licensed as follows:
|
||||
|
||||
* All content that resides under the "app/proprietary/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "app/proprietary/LICENSE".
|
||||
* All content that resides under the "engine/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "engine/LICENSE".
|
||||
* All content that resides under the "frontend/src/proprietary/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/proprietary/LICENSE".
|
||||
* All content that resides under the "frontend/src/desktop/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/desktop/LICENSE".
|
||||
* All content that resides under the "frontend/src/saas/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/saas/LICENSE".
|
||||
* Content outside of the above mentioned directories or restrictions above is
|
||||
available under the MIT License as defined below.
|
||||
|
||||
|
||||
@@ -96,22 +96,6 @@
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "MPL 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Mozilla Public License, Version 2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "CDDL+GPL License"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "BSD"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "UnboundID SCIM2 SDK Free Use License"
|
||||
|
||||
+9
-13
@@ -5,10 +5,10 @@ bootRun {
|
||||
spotless {
|
||||
java {
|
||||
target 'src/**/java/**/*.java'
|
||||
targetExclude 'src/main/java/org/apache/**'
|
||||
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
||||
|
||||
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
||||
toggleOffOn()
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
@@ -27,26 +27,22 @@ spotless {
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
api 'com.google.guava:guava:33.4.8-jre'
|
||||
api 'org.springframework.boot:spring-boot-starter-webmvc'
|
||||
api 'org.springframework.boot:spring-boot-starter-aspectj'
|
||||
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260102.1'
|
||||
api 'org.springframework.boot:spring-boot-starter-web'
|
||||
api 'org.springframework.boot:spring-boot-starter-aop'
|
||||
// api 'org.springframework.boot:spring-boot-starter-thymeleaf' // Deprecated - UI moved to React frontend
|
||||
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1'
|
||||
api 'com.fathzer:javaluator:3.0.6'
|
||||
api 'com.posthog.java:posthog:1.2.0'
|
||||
api 'org.apache.commons:commons-lang3:3.20.0'
|
||||
api 'org.apache.commons:commons-lang3:3.19.0'
|
||||
api 'com.drewnoakes:metadata-extractor:2.19.0' // Image metadata extractor
|
||||
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
|
||||
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
|
||||
api "org.apache.pdfbox:pdfbox-io:$pdfboxVersion"
|
||||
api "org.apache.pdfbox:xmpbox:$pdfboxVersion"
|
||||
api "org.apache.pdfbox:preflight:$pdfboxVersion"
|
||||
api 'com.github.junrar:junrar:7.5.8' // RAR archive support for CBR files
|
||||
api 'com.github.junrar:junrar:7.5.5' // RAR archive support for CBR files
|
||||
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
|
||||
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.1"
|
||||
// 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.snakeyaml:snakeyaml-engine:2.10'
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13"
|
||||
api 'jakarta.mail:jakarta.mail-api:2.1.5'
|
||||
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
|
||||
}
|
||||
|
||||
+45
-102
@@ -103,9 +103,8 @@ public class EndpointConfiguration {
|
||||
|
||||
// Rule 2: Functional-group override - check if endpoint belongs to any disabled functional
|
||||
// group
|
||||
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
|
||||
String group = entry.getKey();
|
||||
if (disabledGroups.contains(group) && entry.getValue().contains(endpoint)) {
|
||||
for (String group : endpointGroups.keySet()) {
|
||||
if (disabledGroups.contains(group) && endpointGroups.get(group).contains(endpoint)) {
|
||||
// Skip tool groups (qpdf, OCRmyPDF, Ghostscript, LibreOffice, etc.)
|
||||
if (!isToolGroup(group)) {
|
||||
log.debug(
|
||||
@@ -132,11 +131,10 @@ public class EndpointConfiguration {
|
||||
|
||||
// Rule 4: Single-dependency check - if no alternatives defined, check if endpoint belongs
|
||||
// to any disabled tool groups
|
||||
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
|
||||
String group = entry.getKey();
|
||||
for (String group : endpointGroups.keySet()) {
|
||||
if (isToolGroup(group)
|
||||
&& disabledGroups.contains(group)
|
||||
&& entry.getValue().contains(endpoint)) {
|
||||
&& endpointGroups.get(group).contains(endpoint)) {
|
||||
log.debug(
|
||||
"isEndpointEnabled('{}') -> false (single tool group '{}' disabled, no alternatives)",
|
||||
original,
|
||||
@@ -158,19 +156,19 @@ public class EndpointConfiguration {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rule 2: For tool groups, they're enabled unless explicitly disabled (handled above)
|
||||
if (isToolGroup(group)) {
|
||||
log.debug("isGroupEnabled('{}') -> true (tool group not disabled)", group);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Rule 3: For functional groups, check if all endpoints are enabled
|
||||
Set<String> endpoints = endpointGroups.get(group);
|
||||
if (endpoints == null || endpoints.isEmpty()) {
|
||||
log.debug("isGroupEnabled('{}') -> false (no endpoints)", group);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rule 2: For functional groups, check if all endpoints are enabled
|
||||
// Rule 3: For tool groups, they're enabled unless explicitly disabled (handled above)
|
||||
if (isToolGroup(group)) {
|
||||
log.debug("isGroupEnabled('{}') -> true (tool group not disabled)", group);
|
||||
return true;
|
||||
}
|
||||
|
||||
// For functional groups, check each endpoint individually
|
||||
for (String endpoint : endpoints) {
|
||||
if (!isEndpointEnabledDirectly(endpoint)) {
|
||||
@@ -305,23 +303,22 @@ public class EndpointConfiguration {
|
||||
// Adding endpoints to "PageOps" group
|
||||
addEndpointToGroup("PageOps", "remove-pages");
|
||||
addEndpointToGroup("PageOps", "merge-pdfs");
|
||||
addEndpointToGroup("PageOps", "split-pages");
|
||||
addEndpointToGroup("PageOps", "rearrange-pages");
|
||||
addEndpointToGroup("PageOps", "split-pdfs");
|
||||
addEndpointToGroup("PageOps", "pdf-organizer");
|
||||
addEndpointToGroup("PageOps", "rotate-pdf");
|
||||
addEndpointToGroup("PageOps", "multi-page-layout");
|
||||
addEndpointToGroup("PageOps", "booklet-imposition");
|
||||
addEndpointToGroup("PageOps", "scale-pages");
|
||||
addEndpointToGroup("PageOps", "crop");
|
||||
addEndpointToGroup("PageOps", "extract-page");
|
||||
addEndpointToGroup("PageOps", "pdf-to-single-page");
|
||||
addEndpointToGroup("PageOps", "auto-split-pdf");
|
||||
addEndpointToGroup("PageOps", "split-by-size-or-count");
|
||||
addEndpointToGroup("PageOps", "overlay-pdf");
|
||||
addEndpointToGroup("PageOps", "split-pdf-by-sections");
|
||||
addEndpointToGroup("PageOps", "split-pdf-by-chapters");
|
||||
addEndpointToGroup("PageOps", "add-page-numbers");
|
||||
addEndpointToGroup("PageOps", "extract-pages");
|
||||
|
||||
// Adding endpoints to "Convert" group (Frontend has 15 convert endpoints)
|
||||
// Adding endpoints to "Convert" group
|
||||
addEndpointToGroup("Convert", "pdf-to-img");
|
||||
addEndpointToGroup("Convert", "img-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-pdfa");
|
||||
@@ -337,16 +334,10 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Convert", "pdf-to-csv");
|
||||
addEndpointToGroup("Convert", "pdf-to-markdown");
|
||||
addEndpointToGroup("Convert", "eml-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-epub");
|
||||
// Backend-only endpoints (not in frontend tool registry)
|
||||
addEndpointToGroup("Convert", "pdf-to-vector");
|
||||
addEndpointToGroup("Convert", "vector-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-video");
|
||||
addEndpointToGroup("Convert", "cbz-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-cbz");
|
||||
addEndpointToGroup("Convert", "pdf-to-json");
|
||||
addEndpointToGroup("Convert", "json-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-rtf");
|
||||
|
||||
// Adding endpoints to "Security" group
|
||||
addEndpointToGroup("Security", "add-password");
|
||||
@@ -357,59 +348,50 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Security", "remove-cert-sign");
|
||||
addEndpointToGroup("Security", "sanitize-pdf");
|
||||
addEndpointToGroup("Security", "auto-redact");
|
||||
addEndpointToGroup("Security", "validate-signature");
|
||||
addEndpointToGroup("Security", "add-stamp");
|
||||
addEndpointToGroup("Security", "unlock-pdf-forms");
|
||||
// Backend-only endpoints (not in frontend tool registry endpoints)
|
||||
addEndpointToGroup("Security", "redact");
|
||||
addEndpointToGroup("Security", "verify-pdf");
|
||||
addEndpointToGroup("Security", "validate-signature");
|
||||
addEndpointToGroup("Security", "stamp");
|
||||
addEndpointToGroup("Security", "sign");
|
||||
|
||||
// Adding endpoints to "Other" group
|
||||
addEndpointToGroup("Other", "ocr-pdf");
|
||||
addEndpointToGroup("Other", "add-image");
|
||||
addEndpointToGroup("Other", "extract-images");
|
||||
addEndpointToGroup("Other", "update-metadata");
|
||||
addEndpointToGroup("Other", "change-metadata");
|
||||
addEndpointToGroup("Other", "flatten");
|
||||
addEndpointToGroup("Other", "unlock-pdf-forms");
|
||||
addEndpointToGroup("Other", REMOVE_BLANKS);
|
||||
addEndpointToGroup("Other", "remove-annotations");
|
||||
addEndpointToGroup("Other", "get-info-on-pdf");
|
||||
addEndpointToGroup("Other", "add-attachments");
|
||||
addEndpointToGroup("Other", "replace-invert-pdf");
|
||||
addEndpointToGroup("Other", "edit-table-of-contents");
|
||||
addEndpointToGroup("Other", "text-editor-pdf");
|
||||
// Backend-only endpoints (not in frontend tool registry endpoints)
|
||||
addEndpointToGroup("Other", "add-image");
|
||||
addEndpointToGroup("Other", "compare");
|
||||
addEndpointToGroup("Other", "add-page-numbers");
|
||||
addEndpointToGroup("Other", "get-info-on-pdf");
|
||||
addEndpointToGroup("Other", "remove-image-pdf");
|
||||
addEndpointToGroup("Other", "add-attachments");
|
||||
addEndpointToGroup("Other", "view-pdf");
|
||||
addEndpointToGroup("Other", "replace-and-invert-color-pdf");
|
||||
addEndpointToGroup("Other", "multi-tool");
|
||||
|
||||
// Adding form-related endpoints to "Other" group
|
||||
addEndpointToGroup("Other", "fields");
|
||||
addEndpointToGroup("Other", "modify-fields");
|
||||
addEndpointToGroup("Other", "delete-fields");
|
||||
addEndpointToGroup("Other", "fill");
|
||||
|
||||
// Adding endpoints to "Advance" group
|
||||
addEndpointToGroup("Advance", "adjust-contrast");
|
||||
addEndpointToGroup("Advance", "compress-pdf");
|
||||
addEndpointToGroup("Advance", "extract-image-scans");
|
||||
addEndpointToGroup("Advance", "repair");
|
||||
addEndpointToGroup("Advance", "auto-rename");
|
||||
addEndpointToGroup("Advance", "pipeline");
|
||||
addEndpointToGroup("Advance", "scanner-effect");
|
||||
addEndpointToGroup("Advance", "auto-split-pdf");
|
||||
addEndpointToGroup("Advance", "show-javascript");
|
||||
addEndpointToGroup("Advance", "split-by-size-or-count");
|
||||
addEndpointToGroup("Advance", "overlay-pdf");
|
||||
// Backend-only endpoints
|
||||
addEndpointToGroup("Advance", "adjust-contrast");
|
||||
|
||||
// Adding endpoints to "Automation" group
|
||||
addEndpointToGroup("Automation", "handleData");
|
||||
addEndpointToGroup("Automation", "automate"); // Alias for handleData (user-friendly name)
|
||||
addEndpointToGroup("Automation", "pipeline");
|
||||
|
||||
// Adding endpoints to "DeveloperTools" group
|
||||
addEndpointToGroup("DeveloperTools", "show-javascript");
|
||||
|
||||
// Adding endpoints to "DeveloperDocs" group (fake endpoints for link-only tools)
|
||||
addEndpointToGroup("DeveloperDocs", "dev-api-docs");
|
||||
addEndpointToGroup("DeveloperDocs", "dev-folder-scanning-docs");
|
||||
addEndpointToGroup("DeveloperDocs", "dev-sso-guide-docs");
|
||||
addEndpointToGroup("DeveloperDocs", "dev-airgapped-docs");
|
||||
addEndpointToGroup("Advance", "split-pdf-by-sections");
|
||||
addEndpointToGroup("Advance", "edit-table-of-contents");
|
||||
addEndpointToGroup("Advance", "split-pdf-by-chapters");
|
||||
|
||||
// CLI
|
||||
addEndpointToGroup("CLI", "compress-pdf");
|
||||
@@ -450,8 +432,8 @@ public class EndpointConfiguration {
|
||||
// Java
|
||||
addEndpointToGroup("Java", "merge-pdfs");
|
||||
addEndpointToGroup("Java", "remove-pages");
|
||||
addEndpointToGroup("Java", "split-pages");
|
||||
addEndpointToGroup("Java", "rearrange-pages");
|
||||
addEndpointToGroup("Java", "split-pdfs");
|
||||
addEndpointToGroup("Java", "pdf-organizer");
|
||||
addEndpointToGroup("Java", "rotate-pdf");
|
||||
addEndpointToGroup("Java", "pdf-to-img");
|
||||
addEndpointToGroup("Java", "img-to-pdf");
|
||||
@@ -459,10 +441,9 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "remove-password");
|
||||
addEndpointToGroup("Java", "change-permissions");
|
||||
addEndpointToGroup("Java", "add-watermark");
|
||||
addEndpointToGroup("Java", "add-stamp");
|
||||
addEndpointToGroup("Java", "add-image");
|
||||
addEndpointToGroup("Java", "extract-images");
|
||||
addEndpointToGroup("Java", "update-metadata");
|
||||
addEndpointToGroup("Java", "change-metadata");
|
||||
addEndpointToGroup("Java", "cert-sign");
|
||||
addEndpointToGroup("Java", "remove-cert-sign");
|
||||
addEndpointToGroup("Java", "multi-page-layout");
|
||||
@@ -474,6 +455,7 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "sanitize-pdf");
|
||||
addEndpointToGroup("Java", "crop");
|
||||
addEndpointToGroup("Java", "get-info-on-pdf");
|
||||
addEndpointToGroup("Java", "extract-page");
|
||||
addEndpointToGroup("Java", "pdf-to-single-page");
|
||||
addEndpointToGroup("Java", "markdown-to-pdf");
|
||||
addEndpointToGroup("Java", "show-javascript");
|
||||
@@ -483,10 +465,9 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "split-by-size-or-count");
|
||||
addEndpointToGroup("Java", "overlay-pdf");
|
||||
addEndpointToGroup("Java", "split-pdf-by-sections");
|
||||
addEndpointToGroup("Java", "split-pdf-by-chapters");
|
||||
addEndpointToGroup("Java", REMOVE_BLANKS);
|
||||
addEndpointToGroup("Java", "remove-annotations");
|
||||
addEndpointToGroup("Java", "pdf-to-text");
|
||||
addEndpointToGroup("Java", "remove-image-pdf");
|
||||
addEndpointToGroup("Java", "pdf-to-markdown");
|
||||
addEndpointToGroup("Java", "add-attachments");
|
||||
addEndpointToGroup("Java", "compress-pdf");
|
||||
@@ -494,24 +475,13 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "pdf-to-cbz");
|
||||
addEndpointToGroup("Java", "pdf-to-json");
|
||||
addEndpointToGroup("Java", "json-to-pdf");
|
||||
addEndpointToGroup("Java", "pdf-to-video");
|
||||
addEndpointToGroup("Java", "verify-pdf");
|
||||
addEndpointToGroup("Java", "flatten");
|
||||
addEndpointToGroup("Java", "unlock-pdf-forms");
|
||||
addEndpointToGroup("Java", "validate-signature");
|
||||
addEndpointToGroup("Java", "text-editor-pdf");
|
||||
addEndpointToGroup("Java", "edit-table-of-contents");
|
||||
addEndpointToGroup("Java", "pdf-to-epub");
|
||||
addEndpointToGroup("Java", "eml-to-pdf");
|
||||
addEndpointToGroup("Java", "handleData");
|
||||
addEndpointToGroup("rar", "pdf-to-cbr");
|
||||
|
||||
// Javascript
|
||||
addEndpointToGroup("Javascript", "rearrange-pages");
|
||||
addEndpointToGroup("Javascript", "pdf-organizer");
|
||||
addEndpointToGroup("Javascript", "sign");
|
||||
addEndpointToGroup("Javascript", "compare");
|
||||
addEndpointToGroup("Javascript", "adjust-contrast");
|
||||
addEndpointToGroup("Javascript", "text-editor-pdf");
|
||||
|
||||
/* qpdf */
|
||||
addEndpointToGroup("qpdf", "repair");
|
||||
@@ -520,14 +490,6 @@ public class EndpointConfiguration {
|
||||
/* Ghostscript */
|
||||
addEndpointToGroup("Ghostscript", "repair");
|
||||
addEndpointToGroup("Ghostscript", "compress-pdf");
|
||||
addEndpointToGroup("Ghostscript", "crop");
|
||||
addEndpointToGroup("Ghostscript", "replace-invert-pdf");
|
||||
addEndpointToGroup("Ghostscript", "scanner-effect");
|
||||
addEndpointToGroup("Ghostscript", "pdf-to-vector");
|
||||
addEndpointToGroup("Ghostscript", "vector-to-pdf");
|
||||
|
||||
/* ImageMagick */
|
||||
addEndpointToGroup("ImageMagick", "compress-pdf");
|
||||
|
||||
/* tesseract */
|
||||
addEndpointToGroup("tesseract", "ocr-pdf");
|
||||
@@ -541,8 +503,6 @@ public class EndpointConfiguration {
|
||||
addEndpointAlternative("compress-pdf", "qpdf");
|
||||
addEndpointAlternative("compress-pdf", "Ghostscript");
|
||||
addEndpointAlternative("compress-pdf", "Java");
|
||||
addEndpointAlternative("crop", "Ghostscript");
|
||||
addEndpointAlternative("crop", "Java");
|
||||
addEndpointAlternative("ocr-pdf", "tesseract");
|
||||
addEndpointAlternative("ocr-pdf", "OCRmyPDF");
|
||||
|
||||
@@ -565,15 +525,9 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Weasyprint", "markdown-to-pdf");
|
||||
addEndpointToGroup("Weasyprint", "eml-to-pdf");
|
||||
|
||||
// veraPDF dependent endpoints
|
||||
addEndpointToGroup("veraPDF", "verify-pdf");
|
||||
|
||||
// Pdftohtml dependent endpoints
|
||||
addEndpointToGroup("Pdftohtml", "pdf-to-html");
|
||||
addEndpointToGroup("Pdftohtml", "pdf-to-markdown");
|
||||
|
||||
// Calibre dependent endpoints
|
||||
addEndpointToGroup("Calibre", "pdf-to-epub");
|
||||
}
|
||||
|
||||
private void processEnvironmentConfigs() {
|
||||
@@ -597,7 +551,7 @@ public class EndpointConfiguration {
|
||||
disableGroup("enterprise");
|
||||
}
|
||||
|
||||
if (!applicationProperties.getSystem().isEnableUrlToPDF()) {
|
||||
if (!applicationProperties.getSystem().getEnableUrlToPDF()) {
|
||||
disableEndpoint("url-to-pdf");
|
||||
}
|
||||
}
|
||||
@@ -606,12 +560,6 @@ public class EndpointConfiguration {
|
||||
return endpointGroups.getOrDefault(group, new HashSet<>());
|
||||
}
|
||||
|
||||
public Set<String> getAllEndpoints() {
|
||||
return endpointGroups.values().stream()
|
||||
.flatMap(Set::stream)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
}
|
||||
|
||||
private boolean isToolGroup(String group) {
|
||||
return "qpdf".equals(group)
|
||||
|| "OCRmyPDF".equals(group)
|
||||
@@ -626,11 +574,7 @@ public class EndpointConfiguration {
|
||||
|| "Javascript".equals(group)
|
||||
|| "Weasyprint".equals(group)
|
||||
|| "Pdftohtml".equals(group)
|
||||
|| "ImageMagick".equals(group)
|
||||
|| "rar".equals(group)
|
||||
|| "Calibre".equals(group)
|
||||
|| "FFmpeg".equals(group)
|
||||
|| "veraPDF".equals(group);
|
||||
|| "rar".equals(group);
|
||||
}
|
||||
|
||||
private boolean isEndpointEnabledDirectly(String endpoint) {
|
||||
@@ -645,9 +589,8 @@ public class EndpointConfiguration {
|
||||
}
|
||||
|
||||
// Check if endpoint belongs to any disabled functional group
|
||||
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
|
||||
String group = entry.getKey();
|
||||
if (disabledGroups.contains(group) && entry.getValue().contains(endpoint)) {
|
||||
for (String group : endpointGroups.keySet()) {
|
||||
if (disabledGroups.contains(group) && endpointGroups.get(group).contains(endpoint)) {
|
||||
if (!isToolGroup(group)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@ package stirling.software.common.aop;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.*;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -28,7 +26,7 @@ import stirling.software.common.service.JobExecutorService;
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Order(20) // Lower precedence - executes AFTER audit aspects populate MDC
|
||||
@Order(0) // Highest precedence - executes before audit aspects
|
||||
public class AutoJobAspect {
|
||||
|
||||
private static final Duration RETRY_BASE_DELAY = Duration.ofMillis(100);
|
||||
@@ -39,7 +37,7 @@ public class AutoJobAspect {
|
||||
|
||||
@Around("@annotation(autoJobPostMapping)")
|
||||
public Object wrapWithJobExecution(
|
||||
ProceedingJoinPoint joinPoint, AutoJobPostMapping autoJobPostMapping) throws Exception {
|
||||
ProceedingJoinPoint joinPoint, AutoJobPostMapping autoJobPostMapping) {
|
||||
// This aspect will run before any audit aspects due to @Order(0)
|
||||
// Extract parameters from the request and annotation
|
||||
boolean async = Boolean.parseBoolean(request.getParameter("async"));
|
||||
@@ -72,29 +70,20 @@ public class AutoJobAspect {
|
||||
// No retries needed, simple execution
|
||||
return jobExecutorService.runJobGeneric(
|
||||
async,
|
||||
wrapWithMDC(
|
||||
() -> {
|
||||
try {
|
||||
// Note: Progress tracking is handled in
|
||||
// TaskManager/JobExecutorService
|
||||
// The trackProgress flag controls whether detailed progress is
|
||||
// stored
|
||||
// for REST API queries, not WebSocket notifications
|
||||
return joinPoint.proceed(args);
|
||||
} catch (Throwable ex) {
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution: {}",
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
// Rethrow RuntimeException as-is to preserve exception type
|
||||
if (ex instanceof RuntimeException) {
|
||||
throw (RuntimeException) ex;
|
||||
}
|
||||
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
|
||||
// BaseAppException
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
}),
|
||||
() -> {
|
||||
try {
|
||||
// Note: Progress tracking is handled in TaskManager/JobExecutorService
|
||||
// The trackProgress flag controls whether detailed progress is stored
|
||||
// for REST API queries, not WebSocket notifications
|
||||
return joinPoint.proceed(args);
|
||||
} catch (Throwable ex) {
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution: {}",
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
},
|
||||
timeout,
|
||||
queueable,
|
||||
resourceWeight);
|
||||
@@ -120,116 +109,115 @@ public class AutoJobAspect {
|
||||
int maxRetries,
|
||||
boolean trackProgress,
|
||||
boolean queueable,
|
||||
int resourceWeight)
|
||||
throws Exception {
|
||||
int resourceWeight) {
|
||||
|
||||
// Keep jobId reference for progress tracking in TaskManager
|
||||
AtomicReference<String> jobIdRef = new AtomicReference<>();
|
||||
|
||||
return jobExecutorService.runJobGeneric(
|
||||
async,
|
||||
wrapWithMDC(
|
||||
() -> {
|
||||
// Use iterative approach instead of recursion to avoid stack overflow
|
||||
Throwable lastException = null;
|
||||
() -> {
|
||||
// Use iterative approach instead of recursion to avoid stack overflow
|
||||
Throwable lastException = null;
|
||||
|
||||
// Attempt counter starts at 1 for first try
|
||||
for (int currentAttempt = 1;
|
||||
currentAttempt <= maxRetries;
|
||||
currentAttempt++) {
|
||||
try {
|
||||
if (trackProgress && async) {
|
||||
// Get jobId for progress tracking in TaskManager
|
||||
// This enables REST API progress queries, not WebSocket
|
||||
if (jobIdRef.get() == null) {
|
||||
jobIdRef.set(getJobIdFromContext());
|
||||
}
|
||||
String jobId = jobIdRef.get();
|
||||
if (jobId != null) {
|
||||
log.debug(
|
||||
"Tracking progress for job {} (attempt {}/{})",
|
||||
jobId,
|
||||
currentAttempt,
|
||||
maxRetries);
|
||||
// Progress is tracked in TaskManager for REST API
|
||||
// access
|
||||
// No WebSocket notifications sent here
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to execute the operation
|
||||
return joinPoint.proceed(args);
|
||||
|
||||
} catch (Throwable ex) {
|
||||
lastException = ex;
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution (attempt"
|
||||
+ " {}/{}): {}",
|
||||
// Attempt counter starts at 1 for first try
|
||||
for (int currentAttempt = 1; currentAttempt <= maxRetries; currentAttempt++) {
|
||||
try {
|
||||
if (trackProgress && async) {
|
||||
// Get jobId for progress tracking in TaskManager
|
||||
// This enables REST API progress queries, not WebSocket
|
||||
if (jobIdRef.get() == null) {
|
||||
jobIdRef.set(getJobIdFromContext());
|
||||
}
|
||||
String jobId = jobIdRef.get();
|
||||
if (jobId != null) {
|
||||
log.debug(
|
||||
"Tracking progress for job {} (attempt {}/{})",
|
||||
jobId,
|
||||
currentAttempt,
|
||||
maxRetries,
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
maxRetries);
|
||||
// Progress is tracked in TaskManager for REST API access
|
||||
// No WebSocket notifications sent here
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should retry
|
||||
if (currentAttempt < maxRetries) {
|
||||
log.info(
|
||||
"Retrying operation, attempt {}/{}",
|
||||
currentAttempt + 1,
|
||||
maxRetries);
|
||||
// Attempt to execute the operation
|
||||
return joinPoint.proceed(args);
|
||||
|
||||
if (trackProgress && async) {
|
||||
String jobId = jobIdRef.get();
|
||||
if (jobId != null) {
|
||||
log.debug(
|
||||
"Recording retry attempt for job {} in TaskManager",
|
||||
jobId);
|
||||
// Retry info is tracked in TaskManager for REST API
|
||||
// access
|
||||
}
|
||||
}
|
||||
} catch (Throwable ex) {
|
||||
lastException = ex;
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution (attempt"
|
||||
+ " {}/{}): {}",
|
||||
currentAttempt,
|
||||
maxRetries,
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
|
||||
// Use sleep for retry delay
|
||||
// For sync jobs, both sleep and async are blocking at this
|
||||
// point
|
||||
// For async jobs, the delay occurs in the executor thread
|
||||
long delayMs = RETRY_BASE_DELAY.toMillis() * currentAttempt;
|
||||
// Check if we should retry
|
||||
if (currentAttempt < maxRetries) {
|
||||
log.info(
|
||||
"Retrying operation, attempt {}/{}",
|
||||
currentAttempt + 1,
|
||||
maxRetries);
|
||||
|
||||
try {
|
||||
Thread.sleep(delayMs);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.debug(
|
||||
"Retry delay interrupted for attempt {}/{}",
|
||||
currentAttempt,
|
||||
maxRetries);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// No more retries, we'll throw the exception after the loop
|
||||
break;
|
||||
if (trackProgress && async) {
|
||||
String jobId = jobIdRef.get();
|
||||
if (jobId != null) {
|
||||
log.debug(
|
||||
"Recording retry attempt for job {} in TaskManager",
|
||||
jobId);
|
||||
// Retry info is tracked in TaskManager for REST API access
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, all retries failed
|
||||
if (lastException != null) {
|
||||
// Rethrow RuntimeException as-is to preserve exception type
|
||||
if (lastException instanceof RuntimeException) {
|
||||
throw (RuntimeException) lastException;
|
||||
// Use non-blocking delay for all retry attempts to avoid blocking
|
||||
// threads
|
||||
// For sync jobs this avoids starving the tomcat thread pool under
|
||||
// load
|
||||
long delayMs = RETRY_BASE_DELAY.toMillis() * currentAttempt;
|
||||
|
||||
// Execute the retry after a delay through the JobExecutorService
|
||||
// rather than blocking the current thread with sleep
|
||||
CompletableFuture<Object> delayedRetry = new CompletableFuture<>();
|
||||
|
||||
// Use a delayed executor for non-blocking delay
|
||||
CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS)
|
||||
.execute(
|
||||
() -> {
|
||||
// Continue the retry loop in the next iteration
|
||||
// We can't return from here directly since
|
||||
// we're in a Runnable
|
||||
delayedRetry.complete(null);
|
||||
});
|
||||
|
||||
// Wait for the delay to complete before continuing
|
||||
try {
|
||||
delayedRetry.join();
|
||||
} catch (Exception e) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
// Wrap checked exceptions - GlobalExceptionHandler will unwrap
|
||||
// BaseAppException
|
||||
throw new RuntimeException(
|
||||
"Job failed after "
|
||||
+ maxRetries
|
||||
+ " attempts: "
|
||||
+ lastException.getMessage(),
|
||||
lastException);
|
||||
} else {
|
||||
// No more retries, we'll throw the exception after the loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This should never happen if lastException is properly tracked
|
||||
throw new RuntimeException("Job failed but no exception was recorded");
|
||||
}),
|
||||
// If we get here, all retries failed
|
||||
if (lastException != null) {
|
||||
throw new RuntimeException(
|
||||
"Job failed after "
|
||||
+ maxRetries
|
||||
+ " attempts: "
|
||||
+ lastException.getMessage(),
|
||||
lastException);
|
||||
}
|
||||
|
||||
// This should never happen if lastException is properly tracked
|
||||
throw new RuntimeException("Job failed but no exception was recorded");
|
||||
},
|
||||
timeout,
|
||||
queueable,
|
||||
resourceWeight);
|
||||
@@ -298,32 +286,4 @@ public class AutoJobAspect {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a supplier to propagate MDC context to background threads. Captures MDC on request
|
||||
* thread and restores it in the background thread. Ensures proper cleanup to prevent context
|
||||
* leakage across jobs in thread pools.
|
||||
*/
|
||||
private <T> Supplier<T> wrapWithMDC(Supplier<T> supplier) {
|
||||
final Map<String, String> captured = MDC.getCopyOfContextMap();
|
||||
return () -> {
|
||||
final Map<String, String> previous = MDC.getCopyOfContextMap();
|
||||
try {
|
||||
// Set the captured context (or clear if none was captured)
|
||||
if (captured != null) {
|
||||
MDC.setContextMap(new HashMap<>(captured));
|
||||
} else {
|
||||
MDC.clear();
|
||||
}
|
||||
return supplier.get();
|
||||
} finally {
|
||||
// Restore previous state (or clear if there was none)
|
||||
if (previous != null) {
|
||||
MDC.setContextMap(previous);
|
||||
} else {
|
||||
MDC.clear();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ public class AppConfig {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Getter
|
||||
@Value("${baseUrl:http://localhost}")
|
||||
private String baseUrl;
|
||||
|
||||
@Getter
|
||||
@Value("${server.servlet.context-path:/}")
|
||||
private String contextPath;
|
||||
@@ -45,17 +49,6 @@ public class AppConfig {
|
||||
@Value("${server.port:8080}")
|
||||
private String serverPort;
|
||||
|
||||
/**
|
||||
* Get the backend URL from system configuration. Falls back to http://localhost if not
|
||||
* configured.
|
||||
*
|
||||
* @return The backend base URL for SAML/OAuth/API callbacks
|
||||
*/
|
||||
public String getBackendUrl() {
|
||||
String backendUrl = applicationProperties.getSystem().getBackendUrl();
|
||||
return (backendUrl != null && !backendUrl.isBlank()) ? backendUrl : "http://localhost";
|
||||
}
|
||||
|
||||
@Value("${v2}")
|
||||
public boolean v2Enabled;
|
||||
|
||||
@@ -64,9 +57,19 @@ public class AppConfig {
|
||||
return v2Enabled;
|
||||
}
|
||||
|
||||
/* Commented out Thymeleaf template engine bean - to be removed when frontend migration is complete
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "system.customHTMLFiles", havingValue = "true")
|
||||
public SpringTemplateEngine templateEngine(ResourceLoader resourceLoader) {
|
||||
SpringTemplateEngine templateEngine = new SpringTemplateEngine();
|
||||
templateEngine.addTemplateResolver(new FileFallbackTemplateResolver(resourceLoader));
|
||||
return templateEngine;
|
||||
}
|
||||
*/
|
||||
|
||||
@Bean(name = "loginEnabled")
|
||||
public boolean loginEnabled() {
|
||||
return applicationProperties.getSecurity().isEnableLogin();
|
||||
return applicationProperties.getSecurity().getEnableLogin();
|
||||
}
|
||||
|
||||
@Bean(name = "appName")
|
||||
@@ -110,7 +113,9 @@ public class AppConfig {
|
||||
|
||||
@Bean(name = "enableAlphaFunctionality")
|
||||
public boolean enableAlphaFunctionality() {
|
||||
return applicationProperties.getSystem().isEnableAlphaFunctionality();
|
||||
return applicationProperties.getSystem().getEnableAlphaFunctionality() != null
|
||||
? applicationProperties.getSystem().getEnableAlphaFunctionality()
|
||||
: false;
|
||||
}
|
||||
|
||||
@Bean(name = "rateLimit")
|
||||
@@ -253,14 +258,9 @@ public class AppConfig {
|
||||
return "NORMAL";
|
||||
}
|
||||
|
||||
@Bean(name = "scarfEnabled")
|
||||
public boolean scarfEnabled() {
|
||||
return applicationProperties.getSystem().isScarfEnabled();
|
||||
}
|
||||
|
||||
@Bean(name = "posthogEnabled")
|
||||
public boolean posthogEnabled() {
|
||||
return applicationProperties.getSystem().isPosthogEnabled();
|
||||
@Bean(name = "disablePixel")
|
||||
public boolean disablePixel() {
|
||||
return Boolean.parseBoolean(env.getProperty("DISABLE_PIXEL", "false"));
|
||||
}
|
||||
|
||||
@Bean(name = "machineType")
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
/* Commented out entire FileFallbackTemplateResolver class - Thymeleaf dependency removed
|
||||
* This class will be removed when frontend migration to React is complete
|
||||
|
||||
|
||||
@Slf4j
|
||||
public class FileFallbackTemplateResolver extends AbstractConfigurableTemplateResolver {
|
||||
|
||||
private final ResourceLoader resourceLoader;
|
||||
|
||||
public FileFallbackTemplateResolver(ResourceLoader resourceLoader) {
|
||||
super();
|
||||
this.resourceLoader = resourceLoader;
|
||||
setSuffix(".html");
|
||||
}
|
||||
|
||||
// Note this does not work in local IDE, Prod jar only.
|
||||
@Override
|
||||
protected ITemplateResource computeTemplateResource(
|
||||
IEngineConfiguration configuration,
|
||||
String ownerTemplate,
|
||||
String template,
|
||||
String resourceName,
|
||||
String characterEncoding,
|
||||
Map<String, Object> templateResolutionAttributes) {
|
||||
Resource resource =
|
||||
resourceLoader.getResource(
|
||||
"file:" + InstallationPathConfig.getTemplatesPath() + resourceName);
|
||||
try {
|
||||
if (resource.exists() && resource.isReadable()) {
|
||||
return new FileTemplateResource(resource.getFile().getPath(), characterEncoding);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Log the exception to help with debugging issues loading external templates
|
||||
log.warn("Unable to read template '{}' from file system", resourceName, e);
|
||||
}
|
||||
|
||||
InputStream inputStream =
|
||||
Thread.currentThread()
|
||||
.getContextClassLoader()
|
||||
.getResourceAsStream("templates/" + resourceName);
|
||||
if (inputStream != null) {
|
||||
return new InputStreamTemplateResource(inputStream, "UTF-8");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
*/
|
||||
+23
@@ -1,6 +1,7 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -59,6 +60,28 @@ public class InstallationPathConfig {
|
||||
}
|
||||
|
||||
private static String initializeBasePath() {
|
||||
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_DESKTOP_UI", "false"))) {
|
||||
String os = System.getProperty("os.name").toLowerCase();
|
||||
if (os.contains("win")) {
|
||||
return Paths.get(
|
||||
System.getenv("APPDATA"), // parent path
|
||||
"Stirling-PDF")
|
||||
+ File.separator;
|
||||
} else if (os.contains("mac")) {
|
||||
return Paths.get(
|
||||
System.getProperty("user.home"),
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Stirling-PDF")
|
||||
+ File.separator;
|
||||
} else {
|
||||
return Paths.get(
|
||||
System.getProperty("user.home"), // parent path
|
||||
".config",
|
||||
"Stirling-PDF")
|
||||
+ File.separator;
|
||||
}
|
||||
}
|
||||
return "." + File.separator;
|
||||
}
|
||||
|
||||
|
||||
+7
-277
@@ -1,14 +1,7 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -17,12 +10,8 @@ import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.CustomPaths;
|
||||
import stirling.software.common.model.ApplicationProperties.CustomPaths.Operations;
|
||||
import stirling.software.common.model.ApplicationProperties.CustomPaths.Pipeline;
|
||||
import stirling.software.common.model.ApplicationProperties.System;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.UnoServerPool;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@@ -30,22 +19,11 @@ import stirling.software.common.util.UnoServerPool;
|
||||
public class RuntimePathConfig {
|
||||
private final ApplicationProperties properties;
|
||||
private final String basePath;
|
||||
|
||||
// Operation paths
|
||||
private final String weasyPrintPath;
|
||||
private final String unoConvertPath;
|
||||
private final String calibrePath;
|
||||
private final String ocrMyPdfPath;
|
||||
private final String sOfficePath;
|
||||
|
||||
// Tesseract data path
|
||||
private final String tessDataPath;
|
||||
|
||||
private final List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> unoServerEndpoints;
|
||||
|
||||
// Pipeline paths
|
||||
private final String pipelineWatchedFoldersPath;
|
||||
private final List<String> pipelineWatchedFoldersPaths;
|
||||
private final String pipelineFinishedFoldersPath;
|
||||
private final String pipelineDefaultWebUiConfigs;
|
||||
private final String pipelinePath;
|
||||
@@ -54,27 +32,17 @@ public class RuntimePathConfig {
|
||||
this.properties = properties;
|
||||
this.basePath = InstallationPathConfig.getPath();
|
||||
|
||||
System system = properties.getSystem();
|
||||
CustomPaths customPaths = system.getCustomPaths();
|
||||
|
||||
Pipeline pipeline = customPaths.getPipeline();
|
||||
|
||||
this.pipelinePath =
|
||||
resolvePath(
|
||||
Path.of(basePath, "pipeline").toString(),
|
||||
pipeline != null ? pipeline.getPipelineDir() : null);
|
||||
this.pipelinePath = Path.of(basePath, "pipeline").toString();
|
||||
String defaultWatchedFolders = Path.of(this.pipelinePath, "watchedFolders").toString();
|
||||
String defaultFinishedFolders = Path.of(this.pipelinePath, "finishedFolders").toString();
|
||||
String defaultWebUIConfigs = Path.of(this.pipelinePath, "defaultWebUIConfigs").toString();
|
||||
|
||||
List<String> watchedFoldersDirs =
|
||||
sanitizePathList(pipeline != null ? pipeline.getWatchedFoldersDirs() : null);
|
||||
this.pipelineWatchedFoldersPaths =
|
||||
resolveWatchedFolderPaths(
|
||||
Pipeline pipeline = properties.getSystem().getCustomPaths().getPipeline();
|
||||
|
||||
this.pipelineWatchedFoldersPath =
|
||||
resolvePath(
|
||||
defaultWatchedFolders,
|
||||
watchedFoldersDirs,
|
||||
pipeline != null ? pipeline.getWatchedFoldersDir() : null);
|
||||
this.pipelineWatchedFoldersPath = this.pipelineWatchedFoldersPaths.get(0);
|
||||
this.pipelineFinishedFoldersPath =
|
||||
resolvePath(
|
||||
defaultFinishedFolders,
|
||||
@@ -84,19 +52,13 @@ public class RuntimePathConfig {
|
||||
defaultWebUIConfigs,
|
||||
pipeline != null ? pipeline.getWebUIConfigsDir() : null);
|
||||
|
||||
// Validate path conflicts after all paths are resolved
|
||||
validatePipelinePaths();
|
||||
|
||||
boolean isDocker = isRunningInDocker();
|
||||
|
||||
// Initialize Operation paths
|
||||
String defaultWeasyPrintPath = isDocker ? "/opt/venv/bin/weasyprint" : "weasyprint";
|
||||
String defaultUnoConvertPath = isDocker ? "/usr/local/bin/unoconvert" : "unoconvert";
|
||||
String defaultCalibrePath = isDocker ? "/opt/calibre/ebook-convert" : "ebook-convert";
|
||||
String defaultOcrMyPdfPath = isDocker ? "/opt/venv/bin/ocrmypdf" : "ocrmypdf";
|
||||
String defaultSOfficePath = isDocker ? "/usr/bin/soffice" : "soffice";
|
||||
String defaultUnoConvertPath = isDocker ? "/opt/venv/bin/unoconvert" : "unoconvert";
|
||||
|
||||
Operations operations = customPaths.getOperations();
|
||||
Operations operations = properties.getSystem().getCustomPaths().getOperations();
|
||||
this.weasyPrintPath =
|
||||
resolvePath(
|
||||
defaultWeasyPrintPath,
|
||||
@@ -105,245 +67,13 @@ public class RuntimePathConfig {
|
||||
resolvePath(
|
||||
defaultUnoConvertPath,
|
||||
operations != null ? operations.getUnoconvert() : null);
|
||||
this.calibrePath =
|
||||
resolvePath(
|
||||
defaultCalibrePath, operations != null ? operations.getCalibre() : null);
|
||||
this.ocrMyPdfPath =
|
||||
resolvePath(
|
||||
defaultOcrMyPdfPath, operations != null ? operations.getOcrmypdf() : null);
|
||||
this.sOfficePath =
|
||||
resolvePath(
|
||||
defaultSOfficePath, operations != null ? operations.getSoffice() : null);
|
||||
|
||||
// Initialize Tesseract data path
|
||||
// Priority: config setting > TESSDATA_PREFIX env var > default path
|
||||
String tessPath = system.getTessdataDir();
|
||||
String tessdataPrefix = java.lang.System.getenv("TESSDATA_PREFIX");
|
||||
String defaultPath = "/usr/share/tesseract-ocr/5/tessdata";
|
||||
|
||||
if (tessPath != null && !tessPath.isEmpty()) {
|
||||
this.tessDataPath = tessPath;
|
||||
} else if (tessdataPrefix != null && !tessdataPrefix.isEmpty()) {
|
||||
this.tessDataPath = tessdataPrefix;
|
||||
} else {
|
||||
this.tessDataPath = defaultPath;
|
||||
}
|
||||
|
||||
log.info("Using Tesseract data path: {}", this.tessDataPath);
|
||||
|
||||
ApplicationProperties.ProcessExecutor processExecutor = properties.getProcessExecutor();
|
||||
int libreOfficeLimit = 1;
|
||||
if (processExecutor != null && processExecutor.getSessionLimit() != null) {
|
||||
libreOfficeLimit = processExecutor.getSessionLimit().getLibreOfficeSessionLimit();
|
||||
}
|
||||
this.unoServerEndpoints = buildUnoServerEndpoints(processExecutor, libreOfficeLimit);
|
||||
ProcessExecutor.setUnoServerPool(new UnoServerPool(this.unoServerEndpoints));
|
||||
}
|
||||
|
||||
private String resolvePath(String defaultPath, String customPath) {
|
||||
return StringUtils.isNotBlank(customPath) ? customPath : defaultPath;
|
||||
}
|
||||
|
||||
private List<String> resolveWatchedFolderPaths(
|
||||
String defaultPath, List<String> watchedFoldersDirs, String legacyWatchedFolder) {
|
||||
List<String> rawPaths = new ArrayList<>();
|
||||
|
||||
// Collect paths from new config
|
||||
if (watchedFoldersDirs != null && !watchedFoldersDirs.isEmpty()) {
|
||||
rawPaths.addAll(watchedFoldersDirs);
|
||||
}
|
||||
// Fall back to legacy config
|
||||
else if (StringUtils.isNotBlank(legacyWatchedFolder)) {
|
||||
rawPaths.add(legacyWatchedFolder);
|
||||
}
|
||||
// Fall back to default
|
||||
else {
|
||||
rawPaths.add(defaultPath);
|
||||
}
|
||||
|
||||
// Validate, normalize, and deduplicate paths
|
||||
List<String> validatedPaths = validateAndNormalizePaths(rawPaths);
|
||||
|
||||
// Ensure we have at least one valid path (critical for system to function)
|
||||
if (validatedPaths.isEmpty()) {
|
||||
log.warn(
|
||||
"No valid watched folder paths configured, falling back to default: {}",
|
||||
defaultPath);
|
||||
validatedPaths.add(defaultPath);
|
||||
}
|
||||
|
||||
// Detect overlapping paths (warning only, not blocking)
|
||||
detectOverlappingPaths(validatedPaths);
|
||||
|
||||
return validatedPaths;
|
||||
}
|
||||
|
||||
private List<String> sanitizePathList(List<String> paths) {
|
||||
if (paths == null || paths.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<String> sanitized = new ArrayList<>();
|
||||
for (String path : paths) {
|
||||
if (StringUtils.isNotBlank(path)) {
|
||||
sanitized.add(path.trim());
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private List<String> validateAndNormalizePaths(List<String> paths) {
|
||||
Set<String> normalizedPaths = new LinkedHashSet<>(); // Preserves order, prevents duplicates
|
||||
|
||||
for (String pathStr : paths) {
|
||||
if (StringUtils.isBlank(pathStr)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Normalize to absolute path
|
||||
Path path = Paths.get(pathStr.trim()).toAbsolutePath().normalize();
|
||||
String normalizedPath = path.toString();
|
||||
|
||||
// Check for duplicates
|
||||
if (normalizedPaths.contains(normalizedPath)) {
|
||||
log.debug("Skipping duplicate watched folder path: {}", pathStr);
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedPaths.add(normalizedPath);
|
||||
log.info("Registered watched folder path: {}", normalizedPath);
|
||||
|
||||
} catch (InvalidPathException e) {
|
||||
log.error(
|
||||
"Invalid watched folder path '{}' - skipping: {}", pathStr, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return new ArrayList<>(normalizedPaths);
|
||||
}
|
||||
|
||||
private void detectOverlappingPaths(List<String> paths) {
|
||||
for (int i = 0; i < paths.size(); i++) {
|
||||
Path path1 = Paths.get(paths.get(i));
|
||||
for (int j = i + 1; j < paths.size(); j++) {
|
||||
Path path2 = Paths.get(paths.get(j));
|
||||
|
||||
// Check if one path is a parent of the other
|
||||
if (path1.startsWith(path2)) {
|
||||
log.warn(
|
||||
"Watched folder path '{}' is nested inside '{}' - this may cause duplicate processing",
|
||||
path1,
|
||||
path2);
|
||||
} else if (path2.startsWith(path1)) {
|
||||
log.warn(
|
||||
"Watched folder path '{}' is nested inside '{}' - this may cause duplicate processing",
|
||||
path2,
|
||||
path1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validatePipelinePaths() {
|
||||
try {
|
||||
Path finishedPath = Paths.get(pipelineFinishedFoldersPath).toAbsolutePath().normalize();
|
||||
|
||||
for (String watchedPathStr : pipelineWatchedFoldersPaths) {
|
||||
Path watchedPath = Paths.get(watchedPathStr).toAbsolutePath().normalize();
|
||||
|
||||
// Check if watched folder is same as finished folder
|
||||
if (watchedPath.equals(finishedPath)) {
|
||||
log.error(
|
||||
"CRITICAL: Watched folder '{}' is the same as finished folder '{}' - this will cause processing loops!",
|
||||
watchedPath,
|
||||
finishedPath);
|
||||
}
|
||||
// Check if watched folder contains finished folder
|
||||
else if (finishedPath.startsWith(watchedPath)) {
|
||||
log.warn(
|
||||
"Finished folder '{}' is nested inside watched folder '{}' - this may cause issues",
|
||||
finishedPath,
|
||||
watchedPath);
|
||||
}
|
||||
// Check if finished folder contains watched folder
|
||||
else if (watchedPath.startsWith(finishedPath)) {
|
||||
log.error(
|
||||
"CRITICAL: Watched folder '{}' is nested inside finished folder '{}' - this will cause processing loops!",
|
||||
watchedPath,
|
||||
finishedPath);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error validating pipeline paths: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isRunningInDocker() {
|
||||
return Files.exists(Path.of("/.dockerenv"));
|
||||
}
|
||||
|
||||
private List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> buildUnoServerEndpoints(
|
||||
ApplicationProperties.ProcessExecutor processExecutor, int sessionLimit) {
|
||||
if (processExecutor == null) {
|
||||
log.warn("ProcessExecutor config missing; defaulting to a single UNO endpoint.");
|
||||
return Collections.singletonList(
|
||||
new ApplicationProperties.ProcessExecutor.UnoServerEndpoint());
|
||||
}
|
||||
if (!processExecutor.isAutoUnoServer()) {
|
||||
List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> configured =
|
||||
sanitizeUnoServerEndpoints(processExecutor.getUnoServerEndpoints());
|
||||
if (!configured.isEmpty()) {
|
||||
// Warn if manual endpoint count doesn't match sessionLimit
|
||||
if (configured.size() != sessionLimit) {
|
||||
log.warn(
|
||||
"Manual UNO endpoint count ({}) differs from libreOfficeSessionLimit ({}). "
|
||||
+ "Concurrency will be limited by endpoint count, not sessionLimit.",
|
||||
configured.size(),
|
||||
sessionLimit);
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
log.warn(
|
||||
"autoUnoServer disabled but no unoServerEndpoints configured; defaulting to 127.0.0.1:2003.");
|
||||
return Collections.singletonList(
|
||||
new ApplicationProperties.ProcessExecutor.UnoServerEndpoint());
|
||||
}
|
||||
int count = sessionLimit > 0 ? sessionLimit : 1;
|
||||
return buildAutoUnoServerEndpoints(count);
|
||||
}
|
||||
|
||||
private List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint>
|
||||
buildAutoUnoServerEndpoints(int count) {
|
||||
List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> endpoints = new ArrayList<>();
|
||||
int basePort = 2003;
|
||||
for (int i = 0; i < count; i++) {
|
||||
ApplicationProperties.ProcessExecutor.UnoServerEndpoint endpoint =
|
||||
new ApplicationProperties.ProcessExecutor.UnoServerEndpoint();
|
||||
endpoint.setHost("127.0.0.1");
|
||||
endpoint.setPort(basePort + (i * 2));
|
||||
endpoints.add(endpoint);
|
||||
}
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint>
|
||||
sanitizeUnoServerEndpoints(
|
||||
List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> endpoints) {
|
||||
if (endpoints == null || endpoints.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> sanitized = new ArrayList<>();
|
||||
for (ApplicationProperties.ProcessExecutor.UnoServerEndpoint endpoint : endpoints) {
|
||||
if (endpoint == null) {
|
||||
continue;
|
||||
}
|
||||
String host = endpoint.getHost();
|
||||
int port = endpoint.getPort();
|
||||
if (host == null || host.isBlank() || port <= 0) {
|
||||
continue;
|
||||
}
|
||||
sanitized.add(endpoint);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.TaskScheduler;
|
||||
import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler;
|
||||
|
||||
/**
|
||||
* Configures the scheduler used by all {@code @Scheduled} methods. Uses virtual threads so that
|
||||
* long-running scheduled tasks (e.g. cleanup, license checks, file monitoring) never block each
|
||||
* other — each runs on its own lightweight virtual thread.
|
||||
*/
|
||||
@Configuration
|
||||
public class SchedulingConfig {
|
||||
|
||||
@Bean
|
||||
public TaskScheduler taskScheduler() {
|
||||
SimpleAsyncTaskScheduler scheduler = new SimpleAsyncTaskScheduler();
|
||||
scheduler.setVirtualThreads(true);
|
||||
scheduler.setThreadNamePrefix("scheduled-vt-");
|
||||
return scheduler;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package stirling.software.common.constants;
|
||||
|
||||
/**
|
||||
* Centralized constants for JWT token management.
|
||||
*
|
||||
* <p>These defaults are used when configuration values are not explicitly set.
|
||||
*/
|
||||
public final class JwtConstants {
|
||||
|
||||
private JwtConstants() {
|
||||
throw new UnsupportedOperationException("Utility class");
|
||||
}
|
||||
|
||||
/** Default JWT access token lifetime in minutes (24 hours). */
|
||||
public static final int DEFAULT_TOKEN_EXPIRY_MINUTES = 1440;
|
||||
|
||||
/** Default desktop client token lifetime in minutes (30 days). */
|
||||
public static final int DEFAULT_DESKTOP_TOKEN_EXPIRY_MINUTES = 43200;
|
||||
|
||||
/**
|
||||
* Default refresh grace period in minutes.
|
||||
*
|
||||
* <p>Allows refresh of expired tokens within this window after expiration.
|
||||
*/
|
||||
public static final int DEFAULT_REFRESH_GRACE_MINUTES = 15;
|
||||
|
||||
/**
|
||||
* Default allowed clock skew in seconds.
|
||||
*
|
||||
* <p>Tolerates small time drift between client and server clocks during validation.
|
||||
*/
|
||||
public static final int DEFAULT_CLOCK_SKEW_SECONDS = 60;
|
||||
|
||||
/** Milliseconds per minute. */
|
||||
public static final long MILLIS_PER_MINUTE = 60_000L;
|
||||
|
||||
/** Seconds per minute. */
|
||||
public static final long SECONDS_PER_MINUTE = 60L;
|
||||
|
||||
/** JWT issuer identifier. */
|
||||
public static final String ISSUER = "https://stirling.com";
|
||||
|
||||
/**
|
||||
* Maximum refresh attempts allowed within the grace period window.
|
||||
*
|
||||
* <p>Prevents abuse of expired tokens by limiting refresh attempts.
|
||||
*/
|
||||
public static final int MAX_REFRESH_ATTEMPTS_IN_GRACE = 3;
|
||||
}
|
||||
+29
-414
@@ -12,7 +12,6 @@ import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@@ -29,8 +28,6 @@ import org.springframework.stereotype.Component;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
@@ -39,7 +36,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.configuration.YamlPropertySourceFactory;
|
||||
import stirling.software.common.constants.JwtConstants;
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
import stirling.software.common.model.oauth2.GitHubProvider;
|
||||
import stirling.software.common.model.oauth2.GoogleProvider;
|
||||
@@ -64,7 +60,6 @@ public class ApplicationProperties {
|
||||
private AutomaticallyGenerated automaticallyGenerated = new AutomaticallyGenerated();
|
||||
|
||||
private Mail mail = new Mail();
|
||||
private Telegram telegram = new Telegram();
|
||||
|
||||
private Premium premium = new Premium();
|
||||
|
||||
@@ -73,7 +68,6 @@ public class ApplicationProperties {
|
||||
|
||||
private AutoPipeline autoPipeline = new AutoPipeline();
|
||||
private ProcessExecutor processExecutor = new ProcessExecutor();
|
||||
private PdfEditor pdfEditor = new PdfEditor();
|
||||
|
||||
@Bean
|
||||
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
|
||||
@@ -101,97 +95,11 @@ public class ApplicationProperties {
|
||||
return propertySource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize fileUploadLimit from environment variables if not set in settings.yml. Supports
|
||||
* SYSTEMFILEUPLOADLIMIT (format: "100MB") and SYSTEM_MAXFILESIZE (format: "100" in MB).
|
||||
*/
|
||||
@PostConstruct
|
||||
public void initializeFileUploadLimitFromEnv() {
|
||||
// Only override if fileUploadLimit is not already set in settings.yml
|
||||
if (system.getFileUploadLimit() == null || system.getFileUploadLimit().isEmpty()) {
|
||||
String fileUploadLimit = null;
|
||||
|
||||
// Check SYSTEMFILEUPLOADLIMIT first (format: "100MB", "1GB", etc.)
|
||||
String systemFileUploadLimit = java.lang.System.getenv("SYSTEMFILEUPLOADLIMIT");
|
||||
if (systemFileUploadLimit != null && !systemFileUploadLimit.trim().isEmpty()) {
|
||||
fileUploadLimit = systemFileUploadLimit.trim();
|
||||
log.info("Setting fileUploadLimit from SYSTEMFILEUPLOADLIMIT: {}", fileUploadLimit);
|
||||
} else {
|
||||
// Check SYSTEM_MAXFILESIZE (format: number in MB, e.g., "100")
|
||||
String systemMaxFileSize = java.lang.System.getenv("SYSTEM_MAXFILESIZE");
|
||||
if (systemMaxFileSize != null && !systemMaxFileSize.trim().isEmpty()) {
|
||||
try {
|
||||
// Validate it's a number
|
||||
long sizeInMB = Long.parseLong(systemMaxFileSize.trim());
|
||||
if (sizeInMB > 0 && sizeInMB <= 999) {
|
||||
fileUploadLimit = sizeInMB + "MB";
|
||||
log.info(
|
||||
"Setting fileUploadLimit from SYSTEM_MAXFILESIZE: {}MB",
|
||||
sizeInMB);
|
||||
} else {
|
||||
log.warn(
|
||||
"SYSTEM_MAXFILESIZE value {} is out of valid range (1-999), ignoring",
|
||||
sizeInMB);
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn(
|
||||
"SYSTEM_MAXFILESIZE value '{}' is not a valid number, ignoring",
|
||||
systemMaxFileSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fileUploadLimit != null) {
|
||||
system.setFileUploadLimit(fileUploadLimit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class AutoPipeline {
|
||||
private String outputFolder;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class PdfEditor {
|
||||
private Cache cache = new Cache();
|
||||
private FontNormalization fontNormalization = new FontNormalization();
|
||||
private CffConverter cffConverter = new CffConverter();
|
||||
private Type3 type3 = new Type3();
|
||||
private String fallbackFont = "classpath:/static/fonts/NotoSans-Regular.ttf";
|
||||
|
||||
@Data
|
||||
public static class Cache {
|
||||
private long maxBytes = -1;
|
||||
private int maxPercent = 20;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class FontNormalization {
|
||||
private boolean enabled = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class CffConverter {
|
||||
private boolean enabled = true;
|
||||
private String method = "python";
|
||||
private String pythonCommand = "/opt/venv/bin/python3";
|
||||
private String pythonScript = "/scripts/convert_cff_to_ttf.py";
|
||||
private String fontforgeCommand = "fontforge";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Type3 {
|
||||
private Library library = new Library();
|
||||
|
||||
@Data
|
||||
public static class Library {
|
||||
private boolean enabled = true;
|
||||
private String index = "classpath:/type3/library/index.json";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Legal {
|
||||
private String termsAndConditions;
|
||||
@@ -203,7 +111,7 @@ public class ApplicationProperties {
|
||||
|
||||
@Data
|
||||
public static class Security {
|
||||
private boolean enableLogin;
|
||||
private Boolean enableLogin;
|
||||
private InitialLogin initialLogin = new InitialLogin();
|
||||
private OAUTH2 oauth2 = new OAUTH2();
|
||||
private SAML2 saml2 = new SAML2();
|
||||
@@ -213,7 +121,6 @@ public class ApplicationProperties {
|
||||
private String customGlobalAPIKey;
|
||||
private Jwt jwt = new Jwt();
|
||||
private Validation validation = new Validation();
|
||||
private String xFrameOptions = "DENY";
|
||||
|
||||
public Boolean isAltLogin() {
|
||||
return saml2.getEnabled() || oauth2.getEnabled();
|
||||
@@ -365,11 +272,11 @@ public class ApplicationProperties {
|
||||
}
|
||||
|
||||
public boolean isSettingsValid() {
|
||||
return !ValidationUtils.isStringEmpty(this.issuer)
|
||||
&& !ValidationUtils.isStringEmpty(this.clientId)
|
||||
&& !ValidationUtils.isStringEmpty(this.clientSecret)
|
||||
&& !ValidationUtils.isCollectionEmpty(this.scopes)
|
||||
&& !ValidationUtils.isStringEmpty(this.useAsUsername);
|
||||
return !ValidationUtils.isStringEmpty(this.getIssuer())
|
||||
&& !ValidationUtils.isStringEmpty(this.getClientId())
|
||||
&& !ValidationUtils.isStringEmpty(this.getClientSecret())
|
||||
&& !ValidationUtils.isCollectionEmpty(this.getScopes())
|
||||
&& !ValidationUtils.isStringEmpty(this.getUseAsUsername());
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -379,7 +286,7 @@ public class ApplicationProperties {
|
||||
private KeycloakProvider keycloak = new KeycloakProvider();
|
||||
|
||||
public Provider get(String registrationId) throws UnsupportedProviderException {
|
||||
return switch (registrationId.toLowerCase(Locale.ROOT)) {
|
||||
return switch (registrationId.toLowerCase()) {
|
||||
case "google" -> getGoogle();
|
||||
case "github" -> getGithub();
|
||||
case "keycloak" -> getKeycloak();
|
||||
@@ -387,114 +294,19 @@ public class ApplicationProperties {
|
||||
throw new UnsupportedProviderException(
|
||||
"Logout from the provider "
|
||||
+ registrationId
|
||||
+ " is not supported. Report it at"
|
||||
+ " https://github.com/Stirling-Tools/Stirling-PDF/issues");
|
||||
+ " is not supported. "
|
||||
+ "Report it at https://github.com/Stirling-Tools/Stirling-PDF/issues");
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT token configuration.
|
||||
*
|
||||
* <p><b>BREAKING CHANGE (v2.0):</b> Default token expiry increased from 12 hours (720
|
||||
* minutes) to 24 hours (1440 minutes). If you require the previous behavior, explicitly set
|
||||
* {@code tokenExpiryMinutes: 720} in your configuration.
|
||||
*/
|
||||
@Data
|
||||
public static class Jwt {
|
||||
private boolean enableKeystore = true;
|
||||
private boolean enableKeyRotation = false;
|
||||
private boolean enableKeyCleanup = true;
|
||||
|
||||
/**
|
||||
* JWT access token lifetime in minutes for web clients.
|
||||
*
|
||||
* <p>Default: {@value JwtConstants#DEFAULT_TOKEN_EXPIRY_MINUTES} minutes (24 hours).
|
||||
*
|
||||
* <p><b>BREAKING CHANGE:</b> Previously hardcoded to 720 minutes (12 hours). Now
|
||||
* defaults to 1440 minutes (24 hours).
|
||||
*/
|
||||
private int tokenExpiryMinutes = JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
|
||||
|
||||
/**
|
||||
* JWT access token lifetime in minutes for desktop clients (Tauri app).
|
||||
*
|
||||
* <p>Desktop clients are automatically detected via User-Agent header and receive
|
||||
* longer-lived tokens because they run on personal devices with OS-level encrypted
|
||||
* storage (macOS Keychain, Windows Credential Manager, Linux Secret Service).
|
||||
*
|
||||
* <p>This provides better UX (login once per month) while maintaining security through
|
||||
* device encryption and secure storage, matching the behavior of popular desktop apps
|
||||
* like Slack, Discord, VS Code, etc.
|
||||
*
|
||||
* <p>Default: 43200 minutes (30 days).
|
||||
*/
|
||||
private int desktopTokenExpiryMinutes = 43200;
|
||||
|
||||
/**
|
||||
* Allowed clock skew in seconds for JWT validation.
|
||||
*
|
||||
* <p>Tolerates small time drift between client and server clocks. Tokens that are
|
||||
* slightly expired or slightly in the future (within this window) will still be
|
||||
* accepted.
|
||||
*
|
||||
* <p>Default: {@value JwtConstants#DEFAULT_CLOCK_SKEW_SECONDS} seconds.
|
||||
*/
|
||||
private int allowedClockSkewSeconds = JwtConstants.DEFAULT_CLOCK_SKEW_SECONDS;
|
||||
|
||||
/**
|
||||
* Grace period in minutes for refreshing expired tokens.
|
||||
*
|
||||
* <p>Allows token refresh using an expired access token if the token expired within
|
||||
* this many minutes. This provides better UX by allowing users to refresh slightly
|
||||
* expired tokens without re-authentication.
|
||||
*
|
||||
* <p>Rate limiting is applied to prevent abuse of expired tokens within the grace
|
||||
* window (max {@value JwtConstants#MAX_REFRESH_ATTEMPTS_IN_GRACE} attempts).
|
||||
*
|
||||
* <p>Default: {@value JwtConstants#DEFAULT_REFRESH_GRACE_MINUTES} minutes.
|
||||
*/
|
||||
private int refreshGraceMinutes = JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES;
|
||||
|
||||
/**
|
||||
* Calculate number of days to retain old JWT signing keys.
|
||||
*
|
||||
* <p>Automatically calculated based on the longest token lifetime plus a proportional
|
||||
* safety buffer. Keys must be retained for at least as long as the tokens they signed
|
||||
* remain valid, otherwise token verification will fail.
|
||||
*
|
||||
* <p>Formula: ceil((maxTokenExpiry + 10% buffer + refreshGrace + clockSkew) / 1440)
|
||||
*
|
||||
* <p>The buffer includes:
|
||||
*
|
||||
* <ul>
|
||||
* <li>10% of token lifetime (scales with token duration)
|
||||
* <li>Token refresh grace period ({@link #refreshGraceMinutes})
|
||||
* <li>Clock skew tolerance ({@link #allowedClockSkewSeconds} converted to minutes)
|
||||
* </ul>
|
||||
*
|
||||
* @return calculated key retention period in days
|
||||
*/
|
||||
public int getKeyRetentionDays() {
|
||||
final int MINUTES_PER_DAY = 1440;
|
||||
final double BUFFER_PERCENTAGE = 0.10; // 10% buffer
|
||||
|
||||
int maxTokenExpiryMinutes = Math.max(tokenExpiryMinutes, desktopTokenExpiryMinutes);
|
||||
|
||||
// Add 10% buffer (scales with token lifetime)
|
||||
int bufferMinutes = (int) Math.ceil(maxTokenExpiryMinutes * BUFFER_PERCENTAGE);
|
||||
|
||||
// Add refresh grace period
|
||||
bufferMinutes += refreshGraceMinutes;
|
||||
|
||||
// Add clock skew (convert seconds to minutes, round up)
|
||||
bufferMinutes += (int) Math.ceil(allowedClockSkewSeconds / 60.0);
|
||||
|
||||
// Total retention in minutes, convert to days (round up)
|
||||
int totalMinutes = maxTokenExpiryMinutes + bufferMinutes;
|
||||
return (int) Math.ceil(totalMinutes / (double) MINUTES_PER_DAY);
|
||||
}
|
||||
private int keyRetentionDays = 7;
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -536,56 +348,44 @@ public class ApplicationProperties {
|
||||
@Data
|
||||
public static class System {
|
||||
private String defaultLocale;
|
||||
private boolean googlevisibility;
|
||||
private Boolean googlevisibility;
|
||||
private boolean showUpdate;
|
||||
private boolean showUpdateOnlyAdmin;
|
||||
private boolean showSettingsWhenNoLogin = true;
|
||||
private Boolean showUpdateOnlyAdmin;
|
||||
private boolean customHTMLFiles;
|
||||
private String tessdataDir;
|
||||
private boolean enableAlphaFunctionality;
|
||||
private Boolean enableAlphaFunctionality;
|
||||
private Boolean enableAnalytics;
|
||||
private Boolean enablePosthog;
|
||||
private Boolean enableScarf;
|
||||
private Boolean enableDesktopInstallSlide;
|
||||
private Datasource datasource;
|
||||
private boolean disableSanitize;
|
||||
private Boolean disableSanitize;
|
||||
private int maxDPI;
|
||||
private boolean enableUrlToPDF;
|
||||
private Boolean enableUrlToPDF;
|
||||
private Html html = new Html();
|
||||
private CustomPaths customPaths = new CustomPaths();
|
||||
private String fileUploadLimit;
|
||||
private TempFileManagement tempFileManagement = new TempFileManagement();
|
||||
private DatabaseBackup databaseBackup = new DatabaseBackup();
|
||||
private List<String> corsAllowedOrigins = new ArrayList<>();
|
||||
private String backendUrl; // Backend base URL for SAML/OAuth/API callbacks (e.g.
|
||||
// 'http://localhost:8080', 'https://api.example.com'). Required for
|
||||
// SSO.
|
||||
private String frontendUrl; // Frontend URL for invite email links (e.g.
|
||||
private String
|
||||
frontendUrl; // Base URL for frontend (used for invite links, etc.). If not set,
|
||||
|
||||
// 'https://app.example.com'). If not set, falls back to backendUrl.
|
||||
private boolean enableMobileScanner = false; // Enable mobile phone QR code upload feature
|
||||
private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings();
|
||||
|
||||
@Data
|
||||
public static class MobileScannerSettings {
|
||||
private boolean convertToPdf = true; // Whether to automatically convert images to PDF
|
||||
private String imageResolution = "full"; // Options: "full", "reduced"
|
||||
private String pageFormat = "A4"; // Options: "keep", "A4", "letter"
|
||||
private boolean stretchToFit = false; // Whether to stretch image to fill page
|
||||
}
|
||||
// falls back to backend URL.
|
||||
|
||||
public boolean isAnalyticsEnabled() {
|
||||
return this.enableAnalytics != null && this.enableAnalytics;
|
||||
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
|
||||
}
|
||||
|
||||
public boolean isPosthogEnabled() {
|
||||
// Treat null as enabled when analytics is enabled
|
||||
return this.isAnalyticsEnabled() && (this.enablePosthog == null || this.enablePosthog);
|
||||
return this.isAnalyticsEnabled()
|
||||
&& (this.getEnablePosthog() == null || this.getEnablePosthog());
|
||||
}
|
||||
|
||||
public boolean isScarfEnabled() {
|
||||
// Treat null as enabled when analytics is enabled
|
||||
return this.isAnalyticsEnabled() && (this.enableScarf == null || this.enableScarf);
|
||||
return this.isAnalyticsEnabled()
|
||||
&& (this.getEnableScarf() == null || this.getEnableScarf());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -601,9 +401,7 @@ public class ApplicationProperties {
|
||||
|
||||
@Data
|
||||
public static class Pipeline {
|
||||
private String pipelineDir;
|
||||
private String watchedFoldersDir;
|
||||
private List<String> watchedFoldersDirs = new ArrayList<>();
|
||||
private String finishedFoldersDir;
|
||||
private String webUIConfigsDir;
|
||||
}
|
||||
@@ -612,9 +410,6 @@ public class ApplicationProperties {
|
||||
public static class Operations {
|
||||
private String weasyprint;
|
||||
private String unoconvert;
|
||||
private String calibre;
|
||||
private String ocrmypdf;
|
||||
private String soffice;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,12 +491,11 @@ public class ApplicationProperties {
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return
|
||||
"""
|
||||
Driver {
|
||||
driverName='%s'
|
||||
}
|
||||
"""
|
||||
return """
|
||||
Driver {
|
||||
driverName='%s'
|
||||
}
|
||||
"""
|
||||
.formatted(driverName);
|
||||
}
|
||||
}
|
||||
@@ -711,9 +505,6 @@ public class ApplicationProperties {
|
||||
private String appNameNavbar;
|
||||
private List<String> languages;
|
||||
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
|
||||
private boolean defaultHideUnavailableTools = false;
|
||||
private boolean defaultHideUnavailableConversions = false;
|
||||
private HideDisabledTools hideDisabledTools = new HideDisabledTools();
|
||||
|
||||
public String getAppNameNavbar() {
|
||||
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
|
||||
@@ -726,12 +517,6 @@ public class ApplicationProperties {
|
||||
}
|
||||
return "classic"; // default
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class HideDisabledTools {
|
||||
private boolean googleDrive = false;
|
||||
private boolean mobileQRScanner = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -742,7 +527,7 @@ public class ApplicationProperties {
|
||||
|
||||
@Data
|
||||
public static class Metrics {
|
||||
private boolean enabled;
|
||||
private Boolean enabled;
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -750,7 +535,6 @@ public class ApplicationProperties {
|
||||
@ToString.Exclude private String key;
|
||||
private String UUID;
|
||||
private String appVersion;
|
||||
private Boolean isNewServer;
|
||||
}
|
||||
|
||||
// TODO: Remove post migration
|
||||
@@ -763,7 +547,6 @@ public class ApplicationProperties {
|
||||
private boolean ssoAutoLogin;
|
||||
private CustomMetadata customMetadata = new CustomMetadata();
|
||||
|
||||
@Deprecated
|
||||
@Data
|
||||
public static class CustomMetadata {
|
||||
private boolean autoUpdateMetadata;
|
||||
@@ -771,23 +554,16 @@ public class ApplicationProperties {
|
||||
private String creator;
|
||||
private String producer;
|
||||
|
||||
@Deprecated
|
||||
public String getCreator() {
|
||||
return creator == null || creator.trim().isEmpty() ? "Stirling-PDF" : creator;
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
public String getProducer() {
|
||||
return producer == null || producer.trim().isEmpty() ? "Stirling-PDF" : producer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mail server configuration properties.
|
||||
*
|
||||
* @since 0.46.1
|
||||
*/
|
||||
@Data
|
||||
public static class Mail {
|
||||
private boolean enabled;
|
||||
@@ -810,102 +586,6 @@ public class ApplicationProperties {
|
||||
private Boolean sslCheckServerIdentity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram bot configuration properties.
|
||||
*
|
||||
* @since 2.2.x
|
||||
*/
|
||||
@Data
|
||||
public static class Telegram {
|
||||
private Boolean enabled = false;
|
||||
@ToString.Exclude private String botToken;
|
||||
private String botUsername;
|
||||
private String pipelineInboxFolder = "telegram";
|
||||
private Boolean customFolderSuffix = false;
|
||||
private Boolean enableAllowUserIDs = false;
|
||||
private List<Long> allowUserIDs = new ArrayList<>();
|
||||
private Boolean enableAllowChannelIDs = false;
|
||||
private List<Long> allowChannelIDs = new ArrayList<>();
|
||||
private long processingTimeoutSeconds = 180;
|
||||
private long pollingIntervalMillis = 2000;
|
||||
private Feedback feedback = new Feedback();
|
||||
|
||||
/**
|
||||
* Configuration for feedback messages sent by the Telegram bot.
|
||||
*
|
||||
* @since 2.2.x
|
||||
*/
|
||||
@Data
|
||||
public static class Feedback {
|
||||
private Channel channel = new Channel();
|
||||
private User user = new User();
|
||||
|
||||
/**
|
||||
* Channel-specific feedback settings.
|
||||
*
|
||||
* @since 2.2.x
|
||||
*/
|
||||
@Data
|
||||
public static class Channel {
|
||||
/**
|
||||
* Set to {@code false} to hide/suppress "no valid document" feedback messages to
|
||||
* the channel (to avoid spam).
|
||||
*/
|
||||
private Boolean noValidDocument = true;
|
||||
|
||||
/**
|
||||
* Set to {@code false} to hide/suppress generic error feedback messages to the
|
||||
* channel (to avoid spam).
|
||||
*/
|
||||
private Boolean errorMessage = true;
|
||||
|
||||
/**
|
||||
* Set to {@code false} to hide/suppress processing error feedback messages to the
|
||||
* channel (to avoid spam).
|
||||
*/
|
||||
private Boolean errorProcessing = true;
|
||||
|
||||
/**
|
||||
* Set to {@code false} to hide/suppress "processing" feedback messages to the
|
||||
* channel (to avoid spam).
|
||||
*/
|
||||
private Boolean processing = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-specific feedback settings.
|
||||
*
|
||||
* @since 2.2.x
|
||||
*/
|
||||
@Data
|
||||
public static class User {
|
||||
/**
|
||||
* Set to {@code false} to hide/suppress "no valid document" feedback messages to
|
||||
* users (to avoid spam).
|
||||
*/
|
||||
private Boolean noValidDocument = true;
|
||||
|
||||
/**
|
||||
* Set to {@code false} to hide/suppress generic error feedback messages to users
|
||||
* (to avoid spam).
|
||||
*/
|
||||
private Boolean errorMessage = true;
|
||||
|
||||
/**
|
||||
* Set to {@code false} to hide/suppress processing error feedback messages to users
|
||||
* (to avoid spam).
|
||||
*/
|
||||
private Boolean errorProcessing = true;
|
||||
|
||||
/**
|
||||
* Set to {@code false} to hide/suppress "processing" feedback messages to users (to
|
||||
* avoid spam).
|
||||
*/
|
||||
private Boolean processing = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Premium {
|
||||
private boolean enabled;
|
||||
@@ -919,15 +599,6 @@ public class ApplicationProperties {
|
||||
private boolean ssoAutoLogin;
|
||||
private boolean database;
|
||||
private CustomMetadata customMetadata = new CustomMetadata();
|
||||
private GoogleDrive googleDrive = new GoogleDrive();
|
||||
|
||||
@Data
|
||||
public static class GoogleDrive {
|
||||
private boolean enabled = false;
|
||||
private String clientId = "";
|
||||
private String apiKey = "";
|
||||
private String appId = "";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class CustomMetadata {
|
||||
@@ -952,37 +623,12 @@ public class ApplicationProperties {
|
||||
public static class EnterpriseFeatures {
|
||||
private PersistentMetrics persistentMetrics = new PersistentMetrics();
|
||||
private Audit audit = new Audit();
|
||||
private DatabaseNotifications databaseNotifications = new DatabaseNotifications();
|
||||
|
||||
@Data
|
||||
public static class DatabaseNotifications {
|
||||
private Backup backups = new Backup();
|
||||
private Imports imports = new Imports();
|
||||
|
||||
@Data
|
||||
public static class Backup {
|
||||
private boolean successful = false;
|
||||
private boolean failed = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Imports {
|
||||
private boolean successful = false;
|
||||
private boolean failed = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Audit {
|
||||
private boolean enabled = true;
|
||||
private int level = 2; // 0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE
|
||||
private int retentionDays = 90;
|
||||
private boolean captureFileHash =
|
||||
false; // Capture SHA-256 hash of files (increases processing time)
|
||||
private boolean capturePdfAuthor =
|
||||
false; // Capture PDF author metadata (increases processing time)
|
||||
private boolean captureOperationResults =
|
||||
false; // Capture operation return values (not recommended, high volume)
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -997,16 +643,6 @@ public class ApplicationProperties {
|
||||
public static class ProcessExecutor {
|
||||
private SessionLimit sessionLimit = new SessionLimit();
|
||||
private TimeoutMinutes timeoutMinutes = new TimeoutMinutes();
|
||||
private boolean autoUnoServer = true;
|
||||
private List<UnoServerEndpoint> unoServerEndpoints = new ArrayList<>();
|
||||
|
||||
@Data
|
||||
public static class UnoServerEndpoint {
|
||||
private String host = "127.0.0.1";
|
||||
private int port = 2003;
|
||||
private String hostLocation = "auto"; // auto|local|remote
|
||||
private String protocol = "http"; // http|https
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class SessionLimit {
|
||||
@@ -1016,12 +652,10 @@ public class ApplicationProperties {
|
||||
private int weasyPrintSessionLimit;
|
||||
private int installAppSessionLimit;
|
||||
private int calibreSessionLimit;
|
||||
private int imageMagickSessionLimit;
|
||||
private int qpdfSessionLimit;
|
||||
private int tesseractSessionLimit;
|
||||
private int ghostscriptSessionLimit;
|
||||
private int ocrMyPdfSessionLimit;
|
||||
private int ffmpegSessionLimit;
|
||||
|
||||
public int getQpdfSessionLimit() {
|
||||
return qpdfSessionLimit > 0 ? qpdfSessionLimit : 2;
|
||||
@@ -1055,10 +689,6 @@ public class ApplicationProperties {
|
||||
return calibreSessionLimit > 0 ? calibreSessionLimit : 1;
|
||||
}
|
||||
|
||||
public int getImageMagickSessionLimit() {
|
||||
return imageMagickSessionLimit > 0 ? imageMagickSessionLimit : 4;
|
||||
}
|
||||
|
||||
public int getGhostscriptSessionLimit() {
|
||||
return ghostscriptSessionLimit > 0 ? ghostscriptSessionLimit : 8;
|
||||
}
|
||||
@@ -1066,10 +696,6 @@ public class ApplicationProperties {
|
||||
public int getOcrMyPdfSessionLimit() {
|
||||
return ocrMyPdfSessionLimit > 0 ? ocrMyPdfSessionLimit : 2;
|
||||
}
|
||||
|
||||
public int getFfmpegSessionLimit() {
|
||||
return ffmpegSessionLimit > 0 ? ffmpegSessionLimit : 2;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -1092,13 +718,10 @@ public class ApplicationProperties {
|
||||
@JsonProperty("calibretimeoutMinutes")
|
||||
private long calibreTimeoutMinutes;
|
||||
|
||||
private long imageMagickTimeoutMinutes;
|
||||
|
||||
private long tesseractTimeoutMinutes;
|
||||
private long qpdfTimeoutMinutes;
|
||||
private long ghostscriptTimeoutMinutes;
|
||||
private long ocrMyPdfTimeoutMinutes;
|
||||
private long ffmpegTimeoutMinutes;
|
||||
|
||||
public long getTesseractTimeoutMinutes() {
|
||||
return tesseractTimeoutMinutes > 0 ? tesseractTimeoutMinutes : 30;
|
||||
@@ -1132,10 +755,6 @@ public class ApplicationProperties {
|
||||
return calibreTimeoutMinutes > 0 ? calibreTimeoutMinutes : 30;
|
||||
}
|
||||
|
||||
public long getImageMagickTimeoutMinutes() {
|
||||
return imageMagickTimeoutMinutes > 0 ? imageMagickTimeoutMinutes : 30;
|
||||
}
|
||||
|
||||
public long getGhostscriptTimeoutMinutes() {
|
||||
return ghostscriptTimeoutMinutes > 0 ? ghostscriptTimeoutMinutes : 30;
|
||||
}
|
||||
@@ -1143,10 +762,6 @@ public class ApplicationProperties {
|
||||
public long getOcrMyPdfTimeoutMinutes() {
|
||||
return ocrMyPdfTimeoutMinutes > 0 ? ocrMyPdfTimeoutMinutes : 30;
|
||||
}
|
||||
|
||||
public long getFfmpegTimeoutMinutes() {
|
||||
return ffmpegTimeoutMinutes > 0 ? ffmpegTimeoutMinutes : 30;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,11 @@ public class FileInfo {
|
||||
// Formats the file size into a human-readable string.
|
||||
public String getFormattedFileSize() {
|
||||
if (fileSize >= 1024 * 1024 * 1024) {
|
||||
return String.format(Locale.ROOT, "%.2f GB", fileSize / (1024.0 * 1024 * 1024));
|
||||
return String.format(Locale.US, "%.2f GB", fileSize / (1024.0 * 1024 * 1024));
|
||||
} else if (fileSize >= 1024 * 1024) {
|
||||
return String.format(Locale.ROOT, "%.2f MB", fileSize / (1024.0 * 1024));
|
||||
return String.format(Locale.US, "%.2f MB", fileSize / (1024.0 * 1024));
|
||||
} else if (fileSize >= 1024) {
|
||||
return String.format(Locale.ROOT, "%.2f KB", fileSize / 1024.0);
|
||||
return String.format(Locale.US, "%.2f KB", fileSize / 1024.0);
|
||||
} else {
|
||||
return String.format("%d Bytes", fileSize);
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package stirling.software.common.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Form field information with coordinates for interactive form viewer. */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@Schema(description = "Form field with coordinates and metadata")
|
||||
public class FormFieldWithCoordinates {
|
||||
|
||||
@Schema(description = "Fully qualified field name", example = "form1.firstName")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "Display label for the field", example = "First Name")
|
||||
private String label;
|
||||
|
||||
@Schema(description = "Field type: text, checkbox, radio, combobox, listbox, button, signature")
|
||||
private String type;
|
||||
|
||||
@Schema(description = "Current field value")
|
||||
private String value;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Available options (export values) for choice fields"
|
||||
+ " (dropdown, radio, listbox)")
|
||||
private List<String> options;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Human-readable display labels for choice field options,"
|
||||
+ " parallel to the 'options' list. Null when identical to options.")
|
||||
private List<String> displayOptions;
|
||||
|
||||
@Schema(description = "Whether the field is required")
|
||||
private boolean required;
|
||||
|
||||
@Schema(description = "Whether the field is read-only")
|
||||
private boolean readOnly;
|
||||
|
||||
@Schema(description = "Whether this is a multi-select list box")
|
||||
private boolean multiSelect;
|
||||
|
||||
@Schema(description = "Whether this is a multi-line text field")
|
||||
private boolean multiline;
|
||||
|
||||
@Schema(description = "Tooltip/alternate name for the field")
|
||||
private String tooltip;
|
||||
|
||||
@Schema(description = "Widget coordinates on each page (fields can have multiple widgets)")
|
||||
private List<WidgetCoordinates> widgets;
|
||||
|
||||
/**
|
||||
* Coordinates for a single widget annotation (visual representation of the field). A field can
|
||||
* have multiple widgets if it appears on multiple pages.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@Schema(description = "Widget coordinates in PDF space")
|
||||
public static class WidgetCoordinates {
|
||||
|
||||
@Schema(description = "Page index (0-based)", example = "0")
|
||||
private int pageIndex;
|
||||
|
||||
@Schema(description = "X coordinate in PDF points (lower-left origin)")
|
||||
private float x;
|
||||
|
||||
@Schema(description = "Y coordinate in PDF points (lower-left origin)")
|
||||
private float y;
|
||||
|
||||
@Schema(description = "Width in PDF points")
|
||||
private float width;
|
||||
|
||||
@Schema(description = "Height in PDF points")
|
||||
private float height;
|
||||
|
||||
@Schema(description = "Export value for this widget (radio/checkbox buttons only)")
|
||||
private String exportValue;
|
||||
|
||||
@Schema(description = "Font size in PDF points")
|
||||
private Float fontSize;
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package stirling.software.common.model;
|
||||
|
||||
/* Commented out entire InputStreamTemplateResource class - Thymeleaf dependency removed
|
||||
* This class will be removed when frontend migration to React is complete
|
||||
|
||||
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@Getter
|
||||
public class InputStreamTemplateResource implements ITemplateResource {
|
||||
private final InputStream inputStream;
|
||||
private final String characterEncoding;
|
||||
|
||||
@Override
|
||||
public Reader reader() throws IOException {
|
||||
return new InputStreamReader(inputStream, characterEncoding);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ITemplateResource relative(String relativeLocation) {
|
||||
// Implement logic for relative resources, if needed
|
||||
throw new UnsupportedOperationException("Relative resources not supported");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return "InputStream resource [Stream]";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getBaseName() {
|
||||
return "streamResource";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists() {
|
||||
return inputStream != null;
|
||||
}
|
||||
}
|
||||
*/
|
||||
@@ -42,7 +42,7 @@ public enum Role {
|
||||
// Using the fromString method to get the Role enum based on the roleId
|
||||
Role role = fromString(roleId);
|
||||
// Return the roleName of the found Role enum
|
||||
return role.roleName;
|
||||
return role.getRoleName();
|
||||
}
|
||||
|
||||
// Method to retrieve all role IDs and role names
|
||||
@@ -50,14 +50,14 @@ public enum Role {
|
||||
// Using LinkedHashMap to preserve order
|
||||
Map<String, String> roleDetails = new LinkedHashMap<>();
|
||||
for (Role role : Role.values()) {
|
||||
roleDetails.put(role.roleId, role.roleName);
|
||||
roleDetails.put(role.getRoleId(), role.getRoleName());
|
||||
}
|
||||
return roleDetails;
|
||||
}
|
||||
|
||||
public static Role fromString(String roleId) {
|
||||
for (Role role : Role.values()) {
|
||||
if (role.roleId.equalsIgnoreCase(roleId)) {
|
||||
if (role.getRoleId().equalsIgnoreCase(roleId)) {
|
||||
return role;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,13 +117,13 @@ public class Provider {
|
||||
+ ", clientName="
|
||||
+ getClientName()
|
||||
+ ", clientId="
|
||||
+ clientId
|
||||
+ getClientId()
|
||||
+ ", clientSecret="
|
||||
+ (clientSecret != null && !clientSecret.isEmpty() ? "*****" : "NULL")
|
||||
+ (getClientSecret() != null && !getClientSecret().isEmpty() ? "*****" : "NULL")
|
||||
+ ", scopes="
|
||||
+ getScopes()
|
||||
+ ", useAsUsername="
|
||||
+ useAsUsername
|
||||
+ getUseAsUsername()
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
|
||||
+376
-566
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,6 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
@@ -23,9 +21,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@Slf4j
|
||||
public class FileStorage {
|
||||
|
||||
/** Holds the result of a stream-to-disk store operation: the file ID and the bytes written. */
|
||||
public record StoredFile(String fileId, long size) {}
|
||||
|
||||
@Value("${stirling.tempDir:/tmp/stirling-files}")
|
||||
private String tempDirPath;
|
||||
|
||||
@@ -109,40 +104,6 @@ public class FileStorage {
|
||||
return Files.readAllBytes(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a file by its ID as a streaming InputStream. The caller is responsible for closing
|
||||
* the returned stream.
|
||||
*
|
||||
* @param fileId The ID of the file to retrieve
|
||||
* @return A buffered InputStream for the file
|
||||
* @throws IOException If the file doesn't exist or can't be read
|
||||
*/
|
||||
public InputStream retrieveInputStream(String fileId) throws IOException {
|
||||
Path filePath = getFilePath(fileId);
|
||||
// Let Files.newInputStream throw NoSuchFileException naturally — avoids TOCTOU race
|
||||
// between exists-check and open when another thread may delete concurrently.
|
||||
return new BufferedInputStream(Files.newInputStream(filePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store data from an InputStream as a file and return its unique ID and byte count. Streams
|
||||
* directly to disk without buffering the entire content in heap.
|
||||
*
|
||||
* @param inputStream The input stream to read from
|
||||
* @param originalName The original name of the file (unused, kept for API symmetry)
|
||||
* @return A {@link StoredFile} containing the file ID and the number of bytes written
|
||||
* @throws IOException If there is an error storing the file
|
||||
*/
|
||||
public StoredFile storeInputStream(InputStream inputStream, String originalName)
|
||||
throws IOException {
|
||||
String fileId = generateFileId();
|
||||
Path filePath = getFilePath(fileId);
|
||||
Files.createDirectories(filePath.getParent());
|
||||
long size = Files.copy(inputStream, filePath);
|
||||
log.debug("Stored input stream with ID: {}", fileId);
|
||||
return new StoredFile(fileId, size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file by its ID
|
||||
*
|
||||
|
||||
@@ -35,7 +35,7 @@ public class JobExecutorService {
|
||||
private final HttpServletRequest request;
|
||||
private final ResourceMonitor resourceMonitor;
|
||||
private final JobQueue jobQueue;
|
||||
private final ExecutorService executor = ExecutorFactory.newVirtualThreadExecutor();
|
||||
private final ExecutorService executor = ExecutorFactory.newVirtualOrCachedThreadExecutor();
|
||||
private final long effectiveTimeoutMs;
|
||||
|
||||
@Autowired(required = false)
|
||||
@@ -253,25 +253,6 @@ public class JobExecutorService {
|
||||
log.error("Synchronous job timed out after {} ms", timeoutToUse);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("error", "Job timed out after " + timeoutToUse + " ms"));
|
||||
} catch (RuntimeException e) {
|
||||
// Check if this is a typed exception that should be handled by
|
||||
// GlobalExceptionHandler (either directly or wrapped)
|
||||
Throwable cause = e.getCause();
|
||||
if (e instanceof IllegalArgumentException
|
||||
|| cause
|
||||
instanceof
|
||||
stirling.software.common.util.ExceptionUtils.BaseAppException
|
||||
|| cause
|
||||
instanceof
|
||||
stirling.software.common.util.ExceptionUtils
|
||||
.BaseValidationException) {
|
||||
// Rethrow so GlobalExceptionHandler can handle with proper HTTP status codes
|
||||
throw e;
|
||||
}
|
||||
// Handle other RuntimeExceptions as generic errors
|
||||
log.error("Error executing synchronous job: {}", e.getMessage(), e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("error", "Job failed: " + e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Error executing synchronous job: {}", e.getMessage(), e);
|
||||
// Construct a JSON error response
|
||||
@@ -316,7 +297,7 @@ public class JobExecutorService {
|
||||
filename =
|
||||
disposition.substring(
|
||||
disposition.indexOf("filename=") + 9,
|
||||
disposition.lastIndexOf('"'));
|
||||
disposition.lastIndexOf("\""));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,10 +44,8 @@ public class JobQueue implements SmartLifecycle {
|
||||
|
||||
private volatile BlockingQueue<QueuedJob> jobQueue;
|
||||
private final Map<String, QueuedJob> jobMap = new ConcurrentHashMap<>();
|
||||
private final ScheduledExecutorService scheduler =
|
||||
Executors.newSingleThreadScheduledExecutor(
|
||||
Thread.ofVirtual().name("job-queue-scheduler-", 0).factory());
|
||||
private final ExecutorService jobExecutor = ExecutorFactory.newVirtualThreadExecutor();
|
||||
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||
private final ExecutorService jobExecutor = ExecutorFactory.newVirtualOrCachedThreadExecutor();
|
||||
private final Object queueLock = new Object(); // Lock for synchronizing queue operations
|
||||
|
||||
private boolean shuttingDown = false;
|
||||
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
/**
|
||||
* Interface for checking license status dynamically. Implementation provided by proprietary module
|
||||
* when available.
|
||||
*/
|
||||
public interface LicenseServiceInterface {
|
||||
|
||||
/**
|
||||
* Get the license type as a string.
|
||||
*
|
||||
* @return "NORMAL", "SERVER", or "ENTERPRISE"
|
||||
*/
|
||||
String getLicenseTypeName();
|
||||
|
||||
/**
|
||||
* Check if running Pro or higher (SERVER or ENTERPRISE license).
|
||||
*
|
||||
* @return true if SERVER or ENTERPRISE license is active
|
||||
*/
|
||||
boolean isRunningProOrHigher();
|
||||
|
||||
/**
|
||||
* Check if running Enterprise edition.
|
||||
*
|
||||
* @return true if ENTERPRISE license is active
|
||||
*/
|
||||
boolean isRunningEE();
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
|
||||
public interface LineArtConversionService {
|
||||
PDImageXObject convertImageToLineArt(
|
||||
PDDocument doc, PDImageXObject originalImage, double threshold, int edgeLevel)
|
||||
throws IOException;
|
||||
}
|
||||
@@ -1,443 +0,0 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Service for handling mobile scanner file uploads and temporary storage. Files are stored
|
||||
* temporarily and automatically cleaned up after 10 minutes or upon retrieval.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class MobileScannerService {
|
||||
|
||||
private static final long SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
private static final Pattern FILENAME_SANITIZE_PATTERN = Pattern.compile("[^a-zA-Z0-9._-]");
|
||||
private static final Pattern SESSION_ID_VALIDATION_PATTERN = Pattern.compile("[a-zA-Z0-9-]+");
|
||||
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("[.][^.]+$");
|
||||
private final Map<String, SessionData> activeSessions = new ConcurrentHashMap<>();
|
||||
private final Path tempDirectory;
|
||||
|
||||
public MobileScannerService() throws IOException {
|
||||
// Create temp directory for mobile scanner uploads
|
||||
this.tempDirectory =
|
||||
Paths.get(System.getProperty("java.io.tmpdir"), "stirling-mobile-scanner");
|
||||
Files.createDirectories(tempDirectory);
|
||||
log.info("Mobile scanner temp directory: {}", tempDirectory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new session (called by desktop when QR code is generated)
|
||||
*
|
||||
* @param sessionId Unique session identifier
|
||||
* @return SessionInfo with creation time and expiry
|
||||
*/
|
||||
public SessionInfo createSession(String sessionId) {
|
||||
validateSessionId(sessionId);
|
||||
|
||||
SessionData session = new SessionData(sessionId);
|
||||
activeSessions.put(sessionId, session);
|
||||
|
||||
log.info("Created mobile scanner session: {}", sessionId);
|
||||
return new SessionInfo(
|
||||
sessionId,
|
||||
session.createdAt,
|
||||
session.createdAt + SESSION_TIMEOUT_MS,
|
||||
SESSION_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a session exists and is not expired
|
||||
*
|
||||
* @param sessionId Session identifier to validate
|
||||
* @return SessionInfo if valid, null if invalid/expired
|
||||
*/
|
||||
public SessionInfo validateSession(String sessionId) {
|
||||
SessionData session = activeSessions.get(sessionId);
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
long expiryTime = session.getLastAccessTime() + SESSION_TIMEOUT_MS;
|
||||
|
||||
// Check if expired
|
||||
if (now > expiryTime) {
|
||||
deleteSession(sessionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
session.updateLastAccess();
|
||||
return new SessionInfo(sessionId, session.createdAt, expiryTime, SESSION_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores uploaded files for a session
|
||||
*
|
||||
* @param sessionId Unique session identifier
|
||||
* @param files Files to upload
|
||||
* @throws IOException If file storage fails
|
||||
*/
|
||||
public void uploadFiles(String sessionId, List<MultipartFile> files) throws IOException {
|
||||
validateSessionId(sessionId);
|
||||
|
||||
SessionData session =
|
||||
activeSessions.computeIfAbsent(sessionId, id -> new SessionData(sessionId));
|
||||
|
||||
// Create session directory
|
||||
Path sessionDir = getSafeSessionDirectory(sessionId);
|
||||
Files.createDirectories(sessionDir);
|
||||
|
||||
// Save each file
|
||||
for (MultipartFile file : files) {
|
||||
if (file.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
if (originalFilename == null || originalFilename.isBlank()) {
|
||||
originalFilename = "upload-" + System.currentTimeMillis();
|
||||
}
|
||||
|
||||
// Sanitize filename
|
||||
String safeFilename = sanitizeFilename(originalFilename);
|
||||
Path filePath = sessionDir.resolve(safeFilename).normalize().toAbsolutePath();
|
||||
|
||||
// Ensure resulting path stays within the session directory
|
||||
if (!filePath.startsWith(sessionDir)) {
|
||||
throw new IOException("Invalid filename");
|
||||
}
|
||||
|
||||
// Handle duplicate filenames
|
||||
int counter = 1;
|
||||
while (Files.exists(filePath)) {
|
||||
String nameWithoutExt =
|
||||
FILE_EXTENSION_PATTERN.matcher(safeFilename).replaceFirst("");
|
||||
String ext =
|
||||
safeFilename.contains(".")
|
||||
? safeFilename.substring(safeFilename.lastIndexOf('.'))
|
||||
: "";
|
||||
safeFilename = nameWithoutExt + "-" + counter + ext;
|
||||
filePath = sessionDir.resolve(safeFilename).normalize().toAbsolutePath();
|
||||
if (!filePath.startsWith(sessionDir)) {
|
||||
throw new IOException("Invalid filename");
|
||||
}
|
||||
counter++;
|
||||
}
|
||||
|
||||
file.transferTo(filePath);
|
||||
session.addFile(new FileMetadata(safeFilename, file.getSize(), file.getContentType()));
|
||||
log.info(
|
||||
"Uploaded file for session {}: {} ({} bytes)",
|
||||
sessionId,
|
||||
safeFilename,
|
||||
file.getSize());
|
||||
}
|
||||
|
||||
session.updateLastAccess();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves file metadata for a session
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @return List of file metadata, or empty list if session doesn't exist
|
||||
*/
|
||||
public List<FileMetadata> getSessionFiles(String sessionId) {
|
||||
SessionData session = activeSessions.get(sessionId);
|
||||
if (session == null) {
|
||||
return List.of();
|
||||
}
|
||||
session.updateLastAccess();
|
||||
return new ArrayList<>(session.getFiles());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves actual file data for download
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @param filename Filename to retrieve
|
||||
* @return File path
|
||||
* @throws IOException If file not found or session doesn't exist
|
||||
*/
|
||||
public Path getFile(String sessionId, String filename) throws IOException {
|
||||
SessionData session = activeSessions.get(sessionId);
|
||||
if (session == null) {
|
||||
throw new IOException("Session not found: " + sessionId);
|
||||
}
|
||||
|
||||
Path filePath = getSafeFilePath(sessionId, filename);
|
||||
if (!Files.exists(filePath)) {
|
||||
throw new IOException("File not found: " + filename);
|
||||
}
|
||||
|
||||
session.updateLastAccess();
|
||||
session.markFileAsDownloaded(filename);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a file after it has been served to the client. Should be called after successful
|
||||
* download.
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @param filename Filename to delete
|
||||
*/
|
||||
public void deleteFileAfterDownload(String sessionId, String filename) {
|
||||
try {
|
||||
Path filePath = getSafeFilePath(sessionId, filename);
|
||||
Files.deleteIfExists(filePath);
|
||||
log.info("Deleted file after download: {}/{}", sessionId, filename);
|
||||
|
||||
// Check if all files have been downloaded - if so, delete the entire session
|
||||
SessionData session = activeSessions.get(sessionId);
|
||||
if (session != null && session.allFilesDownloaded()) {
|
||||
deleteSession(sessionId);
|
||||
log.info("All files downloaded - deleted session: {}", sessionId);
|
||||
}
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
log.warn("Failed to delete file after download: {}/{}", sessionId, filename, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a session and all its files
|
||||
*
|
||||
* @param sessionId Session to delete
|
||||
*/
|
||||
public void deleteSession(String sessionId) {
|
||||
SessionData session = activeSessions.remove(sessionId);
|
||||
if (session != null) {
|
||||
try {
|
||||
Path sessionDir = getSafeSessionDirectory(sessionId);
|
||||
if (Files.exists(sessionDir)) {
|
||||
// Delete all files in session directory
|
||||
Files.walk(sessionDir)
|
||||
.sorted(
|
||||
(a, b) ->
|
||||
-a.compareTo(b)) // Reverse order to delete files before
|
||||
// directory
|
||||
.forEach(
|
||||
path -> {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete file: {}", path, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
log.info("Deleted session: {}", sessionId);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn(
|
||||
"Refused to delete session with invalid sessionId '{}': {}",
|
||||
sessionId,
|
||||
e.getMessage());
|
||||
} catch (IOException e) {
|
||||
log.error("Error deleting session directory: {}", sessionId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Scheduled cleanup of expired sessions (runs every 5 minutes) */
|
||||
@Scheduled(fixedRate = 5 * 60 * 1000)
|
||||
public void cleanupExpiredSessions() {
|
||||
long now = System.currentTimeMillis();
|
||||
List<String> expiredSessions = new ArrayList<>();
|
||||
|
||||
activeSessions.forEach(
|
||||
(sessionId, session) -> {
|
||||
if (now - session.getLastAccessTime() > SESSION_TIMEOUT_MS) {
|
||||
expiredSessions.add(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
if (!expiredSessions.isEmpty()) {
|
||||
log.info("Cleaning up {} expired mobile scanner sessions", expiredSessions.size());
|
||||
expiredSessions.forEach(this::deleteSession);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateSessionId(String sessionId) {
|
||||
if (sessionId == null || sessionId.isBlank()) {
|
||||
throw new IllegalArgumentException("Session ID cannot be empty");
|
||||
}
|
||||
// Basic validation: alphanumeric and hyphens only
|
||||
if (!SESSION_ID_VALIDATION_PATTERN.matcher(sessionId).matches()) {
|
||||
throw new IllegalArgumentException("Invalid session ID format");
|
||||
}
|
||||
}
|
||||
|
||||
private String sanitizeFilename(String filename) {
|
||||
// Remove path traversal attempts and dangerous characters
|
||||
String sanitized = FILENAME_SANITIZE_PATTERN.matcher(filename).replaceAll("_");
|
||||
// Ensure we have a non-empty, safe filename
|
||||
if (sanitized.isBlank()) {
|
||||
sanitized = "upload-" + System.currentTimeMillis();
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely resolves and validates a session directory path to prevent directory traversal
|
||||
* attacks.
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @return Normalized absolute path to the session directory
|
||||
* @throws IllegalArgumentException if the resolved path escapes the temp directory
|
||||
*/
|
||||
private Path getSafeSessionDirectory(String sessionId) {
|
||||
validateSessionId(sessionId);
|
||||
|
||||
Path baseDir = tempDirectory.normalize().toAbsolutePath();
|
||||
Path sessionDir = baseDir.resolve(sessionId).normalize().toAbsolutePath();
|
||||
|
||||
// Verify the resolved path is still within the temp directory
|
||||
if (!sessionDir.startsWith(baseDir)) {
|
||||
throw new IllegalArgumentException("Invalid session ID: path traversal detected");
|
||||
}
|
||||
|
||||
return sessionDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely resolves and validates a file path within a session directory to prevent directory
|
||||
* traversal attacks.
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @param filename Filename within the session
|
||||
* @return Normalized absolute path to the file
|
||||
* @throws IOException if the resolved path escapes the session directory
|
||||
*/
|
||||
private Path getSafeFilePath(String sessionId, String filename) throws IOException {
|
||||
if (filename == null || filename.isBlank()) {
|
||||
throw new IOException("Filename cannot be empty");
|
||||
}
|
||||
|
||||
// Additional validation: reject filenames with path separators or parent references
|
||||
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
|
||||
throw new IOException(
|
||||
"Invalid filename: contains path separators or parent references");
|
||||
}
|
||||
|
||||
Path sessionDir = getSafeSessionDirectory(sessionId);
|
||||
Path filePath = sessionDir.resolve(filename).normalize().toAbsolutePath();
|
||||
|
||||
// Verify the resolved path is still within the session directory
|
||||
if (!filePath.startsWith(sessionDir)) {
|
||||
throw new IOException("Invalid filename: path traversal detected");
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/** Session information for client */
|
||||
public static class SessionInfo {
|
||||
private final String sessionId;
|
||||
private final long createdAt;
|
||||
private final long expiresAt;
|
||||
private final long timeoutMs;
|
||||
|
||||
public SessionInfo(String sessionId, long createdAt, long expiresAt, long timeoutMs) {
|
||||
this.sessionId = sessionId;
|
||||
this.createdAt = createdAt;
|
||||
this.expiresAt = expiresAt;
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
public String getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
public long getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public long getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public long getTimeoutMs() {
|
||||
return timeoutMs;
|
||||
}
|
||||
}
|
||||
|
||||
/** File metadata for client */
|
||||
public static class FileMetadata {
|
||||
private final String filename;
|
||||
private final long size;
|
||||
private final String contentType;
|
||||
|
||||
public FileMetadata(String filename, long size, String contentType) {
|
||||
this.filename = filename;
|
||||
this.size = size;
|
||||
this.contentType = contentType;
|
||||
}
|
||||
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
|
||||
public long getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
}
|
||||
|
||||
/** Session data tracking */
|
||||
private static class SessionData {
|
||||
private final String sessionId;
|
||||
private final List<FileMetadata> files = new ArrayList<>();
|
||||
private final Map<String, Boolean> downloadedFiles = new HashMap<>();
|
||||
private final long createdAt;
|
||||
private long lastAccessTime;
|
||||
|
||||
public SessionData(String sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
this.createdAt = System.currentTimeMillis();
|
||||
this.lastAccessTime = createdAt;
|
||||
}
|
||||
|
||||
public void addFile(FileMetadata file) {
|
||||
files.add(file);
|
||||
downloadedFiles.put(file.getFilename(), false);
|
||||
}
|
||||
|
||||
public List<FileMetadata> getFiles() {
|
||||
return files;
|
||||
}
|
||||
|
||||
public void markFileAsDownloaded(String filename) {
|
||||
downloadedFiles.put(filename, true);
|
||||
}
|
||||
|
||||
public boolean allFilesDownloaded() {
|
||||
return !downloadedFiles.isEmpty()
|
||||
&& downloadedFiles.values().stream().allMatch(downloaded -> downloaded);
|
||||
}
|
||||
|
||||
public void updateLastAccess() {
|
||||
this.lastAccessTime = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
public long getLastAccessTime() {
|
||||
return lastAccessTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,10 +169,7 @@ public class PdfMetadataService {
|
||||
.getAuthor();
|
||||
|
||||
if (userService != null) {
|
||||
String username = userService.getCurrentUsername();
|
||||
if (username != null) {
|
||||
author = author.replace("username", username);
|
||||
}
|
||||
author = author.replace("username", userService.getCurrentUsername());
|
||||
}
|
||||
}
|
||||
pdf.getDocumentInformation().setAuthor(author);
|
||||
|
||||
@@ -253,7 +253,7 @@ public class PostHogService {
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"security_enableLogin",
|
||||
applicationProperties.getSecurity().isEnableLogin());
|
||||
applicationProperties.getSecurity().getEnableLogin());
|
||||
addIfNotEmpty(properties, "security_csrfDisabled", true);
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
@@ -299,13 +299,13 @@ public class PostHogService {
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"system_googlevisibility",
|
||||
applicationProperties.getSystem().isGooglevisibility());
|
||||
applicationProperties.getSystem().getGooglevisibility());
|
||||
addIfNotEmpty(
|
||||
properties, "system_showUpdate", applicationProperties.getSystem().isShowUpdate());
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"system_showUpdateOnlyAdmin",
|
||||
applicationProperties.getSystem().isShowUpdateOnlyAdmin());
|
||||
applicationProperties.getSystem().getShowUpdateOnlyAdmin());
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"system_customHTMLFiles",
|
||||
@@ -317,7 +317,7 @@ public class PostHogService {
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"system_enableAlphaFunctionality",
|
||||
applicationProperties.getSystem().isEnableAlphaFunctionality());
|
||||
applicationProperties.getSystem().getEnableAlphaFunctionality());
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"system_enableAnalytics",
|
||||
@@ -337,7 +337,7 @@ public class PostHogService {
|
||||
|
||||
// Capture Metrics properties
|
||||
addIfNotEmpty(
|
||||
properties, "metrics_enabled", applicationProperties.getMetrics().isEnabled());
|
||||
properties, "metrics_enabled", applicationProperties.getMetrics().getEnabled());
|
||||
|
||||
// Capture EnterpriseEdition properties
|
||||
addIfNotEmpty(
|
||||
|
||||
@@ -5,7 +5,6 @@ import java.lang.management.MemoryMXBean;
|
||||
import java.lang.management.OperatingSystemMXBean;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -43,9 +42,7 @@ public class ResourceMonitor {
|
||||
@Value("${stirling.resource.monitor.interval-ms:60000}")
|
||||
private long monitorIntervalMs = 60000; // 60 seconds
|
||||
|
||||
private final ScheduledExecutorService scheduler =
|
||||
Executors.newSingleThreadScheduledExecutor(
|
||||
Thread.ofVirtual().name("resource-monitor-", 0).factory());
|
||||
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||
private final MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean();
|
||||
private final OperatingSystemMXBean osMXBean = ManagementFactory.getOperatingSystemMXBean();
|
||||
|
||||
@@ -176,8 +173,8 @@ public class ResourceMonitor {
|
||||
log.info("System resource status changed from {} to {}", oldStatus, newStatus);
|
||||
log.info(
|
||||
"Current metrics - CPU: {}%, Memory: {}%, Free Memory: {} MB",
|
||||
String.format(Locale.ROOT, "%.1f", cpuUsage * 100),
|
||||
String.format(Locale.ROOT, "%.1f", memoryUsage * 100),
|
||||
String.format("%.1f", cpuUsage * 100),
|
||||
String.format("%.1f", memoryUsage * 100),
|
||||
freeMemory / (1024 * 1024));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
|
||||
+6
-22
@@ -5,7 +5,6 @@ import java.net.Inet6Address;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -84,7 +83,7 @@ public class SsrfProtectionService {
|
||||
return false;
|
||||
}
|
||||
|
||||
return config.getAllowedDomains().contains(host.toLowerCase(Locale.ROOT));
|
||||
return config.getAllowedDomains().contains(host.toLowerCase());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to parse URL for MAX security check: {}", url, e);
|
||||
@@ -102,7 +101,7 @@ public class SsrfProtectionService {
|
||||
return false;
|
||||
}
|
||||
|
||||
String hostLower = host.toLowerCase(Locale.ROOT);
|
||||
String hostLower = host.toLowerCase();
|
||||
|
||||
// Check explicit blocked domains
|
||||
if (config.getBlockedDomains().contains(hostLower)) {
|
||||
@@ -112,7 +111,7 @@ public class SsrfProtectionService {
|
||||
|
||||
// Check internal TLD patterns
|
||||
for (String tld : config.getInternalTlds()) {
|
||||
if (hostLower.endsWith(tld.toLowerCase(Locale.ROOT))) {
|
||||
if (hostLower.endsWith(tld.toLowerCase())) {
|
||||
log.debug("URL blocked by internal TLD pattern '{}': {}", tld, url);
|
||||
return false;
|
||||
}
|
||||
@@ -124,11 +123,9 @@ public class SsrfProtectionService {
|
||||
config.getAllowedDomains().stream()
|
||||
.anyMatch(
|
||||
domain ->
|
||||
hostLower.equals(domain.toLowerCase(Locale.ROOT))
|
||||
hostLower.equals(domain.toLowerCase())
|
||||
|| hostLower.endsWith(
|
||||
"."
|
||||
+ domain.toLowerCase(
|
||||
Locale.ROOT)));
|
||||
"." + domain.toLowerCase()));
|
||||
|
||||
if (!isAllowed) {
|
||||
log.debug("URL not in allowed domains list: {}", url);
|
||||
@@ -226,11 +223,10 @@ public class SsrfProtectionService {
|
||||
}
|
||||
|
||||
private boolean isPrivateIPv4Range(String ip) {
|
||||
// Includes RFC1918, RFC6598, loopback, link-local, and unspecified addresses
|
||||
// Includes RFC1918, loopback, link-local, and unspecified addresses
|
||||
return ip.startsWith("10.")
|
||||
|| ip.startsWith("192.168.")
|
||||
|| (ip.startsWith("172.") && isInRange172(ip))
|
||||
|| (ip.startsWith("100.") && isInRange100(ip))
|
||||
|| ip.startsWith("169.254.")
|
||||
|| ip.startsWith("127.")
|
||||
|| "0.0.0.0".equals(ip);
|
||||
@@ -248,18 +244,6 @@ public class SsrfProtectionService {
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isInRange100(String ip) {
|
||||
String[] parts = ip.split("\\.");
|
||||
if (parts.length >= 2) {
|
||||
try {
|
||||
int secondOctet = Integer.parseInt(parts[1]);
|
||||
return secondOctet >= 64 && secondOctet <= 127;
|
||||
} catch (NumberFormatException e) {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isCloudMetadataAddress(String ip) {
|
||||
String normalizedIp = normalizeIpv4MappedAddress(ip);
|
||||
// Cloud metadata endpoints for AWS, GCP, Azure, Oracle Cloud, and IBM Cloud
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
@@ -19,6 +18,7 @@ import java.util.zip.ZipInputStream;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.ZipSecurity;
|
||||
|
||||
@@ -41,8 +41,7 @@ public class TaskManager {
|
||||
|
||||
private final FileStorage fileStorage;
|
||||
private final ScheduledExecutorService cleanupExecutor =
|
||||
Executors.newSingleThreadScheduledExecutor(
|
||||
Thread.ofVirtual().name("task-cleanup-", 0).factory());
|
||||
Executors.newSingleThreadScheduledExecutor();
|
||||
|
||||
/** Initialize the task manager and start the cleanup scheduler */
|
||||
public TaskManager(FileStorage fileStorage) {
|
||||
@@ -102,16 +101,14 @@ public class TaskManager {
|
||||
if (!extractedFiles.isEmpty()) {
|
||||
jobResult.completeWithFiles(extractedFiles);
|
||||
log.debug(
|
||||
"Set multiple file results for job ID: {} with {} files extracted from"
|
||||
+ " ZIP",
|
||||
"Set multiple file results for job ID: {} with {} files extracted from ZIP",
|
||||
jobId,
|
||||
extractedFiles.size());
|
||||
return;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to extract ZIP file for job {}: {}. Falling back to single file"
|
||||
+ " result.",
|
||||
"Failed to extract ZIP file for job {}: {}. Falling back to single file result.",
|
||||
jobId,
|
||||
e.getMessage());
|
||||
}
|
||||
@@ -345,12 +342,12 @@ public class TaskManager {
|
||||
/** Check if a file is a ZIP file based on content type and filename */
|
||||
private boolean isZipFile(String contentType, String fileName) {
|
||||
if (contentType != null
|
||||
&& ("application/zip".equals(contentType)
|
||||
|| "application/x-zip-compressed".equals(contentType))) {
|
||||
&& (contentType.equals("application/zip")
|
||||
|| contentType.equals("application/x-zip-compressed"))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fileName != null && fileName.toLowerCase(Locale.ROOT).endsWith(".zip")) {
|
||||
if (fileName != null && fileName.toLowerCase().endsWith(".zip")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -362,29 +359,39 @@ public class TaskManager {
|
||||
String zipFileId, String originalZipFileName) throws IOException {
|
||||
List<ResultFile> extractedFiles = new ArrayList<>();
|
||||
|
||||
try (InputStream fileStream = fileStorage.retrieveInputStream(zipFileId);
|
||||
ZipInputStream zipIn =
|
||||
ZipSecurity.createHardenedInputStream(
|
||||
new BufferedInputStream(fileStream))) {
|
||||
MultipartFile zipFile = fileStorage.retrieveFile(zipFileId);
|
||||
|
||||
try (ZipInputStream zipIn =
|
||||
ZipSecurity.createHardenedInputStream(
|
||||
new ByteArrayInputStream(zipFile.getBytes()))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zipIn.getNextEntry()) != null) {
|
||||
if (!entry.isDirectory()) {
|
||||
// Use buffered reading for memory safety
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[4096];
|
||||
int bytesRead;
|
||||
while ((bytesRead = zipIn.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, bytesRead);
|
||||
}
|
||||
byte[] fileContent = out.toByteArray();
|
||||
|
||||
String contentType = determineContentType(entry.getName());
|
||||
// storeInputStream returns the fileId and byte count — no extra stat needed
|
||||
FileStorage.StoredFile stored =
|
||||
fileStorage.storeInputStream(zipIn, entry.getName());
|
||||
String individualFileId = fileStorage.storeBytes(fileContent, entry.getName());
|
||||
|
||||
ResultFile resultFile =
|
||||
ResultFile.builder()
|
||||
.fileId(stored.fileId())
|
||||
.fileId(individualFileId)
|
||||
.fileName(entry.getName())
|
||||
.contentType(contentType)
|
||||
.fileSize(stored.size())
|
||||
.fileSize(fileContent.length)
|
||||
.build();
|
||||
|
||||
extractedFiles.add(resultFile);
|
||||
log.debug(
|
||||
"Extracted file: {} (size: {} bytes)", entry.getName(), stored.size());
|
||||
"Extracted file: {} (size: {} bytes)",
|
||||
entry.getName(),
|
||||
fileContent.length);
|
||||
}
|
||||
zipIn.closeEntry();
|
||||
}
|
||||
@@ -407,7 +414,7 @@ public class TaskManager {
|
||||
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
|
||||
}
|
||||
|
||||
String lowerName = fileName.toLowerCase(Locale.ROOT);
|
||||
String lowerName = fileName.toLowerCase();
|
||||
if (lowerName.endsWith(".pdf")) {
|
||||
return MediaType.APPLICATION_PDF_VALUE;
|
||||
} else if (lowerName.endsWith(".txt")) {
|
||||
@@ -456,24 +463,4 @@ public class TaskManager {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the job key that owns a given file ID.
|
||||
*
|
||||
* @param fileId file identifier to look up
|
||||
* @return scoped job key if found, otherwise null
|
||||
*/
|
||||
public String findJobKeyByFileId(String fileId) {
|
||||
for (Map.Entry<String, JobResult> entry : jobResults.entrySet()) {
|
||||
JobResult jobResult = entry.getValue();
|
||||
if (jobResult.hasFiles()) {
|
||||
for (ResultFile resultFile : jobResult.getAllResultFiles()) {
|
||||
if (fileId.equals(resultFile.getFileId())) {
|
||||
return entry.getKey();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
@@ -59,24 +58,31 @@ public class CbrUtils {
|
||||
log.warn(
|
||||
"Failed to open CBR/RAR archive due to corrupt header: {}",
|
||||
e.getMessage());
|
||||
throw ExceptionUtils.createCbrInvalidFormatException(null);
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid or corrupted CBR/RAR archive. "
|
||||
+ "The file may be corrupted, use an unsupported RAR format (RAR5+), "
|
||||
+ "or may not be a valid RAR archive. "
|
||||
+ "Please ensure the file is a valid RAR archive.");
|
||||
} catch (RarException e) {
|
||||
log.warn("Failed to open CBR/RAR archive: {}", e.getMessage());
|
||||
String errorMessage;
|
||||
String exMessage = e.getMessage() != null ? e.getMessage() : "";
|
||||
|
||||
if (exMessage.contains("encrypted")) {
|
||||
throw ExceptionUtils.createCbrEncryptedException();
|
||||
errorMessage = "Encrypted CBR/RAR archives are not supported.";
|
||||
} else if (exMessage.isEmpty()) {
|
||||
throw ExceptionUtils.createCbrInvalidFormatException(
|
||||
"Invalid CBR/RAR archive. The file may be encrypted, corrupted, or"
|
||||
+ " use an unsupported format.");
|
||||
errorMessage =
|
||||
"Invalid CBR/RAR archive. "
|
||||
+ "The file may be encrypted, corrupted, or use an unsupported format.";
|
||||
} else {
|
||||
throw ExceptionUtils.createCbrInvalidFormatException(
|
||||
errorMessage =
|
||||
"Invalid CBR/RAR archive: "
|
||||
+ exMessage
|
||||
+ ". The file may be encrypted, corrupted, or use an"
|
||||
+ " unsupported format.");
|
||||
+ ". The file may be encrypted, corrupted, or use an unsupported format.";
|
||||
}
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat", errorMessage);
|
||||
} catch (IOException e) {
|
||||
log.warn("IO error reading CBR/RAR archive: {}", e.getMessage());
|
||||
throw ExceptionUtils.createFileProcessingException("CBR extraction", e);
|
||||
@@ -115,8 +121,7 @@ public class CbrUtils {
|
||||
if (imageEntries.isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.fileProcessing",
|
||||
"No valid images found in the CBR file. The archive may be empty or"
|
||||
+ " contain no supported image formats.");
|
||||
"No valid images found in the CBR file. The archive may be empty or contain no supported image formats.");
|
||||
}
|
||||
|
||||
for (ImageEntryData imageEntry : imageEntries) {
|
||||
@@ -129,12 +134,7 @@ public class CbrUtils {
|
||||
new PDRectangle(pdImage.getWidth(), pdImage.getHeight()));
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true,
|
||||
true)) {
|
||||
new PDPageContentStream(document, page)) {
|
||||
contentStream.drawImage(pdImage, 0, 0);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
@@ -146,8 +146,7 @@ public class CbrUtils {
|
||||
if (document.getNumberOfPages() == 0) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.fileProcessing",
|
||||
"No images could be processed from the CBR file. All images may be"
|
||||
+ " corrupted or in unsupported formats.");
|
||||
"No images could be processed from the CBR file. All images may be corrupted or in unsupported formats.");
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
@@ -160,6 +159,7 @@ public class CbrUtils {
|
||||
return GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
|
||||
} catch (IOException e) {
|
||||
log.warn("Ghostscript optimization failed, returning unoptimized PDF", e);
|
||||
return pdfBytes;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,17 +170,17 @@ public class CbrUtils {
|
||||
|
||||
private void validateCbrFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw ExceptionUtils.createFileNullOrEmptyException();
|
||||
throw new IllegalArgumentException("File cannot be null or empty");
|
||||
}
|
||||
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
throw ExceptionUtils.createFileNoNameException();
|
||||
throw new IllegalArgumentException("File must have a name");
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
if (!"cbr".equals(extension) && !"rar".equals(extension)) {
|
||||
throw ExceptionUtils.createNotCbrFileException();
|
||||
throw new IllegalArgumentException("File must be a CBR or RAR archive");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ public class CbrUtils {
|
||||
return false;
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
return "cbr".equals(extension) || "rar".equals(extension);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,10 @@ import java.io.BufferedInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
import java.util.zip.ZipInputStream;
|
||||
@@ -31,7 +29,15 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
@UtilityClass
|
||||
public class CbzUtils {
|
||||
|
||||
public TempFile convertCbzToPdf(
|
||||
public byte[] convertCbzToPdf(
|
||||
MultipartFile cbzFile,
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
TempFileManager tempFileManager)
|
||||
throws IOException {
|
||||
return convertCbzToPdf(cbzFile, pdfDocumentFactory, tempFileManager, false);
|
||||
}
|
||||
|
||||
public byte[] convertCbzToPdf(
|
||||
MultipartFile cbzFile,
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
TempFileManager tempFileManager,
|
||||
@@ -49,115 +55,92 @@ public class CbzUtils {
|
||||
new java.io.FileInputStream(tempFile.getFile()));
|
||||
ZipInputStream zis = new ZipInputStream(bis)) {
|
||||
if (zis.getNextEntry() == null) {
|
||||
throw ExceptionUtils.createCbzEmptyException();
|
||||
throw new IllegalArgumentException("Archive is empty or invalid ZIP");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw ExceptionUtils.createCbzInvalidFormatException(e);
|
||||
throw new IllegalArgumentException("Invalid CBZ/ZIP archive", e);
|
||||
}
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.createNewDocument();
|
||||
ZipFile zipFile = new ZipFile(tempFile.getFile())) {
|
||||
|
||||
// Pass 1: collect sorted image names (cheap just strings, no image data)
|
||||
List<String> sortedImageNames = new ArrayList<>();
|
||||
Enumeration<? extends ZipEntry> entries = zipFile.entries();
|
||||
List<ImageEntryData> imageEntries = new ArrayList<>();
|
||||
while (entries.hasMoreElements()) {
|
||||
ZipEntry entry = entries.nextElement();
|
||||
if (!entry.isDirectory() && isImageFile(entry.getName())) {
|
||||
sortedImageNames.add(entry.getName());
|
||||
try (InputStream is = zipFile.getInputStream(entry)) {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
is.transferTo(baos);
|
||||
imageEntries.add(
|
||||
new ImageEntryData(entry.getName(), baos.toByteArray()));
|
||||
} catch (IOException e) {
|
||||
log.warn("Error reading image {}: {}", entry.getName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
sortedImageNames.sort(new NaturalOrderComparator());
|
||||
|
||||
if (sortedImageNames.isEmpty()) {
|
||||
throw ExceptionUtils.createCbzNoImagesException();
|
||||
imageEntries.sort(
|
||||
Comparator.comparing(ImageEntryData::name, new NaturalOrderComparator()));
|
||||
|
||||
if (imageEntries.isEmpty()) {
|
||||
throw new IllegalArgumentException("No valid images found in the CBZ file");
|
||||
}
|
||||
|
||||
// Pass 2: load ONE image at a time peak memory = max(single image)
|
||||
for (String imageName : sortedImageNames) {
|
||||
ZipEntry entry = zipFile.getEntry(imageName);
|
||||
try (InputStream is = zipFile.getInputStream(entry)) {
|
||||
ByteArrayOutputStream imgBaos = new ByteArrayOutputStream();
|
||||
is.transferTo(imgBaos);
|
||||
byte[] imageBytes = imgBaos.toByteArray();
|
||||
try {
|
||||
PDImageXObject pdImage =
|
||||
PDImageXObject.createFromByteArray(
|
||||
document, imageBytes, imageName);
|
||||
PDPage page =
|
||||
new PDPage(
|
||||
new PDRectangle(
|
||||
pdImage.getWidth(), pdImage.getHeight()));
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.OVERWRITE,
|
||||
true,
|
||||
true)) {
|
||||
contentStream.drawImage(pdImage, 0, 0);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Error processing image {}: {}", imageName, e.getMessage());
|
||||
for (ImageEntryData imageEntry : imageEntries) {
|
||||
try {
|
||||
PDImageXObject pdImage =
|
||||
PDImageXObject.createFromByteArray(
|
||||
document, imageEntry.data(), imageEntry.name());
|
||||
PDPage page =
|
||||
new PDPage(
|
||||
new PDRectangle(pdImage.getWidth(), pdImage.getHeight()));
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(document, page)) {
|
||||
contentStream.drawImage(pdImage, 0, 0);
|
||||
}
|
||||
// imageBytes eligible for GC after each iteration
|
||||
} catch (IOException e) {
|
||||
log.warn("Error reading image {}: {}", imageName, e.getMessage());
|
||||
log.warn(
|
||||
"Error processing image {}: {}", imageEntry.name(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (document.getNumberOfPages() == 0) {
|
||||
throw ExceptionUtils.createCbzCorruptedImagesException();
|
||||
throw new IllegalArgumentException(
|
||||
"No images could be processed from the CBZ file");
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
byte[] pdfBytes = baos.toByteArray();
|
||||
|
||||
// Write to TempFile (not BAOS)
|
||||
TempFile pdfTempFile = new TempFile(tempFileManager, ".pdf");
|
||||
try {
|
||||
document.save(pdfTempFile.getFile());
|
||||
|
||||
if (optimizeForEbook) {
|
||||
try {
|
||||
byte[] pdfBytes = Files.readAllBytes(pdfTempFile.getPath());
|
||||
byte[] optimized = GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
|
||||
pdfTempFile.close();
|
||||
TempFile optimizedFile = new TempFile(tempFileManager, ".pdf");
|
||||
try {
|
||||
Files.write(optimizedFile.getPath(), optimized);
|
||||
return optimizedFile;
|
||||
} catch (Exception e) {
|
||||
optimizedFile.close();
|
||||
throw e;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Ghostscript optimization failed, returning unoptimized PDF",
|
||||
e);
|
||||
}
|
||||
// Apply Ghostscript optimization if requested
|
||||
if (optimizeForEbook) {
|
||||
try {
|
||||
return GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
|
||||
} catch (IOException e) {
|
||||
log.warn("Ghostscript optimization failed, returning unoptimized PDF", e);
|
||||
return pdfBytes;
|
||||
}
|
||||
|
||||
return pdfTempFile;
|
||||
} catch (Exception e) {
|
||||
pdfTempFile.close();
|
||||
throw e;
|
||||
}
|
||||
|
||||
return pdfBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateCbzFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw ExceptionUtils.createFileNullOrEmptyException();
|
||||
throw new IllegalArgumentException("File cannot be null or empty");
|
||||
}
|
||||
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
throw ExceptionUtils.createFileNoNameException();
|
||||
throw new IllegalArgumentException("File must have a name");
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
if (!"cbz".equals(extension) && !"zip".equals(extension)) {
|
||||
throw ExceptionUtils.createNotCbzFileException();
|
||||
throw new IllegalArgumentException("File must be a CBZ or ZIP archive");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +150,7 @@ public class CbzUtils {
|
||||
return false;
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
return "cbz".equals(extension) || "zip".equals(extension);
|
||||
}
|
||||
|
||||
@@ -177,7 +160,7 @@ public class CbzUtils {
|
||||
return false;
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
return "cbz".equals(extension)
|
||||
|| "zip".equals(extension)
|
||||
|| "cbr".equals(extension)
|
||||
@@ -188,6 +171,8 @@ public class CbzUtils {
|
||||
return RegexPatternUtils.getInstance().getImageFilePattern().matcher(filename).matches();
|
||||
}
|
||||
|
||||
private record ImageEntryData(String name, byte[] data) {}
|
||||
|
||||
private class NaturalOrderComparator implements Comparator<String> {
|
||||
@Override
|
||||
public int compare(String s1, String s2) {
|
||||
|
||||
@@ -11,8 +11,6 @@ public class CheckProgramInstall {
|
||||
private static final List<String> PYTHON_COMMANDS = Arrays.asList("python3", "python");
|
||||
private static boolean pythonAvailableChecked = false;
|
||||
private static String availablePythonCommand = null;
|
||||
private static boolean ffmpegAvailableChecked = false;
|
||||
private static boolean ffmpegAvailable = false;
|
||||
|
||||
/**
|
||||
* Checks which Python command is available and returns it.
|
||||
@@ -58,25 +56,4 @@ public class CheckProgramInstall {
|
||||
public static boolean isPythonAvailable() {
|
||||
return getAvailablePythonCommand() != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if FFmpeg is available on the system.
|
||||
*
|
||||
* @return true if FFmpeg is installed and accessible, false otherwise.
|
||||
*/
|
||||
public static boolean isFfmpegAvailable() {
|
||||
if (!ffmpegAvailableChecked) {
|
||||
try {
|
||||
ProcessExecutorResult result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.FFMPEG)
|
||||
.runCommandWithOutputHandling(Arrays.asList("ffmpeg", "-version"));
|
||||
ffmpegAvailable = true;
|
||||
} catch (IOException | InterruptedException e) {
|
||||
ffmpegAvailable = false;
|
||||
} finally {
|
||||
ffmpegAvailableChecked = true;
|
||||
}
|
||||
}
|
||||
return ffmpegAvailable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,11 +58,14 @@ public class ChecksumUtils {
|
||||
* @throws IOException if reading from the stream fails
|
||||
*/
|
||||
public static String checksum(InputStream is, String algorithm) throws IOException {
|
||||
return switch (algorithm.toUpperCase(Locale.ROOT)) {
|
||||
case "CRC32" -> checksumChecksum(is, new CRC32());
|
||||
case "ADLER32" -> checksumChecksum(is, new Adler32());
|
||||
default -> toHex(checksumBytes(is, algorithm));
|
||||
};
|
||||
switch (algorithm.toUpperCase(Locale.ROOT)) {
|
||||
case "CRC32":
|
||||
return checksumChecksum(is, new CRC32());
|
||||
case "ADLER32":
|
||||
return checksumChecksum(is, new Adler32());
|
||||
default:
|
||||
return toHex(checksumBytes(is, algorithm));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,13 +98,14 @@ public class ChecksumUtils {
|
||||
* @throws IOException if reading from the stream fails
|
||||
*/
|
||||
public static String checksumBase64(InputStream is, String algorithm) throws IOException {
|
||||
return switch (algorithm.toUpperCase(Locale.ROOT)) {
|
||||
case "CRC32" ->
|
||||
Base64.getEncoder().encodeToString(checksumChecksumBytes(is, new CRC32()));
|
||||
case "ADLER32" ->
|
||||
Base64.getEncoder().encodeToString(checksumChecksumBytes(is, new Adler32()));
|
||||
default -> Base64.getEncoder().encodeToString(checksumBytes(is, algorithm));
|
||||
};
|
||||
switch (algorithm.toUpperCase(Locale.ROOT)) {
|
||||
case "CRC32":
|
||||
return Base64.getEncoder().encodeToString(checksumChecksumBytes(is, new CRC32()));
|
||||
case "ADLER32":
|
||||
return Base64.getEncoder().encodeToString(checksumChecksumBytes(is, new Adler32()));
|
||||
default:
|
||||
return Base64.getEncoder().encodeToString(checksumBytes(is, algorithm));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,7 +179,7 @@ public class ChecksumUtils {
|
||||
for (Map.Entry<String, Checksum> entry : checksums.entrySet()) {
|
||||
// Keep value as long and mask to ensure unsigned hex formatting.
|
||||
long unsigned32 = entry.getValue().getValue() & UNSIGNED_32_BIT_MASK;
|
||||
results.put(entry.getKey(), String.format(Locale.ROOT, "%08x", unsigned32));
|
||||
results.put(entry.getKey(), String.format("%08x", unsigned32));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -254,7 +258,7 @@ public class ChecksumUtils {
|
||||
}
|
||||
// Keep as long and mask to ensure correct unsigned representation.
|
||||
long unsigned32 = checksum.getValue() & UNSIGNED_32_BIT_MASK;
|
||||
return String.format(Locale.ROOT, "%08x", unsigned32);
|
||||
return String.format("%08x", unsigned32);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,7 +294,7 @@ public class ChecksumUtils {
|
||||
private static String toHex(byte[] hash) {
|
||||
StringBuilder sb = new StringBuilder(hash.length * 2);
|
||||
for (byte b : hash) {
|
||||
sb.append(String.format(Locale.ROOT, "%02x", b));
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@@ -62,7 +62,8 @@ public class CustomHtmlSanitizer {
|
||||
.and(new HtmlPolicyBuilder().disallowElements("noscript").toFactory());
|
||||
|
||||
public String sanitize(String html) {
|
||||
boolean disableSanitize = applicationProperties.getSystem().isDisableSanitize();
|
||||
boolean disableSanitize =
|
||||
Boolean.TRUE.equals(applicationProperties.getSystem().getDisableSanitize());
|
||||
return disableSanitize ? html : POLICY.sanitize(html);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,417 +1,646 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.ZoneOffset;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.Properties;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.simplejavamail.api.email.AttachmentResource;
|
||||
import org.simplejavamail.api.email.Email;
|
||||
import org.simplejavamail.api.email.Recipient;
|
||||
import org.simplejavamail.converter.EmailConverter;
|
||||
|
||||
import jakarta.activation.DataSource;
|
||||
import jakarta.mail.Message.RecipientType;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.api.converters.EmlToPdfRequest;
|
||||
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
public class EmlParser {
|
||||
|
||||
// Configuration constants
|
||||
private final int DEFAULT_MAX_ATTACHMENT_MB = 10;
|
||||
private final long MAX_SIZE_ESTIMATION_BYTES = 500L * 1024 * 1024; // 500MB
|
||||
private static volatile Boolean jakartaMailAvailable = null;
|
||||
private static volatile Method mimeUtilityDecodeTextMethod = null;
|
||||
private static volatile boolean mimeUtilityChecked = false;
|
||||
|
||||
// Message constants
|
||||
private final String NO_CONTENT_MESSAGE = "Email content could not be parsed";
|
||||
private final String ATTACHMENT_PREFIX = "attachment-";
|
||||
private static final Pattern MIME_ENCODED_PATTERN =
|
||||
RegexPatternUtils.getInstance().getMimeEncodedWordPattern();
|
||||
|
||||
public EmailContent extractEmailContent(
|
||||
private static final String DISPOSITION_ATTACHMENT = "attachment";
|
||||
private static final String TEXT_PLAIN = MediaType.TEXT_PLAIN_VALUE;
|
||||
private static final String TEXT_HTML = MediaType.TEXT_HTML_VALUE;
|
||||
private static final String MULTIPART_PREFIX = "multipart/";
|
||||
|
||||
private static final String HEADER_CONTENT_TYPE = "content-type:";
|
||||
private static final String HEADER_CONTENT_DISPOSITION = "content-disposition:";
|
||||
private static final String HEADER_CONTENT_TRANSFER_ENCODING = "content-transfer-encoding:";
|
||||
private static final String HEADER_CONTENT_ID = "Content-ID";
|
||||
private static final String HEADER_SUBJECT = "Subject:";
|
||||
private static final String HEADER_FROM = "From:";
|
||||
private static final String HEADER_TO = "To:";
|
||||
private static final String HEADER_CC = "Cc:";
|
||||
private static final String HEADER_BCC = "Bcc:";
|
||||
private static final String HEADER_DATE = "Date:";
|
||||
|
||||
private static synchronized boolean isJakartaMailAvailable() {
|
||||
if (jakartaMailAvailable == null) {
|
||||
try {
|
||||
Class.forName("jakarta.mail.internet.MimeMessage");
|
||||
Class.forName("jakarta.mail.Session");
|
||||
Class.forName("jakarta.mail.internet.MimeUtility");
|
||||
Class.forName("jakarta.mail.internet.MimePart");
|
||||
Class.forName("jakarta.mail.internet.MimeMultipart");
|
||||
Class.forName("jakarta.mail.Multipart");
|
||||
Class.forName("jakarta.mail.Part");
|
||||
jakartaMailAvailable = true;
|
||||
} catch (ClassNotFoundException e) {
|
||||
jakartaMailAvailable = false;
|
||||
}
|
||||
}
|
||||
return jakartaMailAvailable;
|
||||
}
|
||||
|
||||
public static EmailContent extractEmailContent(
|
||||
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer)
|
||||
throws IOException {
|
||||
|
||||
EmlProcessingUtils.validateEmlInput(emlBytes);
|
||||
|
||||
Email email = parseEmail(emlBytes);
|
||||
return buildEmailContent(email, request, customHtmlSanitizer);
|
||||
}
|
||||
|
||||
private Email parseEmail(byte[] emlBytes) throws IOException {
|
||||
boolean isMsgFile = EmlProcessingUtils.isMsgFile(emlBytes);
|
||||
try (ByteArrayInputStream input = new ByteArrayInputStream(emlBytes)) {
|
||||
Email email;
|
||||
if (isMsgFile) {
|
||||
try {
|
||||
email = EmailConverter.outlookMsgToEmail(input);
|
||||
} catch (Exception e) {
|
||||
// OLE2 magic bytes match but parsing failed - might be DOC/XLS/other OLE2 file
|
||||
throw new IOException(
|
||||
"The file appears to be an OLE2 file (MSG/DOC/XLS) but could not be "
|
||||
+ "parsed as an Outlook email. Ensure it is a valid .msg file: "
|
||||
+ e.getMessage(),
|
||||
e);
|
||||
}
|
||||
} else {
|
||||
email = EmailConverter.emlToEmail(input);
|
||||
}
|
||||
|
||||
return email;
|
||||
} catch (IOException e) {
|
||||
throw e; // Re-throw IOException as-is
|
||||
} catch (Exception e) {
|
||||
throw new IOException(
|
||||
String.format(
|
||||
"Failed to parse EML file with Simple Java Mail: %s", e.getMessage()),
|
||||
e);
|
||||
if (isJakartaMailAvailable()) {
|
||||
return extractEmailContentAdvanced(emlBytes, request, customHtmlSanitizer);
|
||||
} else {
|
||||
return extractEmailContentBasic(emlBytes, customHtmlSanitizer);
|
||||
}
|
||||
}
|
||||
|
||||
private EmailContent buildEmailContent(
|
||||
Email email, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer)
|
||||
throws IOException {
|
||||
|
||||
private static EmailContent extractEmailContentBasic(
|
||||
byte[] emlBytes, CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
String emlContent = new String(emlBytes, StandardCharsets.UTF_8);
|
||||
EmailContent content = new EmailContent();
|
||||
content.setSubject(defaultString(email.getSubject()));
|
||||
content.setFrom(formatRecipient(email.getFromRecipient()));
|
||||
content.setTo(formatRecipients(email.getRecipients(), RecipientType.TO));
|
||||
content.setCc(formatRecipients(email.getRecipients(), RecipientType.CC));
|
||||
content.setBcc(formatRecipients(email.getRecipients(), RecipientType.BCC));
|
||||
|
||||
Date sentDate = email.getSentDate();
|
||||
if (sentDate != null) {
|
||||
// Use UTC for consistent timezone handling across deployments
|
||||
content.setDate(ZonedDateTime.ofInstant(sentDate.toInstant(), ZoneOffset.UTC));
|
||||
content.setSubject(extractBasicHeader(emlContent, HEADER_SUBJECT));
|
||||
content.setFrom(extractBasicHeader(emlContent, HEADER_FROM));
|
||||
content.setTo(extractBasicHeader(emlContent, HEADER_TO));
|
||||
content.setCc(extractBasicHeader(emlContent, HEADER_CC));
|
||||
content.setBcc(extractBasicHeader(emlContent, HEADER_BCC));
|
||||
|
||||
String dateStr = extractBasicHeader(emlContent, HEADER_DATE);
|
||||
if (!dateStr.isEmpty()) {
|
||||
content.setDateString(dateStr);
|
||||
}
|
||||
|
||||
String htmlBody = email.getHTMLText();
|
||||
if (customHtmlSanitizer != null && htmlBody != null) {
|
||||
htmlBody = customHtmlSanitizer.sanitize(htmlBody);
|
||||
}
|
||||
content.setHtmlBody(htmlBody);
|
||||
|
||||
String textBody = email.getPlainText();
|
||||
if (customHtmlSanitizer != null && textBody != null) {
|
||||
textBody = customHtmlSanitizer.sanitize(textBody);
|
||||
}
|
||||
content.setTextBody(textBody);
|
||||
|
||||
if (isBlank(content.getHtmlBody()) && isBlank(content.getTextBody())) {
|
||||
content.setTextBody(NO_CONTENT_MESSAGE);
|
||||
String htmlBody = extractHtmlBody(emlContent);
|
||||
if (htmlBody != null) {
|
||||
content.setHtmlBody(htmlBody);
|
||||
} else {
|
||||
String textBody = extractTextBody(emlContent);
|
||||
content.setTextBody(textBody != null ? textBody : "Email content could not be parsed");
|
||||
}
|
||||
|
||||
List<EmailAttachment> attachments = new ArrayList<>();
|
||||
attachments.addAll(mapResources(email.getEmbeddedImages(), request, true));
|
||||
attachments.addAll(mapResources(email.getAttachments(), request, false));
|
||||
content.setAttachments(attachments);
|
||||
content.setAttachmentCount(attachments.size());
|
||||
content.getAttachments().addAll(extractAttachmentsBasic(emlContent));
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
private List<EmailAttachment> mapResources(
|
||||
List<AttachmentResource> resources, EmlToPdfRequest request, boolean embedded)
|
||||
throws IOException {
|
||||
private static EmailContent extractEmailContentAdvanced(
|
||||
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
try {
|
||||
Class<?> sessionClass = Class.forName("jakarta.mail.Session");
|
||||
Class<?> mimeMessageClass = Class.forName("jakarta.mail.internet.MimeMessage");
|
||||
|
||||
if (resources == null || resources.isEmpty()) {
|
||||
return List.of();
|
||||
Method getDefaultInstance =
|
||||
sessionClass.getMethod("getDefaultInstance", Properties.class);
|
||||
Object session = getDefaultInstance.invoke(null, new Properties());
|
||||
|
||||
Class<?>[] constructorArgs = new Class<?>[] {sessionClass, InputStream.class};
|
||||
Constructor<?> mimeMessageConstructor =
|
||||
mimeMessageClass.getConstructor(constructorArgs);
|
||||
Object message =
|
||||
mimeMessageConstructor.newInstance(session, new ByteArrayInputStream(emlBytes));
|
||||
|
||||
return extractFromMimeMessage(message, request, customHtmlSanitizer);
|
||||
|
||||
} catch (ReflectiveOperationException e) {
|
||||
return extractEmailContentBasic(emlBytes, customHtmlSanitizer);
|
||||
}
|
||||
|
||||
List<EmailAttachment> mapped = new ArrayList<>(resources.size());
|
||||
int unnamedCounter = 0; // Start at 0, increment before use
|
||||
|
||||
for (AttachmentResource resource : resources) {
|
||||
if (resource == null) {
|
||||
continue; // Skip null resources early
|
||||
}
|
||||
|
||||
// Pre-determine if this resource needs a generated filename
|
||||
boolean needsGeneratedName = !embedded && needsGeneratedFilename(resource);
|
||||
|
||||
if (needsGeneratedName) {
|
||||
unnamedCounter++;
|
||||
}
|
||||
|
||||
EmailAttachment attachment =
|
||||
toEmailAttachment(resource, request, embedded, unnamedCounter);
|
||||
if (attachment != null) {
|
||||
mapped.add(attachment);
|
||||
}
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
/** Checks if a resource needs a generated filename (has no usable name). */
|
||||
private boolean needsGeneratedFilename(AttachmentResource resource) {
|
||||
if (resource == null) {
|
||||
return false;
|
||||
private static EmailContent extractFromMimeMessage(
|
||||
Object message, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
EmailContent content = new EmailContent();
|
||||
|
||||
try {
|
||||
Class<?> messageClass = message.getClass();
|
||||
|
||||
Method getSubject = messageClass.getMethod("getSubject");
|
||||
String subject = (String) getSubject.invoke(message);
|
||||
content.setSubject(subject != null ? safeMimeDecode(subject) : "No Subject");
|
||||
|
||||
Method getFrom = messageClass.getMethod("getFrom");
|
||||
Object[] fromAddresses = (Object[]) getFrom.invoke(message);
|
||||
content.setFrom(buildAddressString(fromAddresses));
|
||||
|
||||
extractRecipients(message, messageClass, content);
|
||||
|
||||
Method getSentDate = messageClass.getMethod("getSentDate");
|
||||
Date legacyDate = (Date) getSentDate.invoke(message);
|
||||
if (legacyDate != null) {
|
||||
content.setDate(
|
||||
ZonedDateTime.ofInstant(legacyDate.toInstant(), ZoneId.systemDefault()));
|
||||
}
|
||||
|
||||
Method getContent = messageClass.getMethod("getContent");
|
||||
Object messageContent = getContent.invoke(message);
|
||||
|
||||
processMessageContent(message, messageContent, content, request, customHtmlSanitizer);
|
||||
|
||||
} catch (ReflectiveOperationException | RuntimeException e) {
|
||||
content.setSubject("Email Conversion");
|
||||
content.setFrom("Unknown");
|
||||
content.setTo("Unknown");
|
||||
content.setCc("");
|
||||
content.setBcc("");
|
||||
content.setTextBody("Email content could not be parsed with advanced processing");
|
||||
}
|
||||
String resourceName = resource.getName();
|
||||
if (!isBlank(resourceName)) {
|
||||
return false;
|
||||
}
|
||||
DataSource dataSource = resource.getDataSource();
|
||||
return isBlank(dataSource.getName());
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
private EmailAttachment toEmailAttachment(
|
||||
AttachmentResource resource, EmlToPdfRequest request, boolean embedded, int counter)
|
||||
throws IOException {
|
||||
private static void extractRecipients(
|
||||
Object message, Class<?> messageClass, EmailContent content) {
|
||||
try {
|
||||
Method getRecipients =
|
||||
messageClass.getMethod(
|
||||
"getRecipients", Class.forName("jakarta.mail.Message$RecipientType"));
|
||||
Class<?> recipientTypeClass = Class.forName("jakarta.mail.Message$RecipientType");
|
||||
|
||||
if (resource == null) {
|
||||
return null;
|
||||
}
|
||||
Object toType = recipientTypeClass.getField("TO").get(null);
|
||||
Object[] toRecipients = (Object[]) getRecipients.invoke(message, toType);
|
||||
content.setTo(buildAddressString(toRecipients));
|
||||
|
||||
EmailAttachment attachment = new EmailAttachment();
|
||||
attachment.setEmbedded(embedded);
|
||||
Object ccType = recipientTypeClass.getField("CC").get(null);
|
||||
Object[] ccRecipients = (Object[]) getRecipients.invoke(message, ccType);
|
||||
content.setCc(buildAddressString(ccRecipients));
|
||||
|
||||
String resourceName = defaultString(resource.getName());
|
||||
String filename = resourceName;
|
||||
DataSource dataSource = resource.getDataSource();
|
||||
String contentType = dataSource.getContentType();
|
||||
Object bccType = recipientTypeClass.getField("BCC").get(null);
|
||||
Object[] bccRecipients = (Object[]) getRecipients.invoke(message, bccType);
|
||||
content.setBcc(buildAddressString(bccRecipients));
|
||||
|
||||
if (!isBlank(dataSource.getName())) {
|
||||
filename = dataSource.getName();
|
||||
}
|
||||
filename = safeMimeDecode(filename);
|
||||
|
||||
// Generate unique filename for unnamed attachments
|
||||
if (isBlank(filename)) {
|
||||
String extension = detectExtensionFromMimeType(contentType);
|
||||
filename = embedded ? resourceName : (ATTACHMENT_PREFIX + counter + extension);
|
||||
}
|
||||
attachment.setFilename(filename);
|
||||
|
||||
String contentId = embedded ? stripCid(resourceName) : null;
|
||||
attachment.setContentId(contentId);
|
||||
|
||||
String detectedContentType = EmlProcessingUtils.detectMimeType(filename, contentType);
|
||||
attachment.setContentType(detectedContentType);
|
||||
|
||||
// Read data with size limit to prevent OOM
|
||||
ReadResult readResult = readData(dataSource, embedded, request);
|
||||
if (readResult != null) {
|
||||
attachment.setSizeBytes(readResult.totalSize);
|
||||
if (shouldIncludeAttachmentData(embedded, request, readResult)) {
|
||||
attachment.setData(readResult.data);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
try {
|
||||
Method getAllRecipients = messageClass.getMethod("getAllRecipients");
|
||||
Object[] recipients = (Object[]) getAllRecipients.invoke(message);
|
||||
content.setTo(buildAddressString(recipients));
|
||||
content.setCc("");
|
||||
content.setBcc("");
|
||||
} catch (ReflectiveOperationException ex) {
|
||||
content.setTo("");
|
||||
content.setCc("");
|
||||
content.setBcc("");
|
||||
}
|
||||
}
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
private boolean shouldIncludeAttachmentData(
|
||||
boolean embedded, EmlToPdfRequest request, ReadResult readResult) {
|
||||
// Always include embedded images for proper rendering
|
||||
if (embedded) {
|
||||
return readResult != null && readResult.data() != null;
|
||||
}
|
||||
// Check if attachments are requested and data is available within size limit
|
||||
if (request == null || !request.isIncludeAttachments()) {
|
||||
return false;
|
||||
}
|
||||
if (readResult == null || readResult.data() == null) {
|
||||
return false;
|
||||
}
|
||||
return readResult.data().length <= getMaxAttachmentSizeBytes(request);
|
||||
}
|
||||
|
||||
private String detectExtensionFromMimeType(String mimeType) {
|
||||
if (mimeType == null) {
|
||||
private static String buildAddressString(Object[] addresses) {
|
||||
if (addresses == null || addresses.length == 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String lower = mimeType.toLowerCase(Locale.ROOT);
|
||||
|
||||
// Remove any parameters (e.g., "text/plain; charset=utf-8" -> "text/plain")
|
||||
int semicolon = lower.indexOf(';');
|
||||
if (semicolon > 0) {
|
||||
lower = lower.substring(0, semicolon).trim();
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < addresses.length; i++) {
|
||||
if (i > 0) builder.append(", ");
|
||||
builder.append(safeMimeDecode(addresses[i].toString()));
|
||||
}
|
||||
|
||||
// Match exact MIME types first, then fall back to contains() for variants
|
||||
return switch (lower) {
|
||||
case "application/pdf" -> ".pdf";
|
||||
case "image/png" -> ".png";
|
||||
case "image/jpeg", "image/jpg" -> ".jpg";
|
||||
case "image/gif" -> ".gif";
|
||||
case "image/webp" -> ".webp";
|
||||
case "image/bmp" -> ".bmp";
|
||||
case "text/plain" -> ".txt";
|
||||
case "text/html" -> ".html";
|
||||
case "text/xml", "application/xml" -> ".xml";
|
||||
case "application/json" -> ".json";
|
||||
case "application/zip" -> ".zip";
|
||||
case "application/octet-stream" -> ".bin";
|
||||
default -> {
|
||||
if (lower.contains("wordprocessingml") || lower.contains("msword")) yield ".docx";
|
||||
if (lower.contains("spreadsheetml") || lower.contains("excel")) yield ".xlsx";
|
||||
if (lower.contains("presentationml") || lower.contains("powerpoint")) yield ".pptx";
|
||||
if (lower.contains("opendocument.text")) yield ".odt";
|
||||
if (lower.contains("opendocument.spreadsheet")) yield ".ods";
|
||||
yield "";
|
||||
}
|
||||
};
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private ReadResult readData(DataSource dataSource, boolean embedded, EmlToPdfRequest request)
|
||||
throws IOException {
|
||||
if (dataSource == null) {
|
||||
return null;
|
||||
}
|
||||
private static void processMessageContent(
|
||||
Object message,
|
||||
Object messageContent,
|
||||
EmailContent content,
|
||||
EmlToPdfRequest request,
|
||||
CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
try {
|
||||
if (messageContent instanceof String stringContent) {
|
||||
Method getContentType = message.getClass().getMethod("getContentType");
|
||||
String contentType = (String) getContentType.invoke(message);
|
||||
|
||||
long maxBytes = getMaxAttachmentSizeBytes(request);
|
||||
|
||||
try (InputStream input = dataSource.getInputStream()) {
|
||||
// Embedded images are usually needed for display regardless of size,
|
||||
// but regular attachments should be guarded against OOM
|
||||
if (!embedded && request != null) {
|
||||
byte[] buffer = new byte[8192];
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
int bytesRead;
|
||||
long totalBytes = 0;
|
||||
while ((bytesRead = input.read(buffer)) != -1) {
|
||||
totalBytes += bytesRead;
|
||||
if (totalBytes > maxBytes) {
|
||||
// Attachment too large - skip remaining data but estimate total size
|
||||
long remainingBytes = countRemainingBytes(input, totalBytes);
|
||||
log.debug(
|
||||
"Attachment exceeds size limit: {} bytes (max: {} bytes), skipping",
|
||||
remainingBytes,
|
||||
maxBytes);
|
||||
return new ReadResult(null, remainingBytes);
|
||||
}
|
||||
output.write(buffer, 0, bytesRead);
|
||||
if (contentType != null && contentType.toLowerCase().contains(TEXT_HTML)) {
|
||||
content.setHtmlBody(stringContent);
|
||||
} else {
|
||||
content.setTextBody(stringContent);
|
||||
}
|
||||
byte[] data = output.toByteArray();
|
||||
return new ReadResult(data, data.length);
|
||||
} else {
|
||||
byte[] data = input.readAllBytes();
|
||||
return new ReadResult(data, data.length);
|
||||
Class<?> multipartClass = Class.forName("jakarta.mail.Multipart");
|
||||
if (multipartClass.isInstance(messageContent)) {
|
||||
processMultipart(messageContent, content, request, customHtmlSanitizer, 0);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
if (embedded) {
|
||||
log.debug(
|
||||
"Failed to read embedded image, using empty placeholder: {}",
|
||||
e.getMessage());
|
||||
return new ReadResult(new byte[0], 0);
|
||||
}
|
||||
throw e;
|
||||
} catch (ReflectiveOperationException | ClassCastException e) {
|
||||
content.setTextBody("Email content could not be parsed with advanced processing");
|
||||
}
|
||||
}
|
||||
|
||||
private long countRemainingBytes(InputStream input, long alreadyRead) throws IOException {
|
||||
long count = alreadyRead;
|
||||
private static void processMultipart(
|
||||
Object multipart,
|
||||
EmailContent content,
|
||||
EmlToPdfRequest request,
|
||||
CustomHtmlSanitizer customHtmlSanitizer,
|
||||
int depth) {
|
||||
|
||||
long skipped;
|
||||
while (count < MAX_SIZE_ESTIMATION_BYTES
|
||||
&& (skipped = input.skip(MAX_SIZE_ESTIMATION_BYTES - count)) > 0) {
|
||||
count += skipped;
|
||||
final int MAX_MULTIPART_DEPTH = 10;
|
||||
if (depth > MAX_MULTIPART_DEPTH) {
|
||||
content.setHtmlBody("<div class=\"error\">Maximum multipart depth exceeded</div>");
|
||||
return;
|
||||
}
|
||||
|
||||
if (count < MAX_SIZE_ESTIMATION_BYTES && input.available() > 0) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = input.read(buffer)) != -1 && count < MAX_SIZE_ESTIMATION_BYTES) {
|
||||
count += read;
|
||||
try {
|
||||
Class<?> multipartClass = multipart.getClass();
|
||||
Method getCount = multipartClass.getMethod("getCount");
|
||||
int count = (Integer) getCount.invoke(multipart);
|
||||
|
||||
Method getBodyPart = multipartClass.getMethod("getBodyPart", int.class);
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
Object part = getBodyPart.invoke(multipart, i);
|
||||
processPart(part, content, request, customHtmlSanitizer, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
} catch (ReflectiveOperationException | ClassCastException e) {
|
||||
content.setHtmlBody("<div class=\"error\">Error processing multipart content</div>");
|
||||
}
|
||||
}
|
||||
|
||||
private String formatRecipients(List<Recipient> recipients, RecipientType type) {
|
||||
if (recipients == null || type == null) {
|
||||
private static void processPart(
|
||||
Object part,
|
||||
EmailContent content,
|
||||
EmlToPdfRequest request,
|
||||
CustomHtmlSanitizer customHtmlSanitizer,
|
||||
int depth) {
|
||||
try {
|
||||
Class<?> partClass = part.getClass();
|
||||
|
||||
Method isMimeType = partClass.getMethod("isMimeType", String.class);
|
||||
Method getContent = partClass.getMethod("getContent");
|
||||
Method getDisposition = partClass.getMethod("getDisposition");
|
||||
Method getFileName = partClass.getMethod("getFileName");
|
||||
Method getContentType = partClass.getMethod("getContentType");
|
||||
Method getHeader = partClass.getMethod("getHeader", String.class);
|
||||
|
||||
Object disposition = getDisposition.invoke(part);
|
||||
String filename = (String) getFileName.invoke(part);
|
||||
String contentType = (String) getContentType.invoke(part);
|
||||
|
||||
String normalizedDisposition =
|
||||
disposition != null ? ((String) disposition).toLowerCase() : null;
|
||||
|
||||
if ((Boolean) isMimeType.invoke(part, TEXT_PLAIN) && normalizedDisposition == null) {
|
||||
Object partContent = getContent.invoke(part);
|
||||
if (partContent instanceof String stringContent) {
|
||||
content.setTextBody(stringContent);
|
||||
}
|
||||
} else if ((Boolean) isMimeType.invoke(part, TEXT_HTML)
|
||||
&& normalizedDisposition == null) {
|
||||
Object partContent = getContent.invoke(part);
|
||||
if (partContent instanceof String stringContent) {
|
||||
String htmlBody =
|
||||
customHtmlSanitizer != null
|
||||
? customHtmlSanitizer.sanitize(stringContent)
|
||||
: stringContent;
|
||||
content.setHtmlBody(htmlBody);
|
||||
}
|
||||
} else if ((normalizedDisposition != null
|
||||
&& normalizedDisposition.contains(DISPOSITION_ATTACHMENT))
|
||||
|| (filename != null && !filename.trim().isEmpty())) {
|
||||
|
||||
processAttachment(
|
||||
part, content, request, getHeader, getContent, filename, contentType);
|
||||
} else if ((Boolean) isMimeType.invoke(part, "multipart/*")) {
|
||||
Object multipartContent = getContent.invoke(part);
|
||||
if (multipartContent != null) {
|
||||
Class<?> multipartClass = Class.forName("jakarta.mail.Multipart");
|
||||
if (multipartClass.isInstance(multipartContent)) {
|
||||
processMultipart(
|
||||
multipartContent, content, request, customHtmlSanitizer, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (ReflectiveOperationException | RuntimeException e) {
|
||||
// Continue processing other parts if one fails
|
||||
}
|
||||
}
|
||||
|
||||
private static void processAttachment(
|
||||
Object part,
|
||||
EmailContent content,
|
||||
EmlToPdfRequest request,
|
||||
Method getHeader,
|
||||
Method getContent,
|
||||
String filename,
|
||||
String contentType) {
|
||||
|
||||
content.setAttachmentCount(content.getAttachmentCount() + 1);
|
||||
|
||||
if (filename != null && !filename.trim().isEmpty()) {
|
||||
EmailAttachment attachment = new EmailAttachment();
|
||||
attachment.setFilename(safeMimeDecode(filename));
|
||||
attachment.setContentType(contentType);
|
||||
|
||||
try {
|
||||
String[] contentIdHeaders = (String[]) getHeader.invoke(part, HEADER_CONTENT_ID);
|
||||
if (contentIdHeaders != null) {
|
||||
for (String contentIdHeader : contentIdHeaders) {
|
||||
if (contentIdHeader != null && !contentIdHeader.trim().isEmpty()) {
|
||||
attachment.setEmbedded(true);
|
||||
String contentId =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getAngleBracketsPattern()
|
||||
.matcher(contentIdHeader.trim())
|
||||
.replaceAll("");
|
||||
attachment.setContentId(contentId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ReflectiveOperationException e) {
|
||||
}
|
||||
|
||||
if ((request != null && request.isIncludeAttachments()) || attachment.isEmbedded()) {
|
||||
extractAttachmentData(part, attachment, getContent, request);
|
||||
}
|
||||
|
||||
content.getAttachments().add(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
private static void extractAttachmentData(
|
||||
Object part, EmailAttachment attachment, Method getContent, EmlToPdfRequest request) {
|
||||
try {
|
||||
Object attachmentContent = getContent.invoke(part);
|
||||
byte[] attachmentData = null;
|
||||
|
||||
if (attachmentContent instanceof InputStream inputStream) {
|
||||
try (InputStream stream = inputStream) {
|
||||
attachmentData = stream.readAllBytes();
|
||||
} catch (IOException e) {
|
||||
if (attachment.isEmbedded()) {
|
||||
attachmentData = new byte[0];
|
||||
} else {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
} else if (attachmentContent instanceof byte[] byteArray) {
|
||||
attachmentData = byteArray;
|
||||
} else if (attachmentContent instanceof String stringContent) {
|
||||
attachmentData = stringContent.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
if (attachmentData != null) {
|
||||
long maxSizeMB = request != null ? request.getMaxAttachmentSizeMB() : 10L;
|
||||
long maxSizeBytes = maxSizeMB * 1024 * 1024;
|
||||
|
||||
if (attachmentData.length <= maxSizeBytes || attachment.isEmbedded()) {
|
||||
attachment.setData(attachmentData);
|
||||
attachment.setSizeBytes(attachmentData.length);
|
||||
} else {
|
||||
attachment.setSizeBytes(attachmentData.length);
|
||||
}
|
||||
}
|
||||
} catch (ReflectiveOperationException | RuntimeException e) {
|
||||
// Continue without attachment data
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractBasicHeader(String emlContent, String headerName) {
|
||||
try {
|
||||
String[] lines =
|
||||
RegexPatternUtils.getInstance().getNewlineSplitPattern().split(emlContent);
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
String line = lines[i];
|
||||
if (line.toLowerCase().startsWith(headerName.toLowerCase())) {
|
||||
StringBuilder value =
|
||||
new StringBuilder(line.substring(headerName.length()).trim());
|
||||
for (int j = i + 1; j < lines.length; j++) {
|
||||
if (lines[j].startsWith(" ") || lines[j].startsWith("\t")) {
|
||||
value.append(" ").append(lines[j].trim());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return safeMimeDecode(value.toString());
|
||||
}
|
||||
if (line.trim().isEmpty()) break;
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
// Ignore errors in header extraction
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String extractHtmlBody(String emlContent) {
|
||||
try {
|
||||
String lowerContent = emlContent.toLowerCase();
|
||||
int htmlStart = lowerContent.indexOf(HEADER_CONTENT_TYPE + " " + TEXT_HTML);
|
||||
if (htmlStart == -1) return null;
|
||||
|
||||
int bodyStart = emlContent.indexOf("\r\n\r\n", htmlStart);
|
||||
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n", htmlStart);
|
||||
if (bodyStart == -1) return null;
|
||||
|
||||
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
|
||||
int bodyEnd = findPartEnd(emlContent, bodyStart);
|
||||
|
||||
return emlContent.substring(bodyStart, bodyEnd).trim();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractTextBody(String emlContent) {
|
||||
try {
|
||||
String lowerContent = emlContent.toLowerCase();
|
||||
int textStart = lowerContent.indexOf(HEADER_CONTENT_TYPE + " " + TEXT_PLAIN);
|
||||
if (textStart == -1) {
|
||||
int bodyStart = emlContent.indexOf("\r\n\r\n");
|
||||
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n");
|
||||
if (bodyStart != -1) {
|
||||
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
|
||||
int bodyEnd = findPartEnd(emlContent, bodyStart);
|
||||
return emlContent.substring(bodyStart, bodyEnd).trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
int bodyStart = emlContent.indexOf("\r\n\r\n", textStart);
|
||||
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n", textStart);
|
||||
if (bodyStart == -1) return null;
|
||||
|
||||
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
|
||||
int bodyEnd = findPartEnd(emlContent, bodyStart);
|
||||
|
||||
return emlContent.substring(bodyStart, bodyEnd).trim();
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static int findPartEnd(String content, int start) {
|
||||
String[] lines =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getNewlineSplitPattern()
|
||||
.split(content.substring(start));
|
||||
StringBuilder result = new StringBuilder();
|
||||
|
||||
for (String line : lines) {
|
||||
if (line.startsWith("--") && line.length() > 10) break;
|
||||
result.append(line).append("\n");
|
||||
}
|
||||
|
||||
return start + result.length();
|
||||
}
|
||||
|
||||
private static List<EmailAttachment> extractAttachmentsBasic(String emlContent) {
|
||||
List<EmailAttachment> attachments = new ArrayList<>();
|
||||
try {
|
||||
String[] lines =
|
||||
RegexPatternUtils.getInstance().getNewlineSplitPattern().split(emlContent);
|
||||
boolean inHeaders = true;
|
||||
String currentContentType = "";
|
||||
String currentDisposition = "";
|
||||
String currentFilename = "";
|
||||
String currentEncoding = "";
|
||||
|
||||
for (String line : lines) {
|
||||
String lowerLine = line.toLowerCase().trim();
|
||||
|
||||
if (line.trim().isEmpty()) {
|
||||
inHeaders = false;
|
||||
if (isAttachment(currentDisposition, currentFilename, currentContentType)) {
|
||||
EmailAttachment attachment = new EmailAttachment();
|
||||
attachment.setFilename(currentFilename);
|
||||
attachment.setContentType(currentContentType);
|
||||
attachment.setTransferEncoding(currentEncoding);
|
||||
attachments.add(attachment);
|
||||
}
|
||||
currentContentType = "";
|
||||
currentDisposition = "";
|
||||
currentFilename = "";
|
||||
currentEncoding = "";
|
||||
inHeaders = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inHeaders) continue;
|
||||
|
||||
if (lowerLine.startsWith(HEADER_CONTENT_TYPE)) {
|
||||
currentContentType = line.substring(HEADER_CONTENT_TYPE.length()).trim();
|
||||
} else if (lowerLine.startsWith(HEADER_CONTENT_DISPOSITION)) {
|
||||
currentDisposition = line.substring(HEADER_CONTENT_DISPOSITION.length()).trim();
|
||||
currentFilename = extractFilenameFromDisposition(currentDisposition);
|
||||
} else if (lowerLine.startsWith(HEADER_CONTENT_TRANSFER_ENCODING)) {
|
||||
currentEncoding =
|
||||
line.substring(HEADER_CONTENT_TRANSFER_ENCODING.length()).trim();
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
// Continue with empty list
|
||||
}
|
||||
return attachments;
|
||||
}
|
||||
|
||||
private static boolean isAttachment(String disposition, String filename, String contentType) {
|
||||
return (disposition.toLowerCase().contains(DISPOSITION_ATTACHMENT) && !filename.isEmpty())
|
||||
|| (!filename.isEmpty() && !contentType.toLowerCase().startsWith("text/"))
|
||||
|| (contentType.toLowerCase().contains("application/") && !filename.isEmpty());
|
||||
}
|
||||
|
||||
private static String extractFilenameFromDisposition(String disposition) {
|
||||
if (disposition == null || !disposition.contains("filename=")) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return recipients.stream()
|
||||
.filter(Objects::nonNull)
|
||||
// Use type.equals() for null-safe comparison (recipient.getType() may be null)
|
||||
.filter(recipient -> type.equals(recipient.getType()))
|
||||
.map(EmlParser::formatRecipient)
|
||||
.filter(string -> !isBlank(string))
|
||||
.collect(Collectors.joining(", "));
|
||||
// Handle filename*= (RFC 2231 encoded filename)
|
||||
if (disposition.toLowerCase().contains("filename*=")) {
|
||||
int filenameStarStart = disposition.toLowerCase().indexOf("filename*=") + 10;
|
||||
int filenameStarEnd = disposition.indexOf(";", filenameStarStart);
|
||||
if (filenameStarEnd == -1) filenameStarEnd = disposition.length();
|
||||
String extendedFilename =
|
||||
disposition.substring(filenameStarStart, filenameStarEnd).trim();
|
||||
extendedFilename =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getQuotesRemovalPattern()
|
||||
.matcher(extendedFilename)
|
||||
.replaceAll("");
|
||||
|
||||
if (extendedFilename.contains("'")) {
|
||||
String[] parts = extendedFilename.split("'", 3);
|
||||
if (parts.length == 3) {
|
||||
return EmlProcessingUtils.decodeUrlEncoded(parts[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle regular filename=
|
||||
int filenameStart = disposition.toLowerCase().indexOf("filename=") + 9;
|
||||
int filenameEnd = disposition.indexOf(";", filenameStart);
|
||||
if (filenameEnd == -1) filenameEnd = disposition.length();
|
||||
String filename = disposition.substring(filenameStart, filenameEnd).trim();
|
||||
filename =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getQuotesRemovalPattern()
|
||||
.matcher(filename)
|
||||
.replaceAll("");
|
||||
return safeMimeDecode(filename);
|
||||
}
|
||||
|
||||
private String formatRecipient(Recipient recipient) {
|
||||
if (recipient == null) {
|
||||
public static String safeMimeDecode(String headerValue) {
|
||||
if (headerValue == null || headerValue.trim().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String name = safeMimeDecode(recipient.getName());
|
||||
String address = safeMimeDecode(recipient.getAddress());
|
||||
|
||||
if (!isBlank(name) && !isBlank(address)) {
|
||||
return name + " <" + address + ">";
|
||||
if (!mimeUtilityChecked) {
|
||||
synchronized (EmlParser.class) {
|
||||
if (!mimeUtilityChecked) {
|
||||
initializeMimeUtilityDecoding();
|
||||
}
|
||||
}
|
||||
}
|
||||
return !isBlank(name) ? name : address;
|
||||
}
|
||||
|
||||
public String safeMimeDecode(String headerValue) {
|
||||
if (isBlank(headerValue)) {
|
||||
return "";
|
||||
if (mimeUtilityDecodeTextMethod != null) {
|
||||
try {
|
||||
return (String) mimeUtilityDecodeTextMethod.invoke(null, headerValue.trim());
|
||||
} catch (ReflectiveOperationException | RuntimeException e) {
|
||||
// Fall through to custom implementation
|
||||
}
|
||||
}
|
||||
|
||||
return EmlProcessingUtils.decodeMimeHeader(headerValue.trim());
|
||||
}
|
||||
|
||||
private String stripCid(String contentId) {
|
||||
if (contentId == null) {
|
||||
return null;
|
||||
}
|
||||
return RegexPatternUtils.getInstance()
|
||||
.getAngleBracketsPattern()
|
||||
.matcher(contentId)
|
||||
.replaceAll("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
private long getMaxAttachmentSizeBytes(EmlToPdfRequest request) {
|
||||
long maxMb = request != null ? request.getMaxAttachmentSizeMB() : DEFAULT_MAX_ATTACHMENT_MB;
|
||||
return maxMb * 1024L * 1024L;
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.trim().isEmpty();
|
||||
}
|
||||
|
||||
private String defaultString(String value) {
|
||||
return value != null ? value : "";
|
||||
}
|
||||
|
||||
private record ReadResult(byte[] data, long totalSize) {
|
||||
public ReadResult {
|
||||
if (totalSize < 0) {
|
||||
throw new IllegalArgumentException("Size cannot be negative: " + totalSize);
|
||||
}
|
||||
if (data != null && data.length > totalSize) {
|
||||
throw new IllegalArgumentException(
|
||||
"Data length (" + data.length + ") exceeds total size (" + totalSize + ")");
|
||||
}
|
||||
private static void initializeMimeUtilityDecoding() {
|
||||
try {
|
||||
Class<?> mimeUtilityClass = Class.forName("jakarta.mail.internet.MimeUtility");
|
||||
mimeUtilityDecodeTextMethod = mimeUtilityClass.getMethod("decodeText", String.class);
|
||||
} catch (ClassNotFoundException | NoSuchMethodException e) {
|
||||
mimeUtilityDecodeTextMethod = null;
|
||||
}
|
||||
mimeUtilityChecked = true;
|
||||
}
|
||||
|
||||
@Data
|
||||
public class EmailContent {
|
||||
public static class EmailContent {
|
||||
private String subject;
|
||||
private String from;
|
||||
private String to;
|
||||
private String cc;
|
||||
private String bcc;
|
||||
private ZonedDateTime date;
|
||||
private String dateString; // Maintained for compatibility
|
||||
private String dateString; // For basic parsing fallback
|
||||
private String htmlBody;
|
||||
private String textBody;
|
||||
private int attachmentCount;
|
||||
@@ -439,7 +668,7 @@ public class EmlParser {
|
||||
}
|
||||
|
||||
@Data
|
||||
public class EmailAttachment {
|
||||
public static class EmailAttachment {
|
||||
private String filename;
|
||||
private String contentType;
|
||||
private byte[] data;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
@@ -10,41 +8,32 @@ import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
import lombok.Synchronized;
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.api.converters.EmlToPdfRequest;
|
||||
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
|
||||
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
public class EmlProcessingUtils {
|
||||
|
||||
// Style constants
|
||||
private final int DEFAULT_FONT_SIZE = 12;
|
||||
private final String DEFAULT_FONT_FAMILY = "Helvetica, sans-serif";
|
||||
private final float DEFAULT_LINE_HEIGHT = 1.4f;
|
||||
private final String DEFAULT_ZOOM = "1.0";
|
||||
private final String DEFAULT_TEXT_COLOR = "#202124";
|
||||
private final String DEFAULT_BACKGROUND_COLOR = "#ffffff";
|
||||
private final String DEFAULT_BORDER_COLOR = "#e8eaed";
|
||||
private final String ATTACHMENT_BACKGROUND_COLOR = "#f9f9f9";
|
||||
private final String ATTACHMENT_BORDER_COLOR = "#eeeeee";
|
||||
private static final int DEFAULT_FONT_SIZE = 12;
|
||||
private static final String DEFAULT_FONT_FAMILY = "Helvetica, sans-serif";
|
||||
private static final float DEFAULT_LINE_HEIGHT = 1.4f;
|
||||
private static final String DEFAULT_ZOOM = "1.0";
|
||||
private static final String DEFAULT_TEXT_COLOR = "#202124";
|
||||
private static final String DEFAULT_BACKGROUND_COLOR = "#ffffff";
|
||||
private static final String DEFAULT_BORDER_COLOR = "#e8eaed";
|
||||
private static final String ATTACHMENT_BACKGROUND_COLOR = "#f9f9f9";
|
||||
private static final String ATTACHMENT_BORDER_COLOR = "#eeeeee";
|
||||
|
||||
private final String CSS_RESOURCE_PATH = "templates/email-pdf-styles.css";
|
||||
private final int EML_CHECK_LENGTH = 8192;
|
||||
private final int MIN_HEADER_COUNT_FOR_VALID_EML = 2;
|
||||
// MSG file magic bytes (Compound File Binary Format / OLE2)
|
||||
// D0 CF 11 E0 A1 B1 1A E1
|
||||
private final byte[] MSG_MAGIC_BYTES = {
|
||||
(byte) 0xD0, (byte) 0xCF, (byte) 0x11, (byte) 0xE0,
|
||||
(byte) 0xA1, (byte) 0xB1, (byte) 0x1A, (byte) 0xE1
|
||||
};
|
||||
private final Map<String, String> EXTENSION_TO_MIME_TYPE =
|
||||
private static final int EML_CHECK_LENGTH = 8192;
|
||||
private static final int MIN_HEADER_COUNT_FOR_VALID_EML = 2;
|
||||
|
||||
// MIME type detection
|
||||
private static final Map<String, String> EXTENSION_TO_MIME_TYPE =
|
||||
Map.of(
|
||||
".png", MediaType.IMAGE_PNG_VALUE,
|
||||
".jpg", MediaType.IMAGE_JPEG_VALUE,
|
||||
@@ -56,36 +45,18 @@ public class EmlProcessingUtils {
|
||||
".ico", "image/x-icon",
|
||||
".tiff", "image/tiff",
|
||||
".tif", "image/tiff");
|
||||
private volatile String cachedCssContent = null;
|
||||
|
||||
public void validateEmlInput(byte[] emlBytes) {
|
||||
public static void validateEmlInput(byte[] emlBytes) {
|
||||
if (emlBytes == null || emlBytes.length == 0) {
|
||||
throw ExceptionUtils.createEmlEmptyException();
|
||||
}
|
||||
|
||||
if (isMsgFile(emlBytes)) {
|
||||
return; // Valid MSG file, no further EML validation needed
|
||||
throw new IllegalArgumentException("EML file is empty or null");
|
||||
}
|
||||
|
||||
if (isInvalidEmlFormat(emlBytes)) {
|
||||
throw ExceptionUtils.createEmlInvalidFormatException();
|
||||
throw new IllegalArgumentException("Invalid EML file format");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isMsgFile(byte[] fileBytes) {
|
||||
if (fileBytes == null || fileBytes.length < MSG_MAGIC_BYTES.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < MSG_MAGIC_BYTES.length; i++) {
|
||||
if (fileBytes[i] != MSG_MAGIC_BYTES[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean isInvalidEmlFormat(byte[] emlBytes) {
|
||||
private static boolean isInvalidEmlFormat(byte[] emlBytes) {
|
||||
try {
|
||||
int checkLength = Math.min(emlBytes.length, EML_CHECK_LENGTH);
|
||||
String content;
|
||||
@@ -130,7 +101,7 @@ public class EmlProcessingUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public String generateEnhancedEmailHtml(
|
||||
public static String generateEnhancedEmailHtml(
|
||||
EmlParser.EmailContent content,
|
||||
EmlToPdfRequest request,
|
||||
CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
@@ -138,13 +109,12 @@ public class EmlProcessingUtils {
|
||||
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="UTF-8">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
""",
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="UTF-8">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
""",
|
||||
sanitizeText(content.getSubject(), customHtmlSanitizer)));
|
||||
|
||||
appendEnhancedStyles(html);
|
||||
@@ -157,15 +127,14 @@ public class EmlProcessingUtils {
|
||||
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
<div class="email-container">
|
||||
<div class="email-header">
|
||||
<h1>%s</h1>
|
||||
<div class="email-meta">
|
||||
<div><strong>From:</strong> %s</div>
|
||||
<div><strong>To:</strong> %s</div>
|
||||
""",
|
||||
<div class="email-container">
|
||||
<div class="email-header">
|
||||
<h1>%s</h1>
|
||||
<div class="email-meta">
|
||||
<div><strong>From:</strong> %s</div>
|
||||
<div><strong>To:</strong> %s</div>
|
||||
""",
|
||||
sanitizeText(content.getSubject(), customHtmlSanitizer),
|
||||
sanitizeText(content.getFrom(), customHtmlSanitizer),
|
||||
sanitizeText(content.getTo(), customHtmlSanitizer)));
|
||||
@@ -173,36 +142,32 @@ public class EmlProcessingUtils {
|
||||
if (content.getCc() != null && !content.getCc().trim().isEmpty()) {
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"<div><strong>CC:</strong> %s</div>%n",
|
||||
"<div><strong>CC:</strong> %s</div>\n",
|
||||
sanitizeText(content.getCc(), customHtmlSanitizer)));
|
||||
}
|
||||
|
||||
if (content.getBcc() != null && !content.getBcc().trim().isEmpty()) {
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"<div><strong>BCC:</strong> %s</div>%n",
|
||||
"<div><strong>BCC:</strong> %s</div>\n",
|
||||
sanitizeText(content.getBcc(), customHtmlSanitizer)));
|
||||
}
|
||||
|
||||
if (content.getDate() != null) {
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"<div><strong>Date:</strong> %s</div>%n",
|
||||
"<div><strong>Date:</strong> %s</div>\n",
|
||||
PdfAttachmentHandler.formatEmailDate(content.getDate())));
|
||||
} else if (content.getDateString() != null && !content.getDateString().trim().isEmpty()) {
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"<div><strong>Date:</strong> %s</div>%n",
|
||||
"<div><strong>Date:</strong> %s</div>\n",
|
||||
sanitizeText(content.getDateString(), customHtmlSanitizer)));
|
||||
}
|
||||
|
||||
html.append(String.format(Locale.ROOT, "</div></div>%n"));
|
||||
html.append("</div></div>\n");
|
||||
|
||||
html.append(String.format(Locale.ROOT, "<div class=\"email-body\">%n"));
|
||||
html.append("<div class=\"email-body\">\n");
|
||||
if (content.getHtmlBody() != null && !content.getHtmlBody().trim().isEmpty()) {
|
||||
String processedHtml =
|
||||
processEmailHtmlBody(content.getHtmlBody(), content, customHtmlSanitizer);
|
||||
@@ -210,23 +175,22 @@ public class EmlProcessingUtils {
|
||||
} else if (content.getTextBody() != null && !content.getTextBody().trim().isEmpty()) {
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"<div class=\"text-body\">%s</div>",
|
||||
convertTextToHtml(content.getTextBody(), customHtmlSanitizer)));
|
||||
} else {
|
||||
html.append("<div class=\"no-content\"><p><em>No content available</em></p></div>");
|
||||
}
|
||||
html.append(String.format(Locale.ROOT, "</div>%n"));
|
||||
html.append("</div>\n");
|
||||
|
||||
if (content.getAttachmentCount() > 0 || !content.getAttachments().isEmpty()) {
|
||||
appendAttachmentsSection(html, content, request);
|
||||
appendAttachmentsSection(html, content, request, customHtmlSanitizer);
|
||||
}
|
||||
|
||||
html.append(String.format(Locale.ROOT, "</div>%n</body></html>"));
|
||||
html.append("</div>\n</body></html>");
|
||||
return html.toString();
|
||||
}
|
||||
|
||||
public String processEmailHtmlBody(
|
||||
public static String processEmailHtmlBody(
|
||||
String htmlBody,
|
||||
EmlParser.EmailContent emailContent,
|
||||
CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
@@ -253,7 +217,8 @@ public class EmlProcessingUtils {
|
||||
return processed;
|
||||
}
|
||||
|
||||
public String convertTextToHtml(String textBody, CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
public static String convertTextToHtml(
|
||||
String textBody, CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
if (textBody == null) return "";
|
||||
|
||||
String html =
|
||||
@@ -269,112 +234,171 @@ public class EmlProcessingUtils {
|
||||
.getUrlLinkPattern()
|
||||
.matcher(html)
|
||||
.replaceAll(
|
||||
"<a href=\"$1\" style=\"color: #1a73e8; text-decoration:"
|
||||
+ " underline;\">$1</a>");
|
||||
"<a href=\"$1\" style=\"color: #1a73e8; text-decoration: underline;\">$1</a>");
|
||||
|
||||
html =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getEmailLinkPattern()
|
||||
.matcher(html)
|
||||
.replaceAll(
|
||||
"<a href=\"mailto:$1\" style=\"color: #1a73e8; text-decoration:"
|
||||
+ " underline;\">$1</a>");
|
||||
"<a href=\"mailto:$1\" style=\"color: #1a73e8; text-decoration: underline;\">$1</a>");
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
private void appendEnhancedStyles(StringBuilder html) {
|
||||
html.append(
|
||||
private static void appendEnhancedStyles(StringBuilder html) {
|
||||
String css =
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
:root {
|
||||
--font-family: %s;
|
||||
--font-size: %dpx;
|
||||
--line-height: %s;
|
||||
--text-color: %s;
|
||||
--bg-color: %s;
|
||||
--border-color: %s;
|
||||
--header-font-size: %dpx;
|
||||
--meta-font-size: %dpx;
|
||||
--attachment-bg: %s;
|
||||
--attachment-border: %s;
|
||||
--attachment-header-size: %dpx;
|
||||
--attachment-detail-size: %dpx;
|
||||
--note-font-size: %dpx;
|
||||
}
|
||||
""",
|
||||
body {
|
||||
font-family: %s;
|
||||
font-size: %dpx;
|
||||
line-height: %s;
|
||||
color: %s;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
background-color: %s;
|
||||
}
|
||||
|
||||
.email-container {
|
||||
width: 100%%;
|
||||
max-width: 100%%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.email-header {
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid %s;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.email-header h1 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: %dpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.email-meta div {
|
||||
margin-bottom: 2px;
|
||||
font-size: %dpx;
|
||||
}
|
||||
|
||||
.email-body {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.attachment-section {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
background-color: %s;
|
||||
border: 1px solid %s;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.attachment-section h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: %dpx;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.attachment-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.attachment-details, .attachment-type {
|
||||
font-size: %dpx;
|
||||
color: #555555;
|
||||
}
|
||||
|
||||
.attachment-inclusion-note, .attachment-info-note {
|
||||
margin-top: 8px;
|
||||
padding: 6px;
|
||||
font-size: %dpx;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.attachment-inclusion-note {
|
||||
background-color: #e6ffed;
|
||||
border: 1px solid #d4f7dc;
|
||||
color: #006420;
|
||||
}
|
||||
|
||||
.attachment-info-note {
|
||||
background-color: #fff9e6;
|
||||
border: 1px solid #fff0c2;
|
||||
color: #664d00;
|
||||
}
|
||||
|
||||
.attachment-link-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.attachment-link-container:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.attachment-note {
|
||||
font-size: %dpx;
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.no-content {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.text-body {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
""",
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TEXT_COLOR,
|
||||
DEFAULT_BACKGROUND_COLOR,
|
||||
DEFAULT_BORDER_COLOR,
|
||||
DEFAULT_FONT_SIZE + 6,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_SIZE + 4,
|
||||
DEFAULT_FONT_SIZE - 1,
|
||||
ATTACHMENT_BACKGROUND_COLOR,
|
||||
ATTACHMENT_BORDER_COLOR,
|
||||
DEFAULT_FONT_SIZE + 2,
|
||||
DEFAULT_FONT_SIZE - 1,
|
||||
DEFAULT_FONT_SIZE - 1));
|
||||
DEFAULT_FONT_SIZE + 1,
|
||||
DEFAULT_FONT_SIZE - 2,
|
||||
DEFAULT_FONT_SIZE - 2,
|
||||
DEFAULT_FONT_SIZE - 3);
|
||||
|
||||
html.append(loadEmailStyles());
|
||||
html.append(css);
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private String loadEmailStyles() {
|
||||
if (cachedCssContent != null) {
|
||||
return cachedCssContent;
|
||||
}
|
||||
|
||||
try {
|
||||
ClassPathResource resource = new ClassPathResource(CSS_RESOURCE_PATH);
|
||||
try (InputStream inputStream = resource.getInputStream()) {
|
||||
cachedCssContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
return cachedCssContent;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to load email CSS from resource, using fallback: {}", e.getMessage());
|
||||
cachedCssContent = getFallbackStyles(); // Cache fallback to avoid repeated attempts
|
||||
return cachedCssContent;
|
||||
}
|
||||
}
|
||||
|
||||
private String getFallbackStyles() {
|
||||
return
|
||||
"""
|
||||
/* Minimal fallback - main CSS resource failed to load */
|
||||
body {
|
||||
font-family: var(--font-family, Helvetica, sans-serif);
|
||||
font-size: var(--font-size, 12px);
|
||||
line-height: var(--line-height, 1.4);
|
||||
color: var(--text-color, #202124);
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.email-container { max-width: 100%; }
|
||||
.email-header { border-bottom: 1px solid #ccc; margin-bottom: 16px; padding-bottom: 12px; }
|
||||
.email-header h1 { margin: 0 0 8px 0; font-size: 18px; }
|
||||
.email-meta { font-size: 12px; color: #666; }
|
||||
.email-body { line-height: 1.6; }
|
||||
.attachment-section { margin-top: 20px; padding: 12px; background: #f5f5f5; border-radius: 4px; }
|
||||
.attachment-item { padding: 6px 0; border-bottom: 1px solid #ddd; }
|
||||
.no-content { padding: 20px; text-align: center; color: #888; font-style: italic; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
""";
|
||||
}
|
||||
|
||||
private void appendAttachmentsSection(
|
||||
StringBuilder html, EmlParser.EmailContent content, EmlToPdfRequest request) {
|
||||
html.append(String.format(Locale.ROOT, "<div class=\"attachment-section\">%n"));
|
||||
private static void appendAttachmentsSection(
|
||||
StringBuilder html,
|
||||
EmlParser.EmailContent content,
|
||||
EmlToPdfRequest request,
|
||||
CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
html.append("<div class=\"attachment-section\">\n");
|
||||
int displayedAttachmentCount =
|
||||
content.getAttachmentCount() > 0
|
||||
? content.getAttachmentCount()
|
||||
: content.getAttachments().size();
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT, "<h3>Attachments (%d)</h3>%n", displayedAttachmentCount));
|
||||
html.append("<h3>Attachments (").append(displayedAttachmentCount).append(")</h3>\n");
|
||||
|
||||
if (!content.getAttachments().isEmpty()) {
|
||||
for (int i = 0; i < content.getAttachments().size(); i++) {
|
||||
@@ -396,14 +420,13 @@ public class EmlProcessingUtils {
|
||||
String attachmentId = "attachment_" + i;
|
||||
html.append(
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"""
|
||||
<div class="attachment-item" id="%s">
|
||||
<span class="attachment-icon" data-filename="%s">@</span>
|
||||
<span class="attachment-name">%s</span>
|
||||
<span class="attachment-details">(%s%s)</span>
|
||||
</div>
|
||||
""",
|
||||
<div class="attachment-item" id="%s">
|
||||
<span class="attachment-icon" data-filename="%s">@</span>
|
||||
<span class="attachment-name">%s</span>
|
||||
<span class="attachment-details">(%s%s)</span>
|
||||
</div>
|
||||
""",
|
||||
attachmentId,
|
||||
escapeHtml(embeddedFilename),
|
||||
escapeHtml(EmlParser.safeMimeDecode(attachment.getFilename())),
|
||||
@@ -427,10 +450,10 @@ public class EmlProcessingUtils {
|
||||
</div>
|
||||
""");
|
||||
}
|
||||
html.append(String.format(Locale.ROOT, "</div>%n"));
|
||||
html.append("</div>\n");
|
||||
}
|
||||
|
||||
public HTMLToPdfRequest createHtmlRequest(EmlToPdfRequest request) {
|
||||
public static HTMLToPdfRequest createHtmlRequest(EmlToPdfRequest request) {
|
||||
HTMLToPdfRequest htmlRequest = new HTMLToPdfRequest();
|
||||
|
||||
if (request != null) {
|
||||
@@ -441,13 +464,13 @@ public class EmlProcessingUtils {
|
||||
return htmlRequest;
|
||||
}
|
||||
|
||||
public String detectMimeType(String filename, String existingMimeType) {
|
||||
public static String detectMimeType(String filename, String existingMimeType) {
|
||||
if (existingMimeType != null && !existingMimeType.isEmpty()) {
|
||||
return existingMimeType;
|
||||
}
|
||||
|
||||
if (filename != null) {
|
||||
String lowerFilename = filename.toLowerCase(Locale.ROOT);
|
||||
String lowerFilename = filename.toLowerCase();
|
||||
for (Map.Entry<String, String> entry : EXTENSION_TO_MIME_TYPE.entrySet()) {
|
||||
if (lowerFilename.endsWith(entry.getKey())) {
|
||||
return entry.getValue();
|
||||
@@ -458,7 +481,7 @@ public class EmlProcessingUtils {
|
||||
return MediaType.IMAGE_PNG_VALUE; // Default MIME type
|
||||
}
|
||||
|
||||
public String decodeUrlEncoded(String encoded) {
|
||||
public static String decodeUrlEncoded(String encoded) {
|
||||
try {
|
||||
return java.net.URLDecoder.decode(encoded, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
@@ -466,7 +489,7 @@ public class EmlProcessingUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public String decodeMimeHeader(String encodedText) {
|
||||
public static String decodeMimeHeader(String encodedText) {
|
||||
if (encodedText == null || encodedText.trim().isEmpty()) {
|
||||
return encodedText;
|
||||
}
|
||||
@@ -493,7 +516,7 @@ public class EmlProcessingUtils {
|
||||
result.append(processedText, lastEnd, matcher.start());
|
||||
|
||||
String charset = matcher.group(1);
|
||||
String encoding = matcher.group(2).toUpperCase(Locale.ROOT);
|
||||
String encoding = matcher.group(2).toUpperCase();
|
||||
String encodedValue = matcher.group(3);
|
||||
|
||||
try {
|
||||
@@ -532,7 +555,7 @@ public class EmlProcessingUtils {
|
||||
}
|
||||
}
|
||||
|
||||
private String decodeQuotedPrintable(String encodedText, String charset) {
|
||||
private static String decodeQuotedPrintable(String encodedText, String charset) {
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (int i = 0; i < encodedText.length(); i++) {
|
||||
char c = encodedText.charAt(i);
|
||||
@@ -575,7 +598,7 @@ public class EmlProcessingUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public String escapeHtml(String text) {
|
||||
public static String escapeHtml(String text) {
|
||||
if (text == null) return "";
|
||||
return text.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
@@ -584,7 +607,7 @@ public class EmlProcessingUtils {
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
public String sanitizeText(String text, CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
public static String sanitizeText(String text, CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
if (customHtmlSanitizer != null) {
|
||||
return customHtmlSanitizer.sanitize(text);
|
||||
} else {
|
||||
@@ -592,7 +615,7 @@ public class EmlProcessingUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public String simplifyHtmlContent(String htmlContent) {
|
||||
public static String simplifyHtmlContent(String htmlContent) {
|
||||
String simplified =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getScriptTagPattern()
|
||||
|
||||
@@ -13,17 +13,11 @@ public class EmlToPdf {
|
||||
|
||||
public static String convertEmlToHtml(byte[] emlBytes, EmlToPdfRequest request)
|
||||
throws IOException {
|
||||
return convertEmlToHtml(emlBytes, request, null);
|
||||
}
|
||||
|
||||
public static String convertEmlToHtml(
|
||||
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer)
|
||||
throws IOException {
|
||||
EmlProcessingUtils.validateEmlInput(emlBytes);
|
||||
|
||||
EmlParser.EmailContent emailContent =
|
||||
EmlParser.extractEmailContent(emlBytes, request, customHtmlSanitizer);
|
||||
return EmlProcessingUtils.generateEnhancedEmailHtml(emailContent, request, customHtmlSanitizer);
|
||||
EmlParser.extractEmailContent(emlBytes, request, null);
|
||||
return EmlProcessingUtils.generateEnhancedEmailHtml(emailContent, request, null);
|
||||
}
|
||||
|
||||
public static byte[] convertEmlToPdf(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,28 +2,30 @@ package stirling.software.common.util;
|
||||
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
|
||||
/**
|
||||
* Factory for creating executors backed by virtual threads (Java 21+). Virtual threads are
|
||||
* lightweight, managed by the JVM, and ideal for I/O-bound tasks. They eliminate the need for
|
||||
* thread pool sizing since thousands can run concurrently with minimal overhead.
|
||||
*/
|
||||
public final class ExecutorFactory {
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
private ExecutorFactory() {}
|
||||
|
||||
/** Creates an {@link ExecutorService} that starts a new virtual thread for each task. */
|
||||
public static ExecutorService newVirtualThreadExecutor() {
|
||||
return Executors.newVirtualThreadPerTaskExecutor();
|
||||
}
|
||||
@Slf4j
|
||||
public class ExecutorFactory {
|
||||
|
||||
/**
|
||||
* Creates a {@link ScheduledExecutorService} backed by a single virtual thread. Useful for
|
||||
* periodic/delayed tasks that should not pin a platform thread.
|
||||
* Creates an ExecutorService using virtual threads if available (Java 21+), or falls back to a
|
||||
* cached thread pool on older Java versions.
|
||||
*/
|
||||
public static ScheduledExecutorService newSingleVirtualThreadScheduledExecutor() {
|
||||
return Executors.newSingleThreadScheduledExecutor(
|
||||
Thread.ofVirtual().name("scheduled-vt-", 0).factory());
|
||||
public static ExecutorService newVirtualOrCachedThreadExecutor() {
|
||||
try {
|
||||
ExecutorService executor =
|
||||
(ExecutorService)
|
||||
Executors.class
|
||||
.getMethod("newVirtualThreadPerTaskExecutor")
|
||||
.invoke(null);
|
||||
return executor;
|
||||
} catch (NoSuchMethodException e) {
|
||||
log.debug("Virtual threads not available; falling back to cached thread pool.");
|
||||
} catch (Exception e) {
|
||||
log.debug("Error initializing virtual thread executor: {}", e.getMessage(), e);
|
||||
}
|
||||
|
||||
return Executors.newCachedThreadPool();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ public class FileMonitor {
|
||||
private final ConcurrentHashMap.KeySetView<Path, Boolean> readyForProcessingFiles;
|
||||
private final WatchService watchService;
|
||||
private final Predicate<Path> pathFilter;
|
||||
private final List<Path> rootDirs;
|
||||
private final Path rootDir;
|
||||
private Set<Path> stagingFiles;
|
||||
|
||||
/**
|
||||
@@ -47,39 +47,8 @@ public class FileMonitor {
|
||||
this.pathFilter = pathFilter;
|
||||
this.readyForProcessingFiles = ConcurrentHashMap.newKeySet();
|
||||
this.watchService = FileSystems.getDefault().newWatchService();
|
||||
|
||||
List<String> watchedFoldersDirs = runtimePathConfig.getPipelineWatchedFoldersPaths();
|
||||
List<Path> validRootDirs = new ArrayList<>();
|
||||
|
||||
for (String pathStr : watchedFoldersDirs) {
|
||||
try {
|
||||
Path path = Path.of(pathStr);
|
||||
validRootDirs.add(path);
|
||||
log.info("Monitoring directory: {}", path);
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Failed to initialize monitoring for path '{}': {}",
|
||||
pathStr,
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
this.rootDirs = Collections.unmodifiableList(validRootDirs);
|
||||
|
||||
if (this.rootDirs.isEmpty()) {
|
||||
log.error("No valid directories to monitor - FileMonitor will not function");
|
||||
} else {
|
||||
// Register directories eagerly so the first @Scheduled tick does not warn.
|
||||
for (Path rootDir : this.rootDirs) {
|
||||
if (Files.exists(rootDir)) {
|
||||
try {
|
||||
recursivelyRegisterEntry(rootDir);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to register monitoring for {}", rootDir, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info("Monitoring directory: {}", runtimePathConfig.getPipelineWatchedFoldersPath());
|
||||
this.rootDir = Path.of(runtimePathConfig.getPipelineWatchedFoldersPath());
|
||||
}
|
||||
|
||||
private boolean shouldNotProcess(Path path) {
|
||||
@@ -116,15 +85,13 @@ public class FileMonitor {
|
||||
readyForProcessingFiles.clear();
|
||||
|
||||
if (path2KeyMapping.isEmpty()) {
|
||||
log.warn("Not monitoring any directories; attempting to re-register root paths.");
|
||||
for (Path rootDir : rootDirs) {
|
||||
if (Files.exists(
|
||||
rootDir)) { // if the root directory exists, re-register the root directory
|
||||
try {
|
||||
recursivelyRegisterEntry(rootDir);
|
||||
} catch (IOException e) {
|
||||
log.error("unable to register monitoring for {}", rootDir, e);
|
||||
}
|
||||
log.warn("not monitoring any directory, even the root directory itself: {}", rootDir);
|
||||
if (Files.exists(
|
||||
rootDir)) { // if the root directory exists, re-register the root directory
|
||||
try {
|
||||
recursivelyRegisterEntry(rootDir);
|
||||
} catch (IOException e) {
|
||||
log.error("unable to register monitoring", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
@@ -35,17 +37,17 @@ public class FileToPdf {
|
||||
try (TempFile tempInputFile =
|
||||
new TempFile(
|
||||
tempFileManager,
|
||||
fileName.toLowerCase(Locale.ROOT).endsWith(".html")
|
||||
? ".html"
|
||||
: ".zip")) {
|
||||
fileName.toLowerCase().endsWith(".html") ? ".html" : ".zip")) {
|
||||
|
||||
if (fileName.toLowerCase(Locale.ROOT).endsWith(".html")) {
|
||||
if (fileName.toLowerCase().endsWith(".html")) {
|
||||
String sanitizedHtml =
|
||||
sanitizeHtmlContent(
|
||||
new String(fileBytes, StandardCharsets.UTF_8),
|
||||
customHtmlSanitizer);
|
||||
Files.writeString(tempInputFile.getPath(), sanitizedHtml);
|
||||
} else if (fileName.toLowerCase(Locale.ROOT).endsWith(".zip")) {
|
||||
Files.write(
|
||||
tempInputFile.getPath(),
|
||||
sanitizedHtml.getBytes(StandardCharsets.UTF_8));
|
||||
} else if (fileName.toLowerCase().endsWith(".zip")) {
|
||||
Files.write(tempInputFile.getPath(), fileBytes);
|
||||
sanitizeHtmlFilesInZip(
|
||||
tempInputFile.getPath(), tempFileManager, customHtmlSanitizer);
|
||||
@@ -98,22 +100,16 @@ public class FileToPdf {
|
||||
while (entry != null) {
|
||||
Path filePath =
|
||||
tempUnzippedDir.getPath().resolve(sanitizeZipFilename(entry.getName()));
|
||||
Path normalizedTargetDir =
|
||||
tempUnzippedDir.getPath().toAbsolutePath().normalize();
|
||||
Path normalizedFilePath = filePath.toAbsolutePath().normalize();
|
||||
if (!normalizedFilePath.startsWith(normalizedTargetDir)) {
|
||||
throw new IOException(
|
||||
"Zip entry path escapes target directory: " + entry.getName());
|
||||
}
|
||||
if (!entry.isDirectory()) {
|
||||
Files.createDirectories(filePath.getParent());
|
||||
if (entry.getName().toLowerCase(Locale.ROOT).endsWith(".html")
|
||||
|| entry.getName().toLowerCase(Locale.ROOT).endsWith(".htm")) {
|
||||
if (entry.getName().toLowerCase().endsWith(".html")
|
||||
|| entry.getName().toLowerCase().endsWith(".htm")) {
|
||||
String content =
|
||||
new String(zipIn.readAllBytes(), StandardCharsets.UTF_8);
|
||||
String sanitizedContent =
|
||||
sanitizeHtmlContent(content, customHtmlSanitizer);
|
||||
Files.writeString(filePath, sanitizedContent);
|
||||
Files.write(
|
||||
filePath, sanitizedContent.getBytes(StandardCharsets.UTF_8));
|
||||
} else {
|
||||
Files.copy(zipIn, filePath);
|
||||
}
|
||||
@@ -149,6 +145,64 @@ public class FileToPdf {
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteDirectory(Path dir) throws IOException {
|
||||
Files.walkFileTree(
|
||||
dir,
|
||||
new SimpleFileVisitor<Path>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
|
||||
throws IOException {
|
||||
Files.delete(file);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult postVisitDirectory(Path dir, IOException exc)
|
||||
throws IOException {
|
||||
Files.delete(dir);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Path unzipAndGetMainHtml(byte[] fileBytes) throws IOException {
|
||||
Path tempDirectory = Files.createTempDirectory("unzipped_");
|
||||
try (ZipInputStream zipIn =
|
||||
ZipSecurity.createHardenedInputStream(new ByteArrayInputStream(fileBytes))) {
|
||||
ZipEntry entry = zipIn.getNextEntry();
|
||||
while (entry != null) {
|
||||
Path filePath = tempDirectory.resolve(sanitizeZipFilename(entry.getName()));
|
||||
if (entry.isDirectory()) {
|
||||
Files.createDirectories(filePath); // Explicitly create the directory structure
|
||||
} else {
|
||||
Files.createDirectories(
|
||||
filePath.getParent()); // Create parent directories if they don't exist
|
||||
Files.copy(zipIn, filePath);
|
||||
}
|
||||
zipIn.closeEntry();
|
||||
entry = zipIn.getNextEntry();
|
||||
}
|
||||
}
|
||||
|
||||
// Search for the main HTML file.
|
||||
try (Stream<Path> walk = Files.walk(tempDirectory)) {
|
||||
List<Path> htmlFiles = walk.filter(file -> file.toString().endsWith(".html")).toList();
|
||||
|
||||
if (htmlFiles.isEmpty()) {
|
||||
throw new IOException("No HTML files found in the unzipped directory.");
|
||||
}
|
||||
|
||||
// Prioritize 'index.html' if it exists, otherwise use the first .html file
|
||||
for (Path htmlFile : htmlFiles) {
|
||||
if ("index.html".equals(htmlFile.getFileName().toString())) {
|
||||
return htmlFile;
|
||||
}
|
||||
}
|
||||
|
||||
return htmlFiles.get(0);
|
||||
}
|
||||
}
|
||||
|
||||
static String sanitizeZipFilename(String entryName) {
|
||||
if (entryName == null || entryName.trim().isEmpty()) {
|
||||
return "";
|
||||
|
||||
-165
@@ -1,165 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDListBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Simplified form field type support for general PDF operations. This is a subset of the full
|
||||
* proprietary FormFieldTypeSupport, containing only what's needed for basic form field copying
|
||||
* during page operations.
|
||||
*/
|
||||
@Slf4j
|
||||
public enum GeneralFormFieldTypeSupport {
|
||||
TEXT("text", "textField", PDTextField.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
PDTextField textField = new PDTextField(acroForm);
|
||||
textField.setDefaultAppearance("/Helv 12 Tf 0 g");
|
||||
return textField;
|
||||
}
|
||||
|
||||
@Override
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
PDTextField src = (PDTextField) source;
|
||||
PDTextField dst = (PDTextField) target;
|
||||
String value = src.getValueAsString();
|
||||
if (value != null) {
|
||||
dst.setValue(value);
|
||||
}
|
||||
}
|
||||
},
|
||||
CHECKBOX("checkbox", "checkBox", PDCheckBox.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDCheckBox(acroForm);
|
||||
}
|
||||
|
||||
@Override
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
PDCheckBox src = (PDCheckBox) source;
|
||||
PDCheckBox dst = (PDCheckBox) target;
|
||||
if (src.isChecked()) {
|
||||
dst.check();
|
||||
} else {
|
||||
dst.unCheck();
|
||||
}
|
||||
}
|
||||
},
|
||||
RADIO("radio", "radioButton", PDRadioButton.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDRadioButton(acroForm);
|
||||
}
|
||||
|
||||
@Override
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
PDRadioButton src = (PDRadioButton) source;
|
||||
PDRadioButton dst = (PDRadioButton) target;
|
||||
if (src.getExportValues() != null) {
|
||||
dst.setExportValues(src.getExportValues());
|
||||
}
|
||||
if (src.getValue() != null) {
|
||||
dst.setValue(src.getValue());
|
||||
}
|
||||
}
|
||||
},
|
||||
COMBOBOX("combobox", "comboBox", PDComboBox.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDComboBox(acroForm);
|
||||
}
|
||||
|
||||
@Override
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
PDComboBox src = (PDComboBox) source;
|
||||
PDComboBox dst = (PDComboBox) target;
|
||||
if (src.getOptions() != null) {
|
||||
dst.setOptions(src.getOptions());
|
||||
}
|
||||
if (src.getValue() != null && !src.getValue().isEmpty()) {
|
||||
dst.setValue(src.getValue());
|
||||
}
|
||||
}
|
||||
},
|
||||
LISTBOX("listbox", "listBox", PDListBox.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDListBox(acroForm);
|
||||
}
|
||||
|
||||
@Override
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
PDListBox src = (PDListBox) source;
|
||||
PDListBox dst = (PDListBox) target;
|
||||
if (src.getOptions() != null) {
|
||||
dst.setOptions(src.getOptions());
|
||||
}
|
||||
if (src.getValue() != null && !src.getValue().isEmpty()) {
|
||||
dst.setValue(src.getValue());
|
||||
}
|
||||
}
|
||||
},
|
||||
SIGNATURE("signature", "signature", PDSignatureField.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDSignatureField(acroForm);
|
||||
}
|
||||
},
|
||||
BUTTON("button", "pushButton", PDPushButton.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDPushButton(acroForm);
|
||||
}
|
||||
};
|
||||
|
||||
private final String typeName;
|
||||
private final String fallbackWidgetName;
|
||||
private final Class<? extends PDTerminalField> fieldClass;
|
||||
|
||||
GeneralFormFieldTypeSupport(
|
||||
String typeName,
|
||||
String fallbackWidgetName,
|
||||
Class<? extends PDTerminalField> fieldClass) {
|
||||
this.typeName = typeName;
|
||||
this.fallbackWidgetName = fallbackWidgetName;
|
||||
this.fieldClass = fieldClass;
|
||||
}
|
||||
|
||||
public static GeneralFormFieldTypeSupport forField(PDField field) {
|
||||
if (field == null) {
|
||||
return null;
|
||||
}
|
||||
for (GeneralFormFieldTypeSupport handler : values()) {
|
||||
if (handler.fieldClass.isInstance(field)) {
|
||||
return handler;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String typeName() {
|
||||
return typeName;
|
||||
}
|
||||
|
||||
String fallbackWidgetName() {
|
||||
return fallbackWidgetName;
|
||||
}
|
||||
|
||||
abstract PDTerminalField createField(PDAcroForm acroForm);
|
||||
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
// default no-op
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
@@ -35,15 +33,6 @@ import stirling.software.common.configuration.InstallationPathConfig;
|
||||
@UtilityClass
|
||||
public class GeneralUtils {
|
||||
|
||||
/**
|
||||
* Maximum number of resolved DNS addresses allowed for a host before it is considered unsafe.
|
||||
*/
|
||||
private static final int MAX_DNS_ADDRESSES = 20;
|
||||
|
||||
// Constants for size conversion
|
||||
private static final BigDecimal KIB = BigDecimal.valueOf(1024L);
|
||||
private static final BigDecimal LONG_MAX_DECIMAL = BigDecimal.valueOf(Long.MAX_VALUE);
|
||||
|
||||
private final Set<String> DEFAULT_VALID_SCRIPTS = Set.of("png_to_webp.py", "split_photos.py");
|
||||
private final Set<String> DEFAULT_VALID_PIPELINE =
|
||||
Set.of(
|
||||
@@ -97,18 +86,36 @@ public class GeneralUtils {
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
if (tempFile.exists()) {
|
||||
try {
|
||||
Files.delete(tempFile.toPath());
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return tempFile;
|
||||
}
|
||||
|
||||
/*
|
||||
* Gets the configured temporary directory, creating it if necessary.
|
||||
*
|
||||
* @return Path to the temporary directory
|
||||
* @throws IOException if directory creation fails
|
||||
*/
|
||||
private Path getTempDirectory() throws IOException {
|
||||
String customTempDir = System.getenv("STIRLING_TEMPFILES_DIRECTORY");
|
||||
if (customTempDir == null || customTempDir.isEmpty()) {
|
||||
customTempDir = System.getProperty("stirling.tempfiles.directory");
|
||||
}
|
||||
|
||||
Path tempDir;
|
||||
if (customTempDir != null && !customTempDir.isEmpty()) {
|
||||
tempDir = Path.of(customTempDir);
|
||||
} else {
|
||||
tempDir = Path.of(System.getProperty("java.io.tmpdir"), "stirling-pdf");
|
||||
}
|
||||
|
||||
if (!Files.exists(tempDir)) {
|
||||
Files.createDirectories(tempDir);
|
||||
}
|
||||
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
/*
|
||||
* Remove file extension
|
||||
*
|
||||
@@ -147,7 +154,7 @@ public class GeneralUtils {
|
||||
return matcher.find() ? matcher.replaceFirst("") : filename;
|
||||
}
|
||||
|
||||
/**
|
||||
/*
|
||||
* Append suffix to base name with null safety.
|
||||
*
|
||||
* @param baseName the base filename, null becomes "default"
|
||||
@@ -158,7 +165,7 @@ public class GeneralUtils {
|
||||
return (baseName == null ? "default" : baseName) + (suffix != null ? suffix : "");
|
||||
}
|
||||
|
||||
/**
|
||||
/*
|
||||
* Generate a PDF filename by removing extension from first file and adding suffix.
|
||||
*
|
||||
* <p>High-level utility method for common PDF naming scenarios. Handles null safety and uses
|
||||
@@ -173,7 +180,7 @@ public class GeneralUtils {
|
||||
return appendSuffix(baseName, suffix);
|
||||
}
|
||||
|
||||
/**
|
||||
/*
|
||||
* Process a list of filenames by removing extensions and adding suffix.
|
||||
*
|
||||
* <p>Efficiently processes multiple filenames using streaming operations and bulk operations
|
||||
@@ -194,7 +201,7 @@ public class GeneralUtils {
|
||||
.forEach(processor);
|
||||
}
|
||||
|
||||
/**
|
||||
/*
|
||||
* Extract title from filename by removing extension, with fallback handling.
|
||||
*
|
||||
* <p>Returns "Untitled" for null or empty filenames, otherwise removes the extension using the
|
||||
@@ -262,12 +269,6 @@ public class GeneralUtils {
|
||||
.getResources(pattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates URL syntax and disallows common-infrastructure targets to reduce SSRF risk.
|
||||
*
|
||||
* @param urlStr a URL string to validate
|
||||
* @return {@code true} if the URL is syntactically valid and allowed; {@code false} otherwise
|
||||
*/
|
||||
public boolean isValidURL(String urlStr) {
|
||||
try {
|
||||
Urls.create(
|
||||
@@ -278,7 +279,7 @@ public class GeneralUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/*
|
||||
* Checks if a URL is reachable with proper timeout configuration and error handling.
|
||||
*
|
||||
* @param urlStr the URL string to check
|
||||
@@ -288,17 +289,15 @@ public class GeneralUtils {
|
||||
return isURLReachable(urlStr, 5000, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a URL is reachable using configurable timeouts. Only {@code http} and {@code
|
||||
* https} protocols are permitted, and local/private/multicast ranges are blocked.
|
||||
/*
|
||||
* Checks if a URL is reachable with configurable timeouts.
|
||||
*
|
||||
* @param urlStr the URL to probe
|
||||
* @param urlStr the URL string to check
|
||||
* @param connectTimeout connection timeout in milliseconds
|
||||
* @param readTimeout read timeout in milliseconds
|
||||
* @return {@code true} if a HEAD request returns a 2xx or 3xx status; {@code false} otherwise
|
||||
* @return true if URL is reachable, false otherwise
|
||||
*/
|
||||
public boolean isURLReachable(String urlStr, int connectTimeout, int readTimeout) {
|
||||
HttpURLConnection connection = null;
|
||||
try {
|
||||
// Parse the URL
|
||||
URL url = URI.create(urlStr).toURL();
|
||||
@@ -309,17 +308,14 @@ public class GeneralUtils {
|
||||
return false; // Disallow other protocols
|
||||
}
|
||||
|
||||
// Check if the host is a local address
|
||||
String host = url.getHost();
|
||||
if (host == null || host.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isDisallowedNetworkLocation(host)) {
|
||||
return false; // Exclude local, private or otherwise sensitive addresses
|
||||
if (isLocalAddress(host)) {
|
||||
return false; // Exclude local addresses
|
||||
}
|
||||
|
||||
// Check if the URL is reachable
|
||||
connection = (HttpURLConnection) url.openConnection();
|
||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setRequestMethod("HEAD");
|
||||
connection.setConnectTimeout(connectTimeout);
|
||||
connection.setReadTimeout(readTimeout);
|
||||
@@ -330,173 +326,29 @@ public class GeneralUtils {
|
||||
} catch (Exception e) {
|
||||
log.debug("URL {} is not reachable: {}", urlStr, e.getMessage());
|
||||
return false; // Return false in case of any exception
|
||||
} finally {
|
||||
if (connection != null) {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the specified host resolves to a disallowed network location, such as
|
||||
* local, private, multicast, or reserved ranges. Excessive DNS results are also blocked.
|
||||
*
|
||||
* @param host the hostname to resolve
|
||||
* @return {@code true} if the host should be considered unsafe
|
||||
*/
|
||||
private boolean isDisallowedNetworkLocation(String host) {
|
||||
// Resolution is delegated to the JVM/OS resolver which already applies system
|
||||
// configured query limits and timeouts. We only need the resolved addresses here so
|
||||
// that we can enforce the MAX_DNS_ADDRESSES limit and perform the sensitive range
|
||||
// checks below.
|
||||
private boolean isLocalAddress(String host) {
|
||||
try {
|
||||
InetAddress[] addresses = InetAddress.getAllByName(host);
|
||||
if (addresses.length > MAX_DNS_ADDRESSES) {
|
||||
log.debug(
|
||||
"Blocking URL to host {} due to excessive DNS records (>{})",
|
||||
host,
|
||||
MAX_DNS_ADDRESSES);
|
||||
return true;
|
||||
}
|
||||
for (InetAddress address : addresses) {
|
||||
if (address == null || isSensitiveAddress(address)) {
|
||||
log.debug("Blocking URL to host {} resolved to {}", host, address);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
// Resolve DNS to IP address
|
||||
InetAddress address = InetAddress.getByName(host);
|
||||
|
||||
// Check for local addresses
|
||||
return address.isAnyLocalAddress()
|
||||
|| // Matches 0.0.0.0 or similar
|
||||
address.isLoopbackAddress()
|
||||
|| // Matches 127.0.0.1 or ::1
|
||||
address.isSiteLocalAddress()
|
||||
|| // Matches private IPv4 ranges: 192.168.x.x, 10.x.x.x, 172.16.x.x to
|
||||
// 172.31.x.x
|
||||
address.getHostAddress()
|
||||
.startsWith("fe80:"); // Matches link-local IPv6 addresses
|
||||
} catch (Exception e) {
|
||||
log.debug("Unable to resolve host {}: {}", host, e.getMessage());
|
||||
return true; // Treat resolution issues as unsafe to avoid SSRF
|
||||
return false; // Return false for invalid or unresolved addresses
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given IP address lies within ranges that should not be contacted by the
|
||||
* server (loopback, link-local, private, multicast, etc.). IPv6 ULA and IPv4-mapped addresses
|
||||
* are handled.
|
||||
*
|
||||
* @param address the resolved address
|
||||
* @return {@code true} if the address is considered sensitive
|
||||
*/
|
||||
private boolean isSensitiveAddress(InetAddress address) {
|
||||
if (address.isAnyLocalAddress()
|
||||
|| address.isLoopbackAddress()
|
||||
|| address.isLinkLocalAddress()
|
||||
|| address.isSiteLocalAddress()
|
||||
|| address.isMulticastAddress()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
byte[] rawAddress = address.getAddress();
|
||||
if (address instanceof Inet4Address) {
|
||||
return isPrivateOrReservedIPv4(rawAddress);
|
||||
}
|
||||
|
||||
if (address instanceof Inet6Address inet6Address) {
|
||||
if (isUniqueLocalIPv6(rawAddress)) {
|
||||
return true;
|
||||
}
|
||||
if (isIPv4MappedAddress(rawAddress) || inet6Address.isIPv4CompatibleAddress()) {
|
||||
byte[] ipv4 =
|
||||
Arrays.copyOfRange(rawAddress, rawAddress.length - 4, rawAddress.length);
|
||||
return isPrivateOrReservedIPv4(ipv4);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an IPv4 address is private or reserved. Any malformed input defaults to {@code
|
||||
* true} (conservative) to avoid misuse.
|
||||
*
|
||||
* @param address 4-byte IPv4 address
|
||||
* @return {@code true} if private/reserved
|
||||
*/
|
||||
private boolean isPrivateOrReservedIPv4(byte[] address) {
|
||||
// IPv4 addresses must be exactly 4 bytes. Treat null or unexpected lengths as
|
||||
// sensitive to avoid processing malformed input.
|
||||
if (address == null || address.length != 4) {
|
||||
return true;
|
||||
}
|
||||
|
||||
int first = Byte.toUnsignedInt(address[0]);
|
||||
int second = Byte.toUnsignedInt(address[1]);
|
||||
|
||||
if (first == 0 || first == 127) {
|
||||
return true; // 0.0.0.0/8 and 127.0.0.0/8
|
||||
}
|
||||
if (first == 100 && second >= 64 && second <= 127) {
|
||||
return true; // 100.64.0.0/10 Carrier-grade NAT
|
||||
}
|
||||
if (first == 169 && second == 254) {
|
||||
return true; // 169.254.0.0/16 Link-local
|
||||
}
|
||||
if (first == 172 && second >= 16 && second <= 31) {
|
||||
return true; // 172.16.0.0/12 Private
|
||||
}
|
||||
if (first == 192 && second == 0 && Byte.toUnsignedInt(address[2]) == 0) {
|
||||
return true; // 192.0.0.0/24 IETF Protocol Assignments
|
||||
}
|
||||
if (first == 192 && second == 0 && Byte.toUnsignedInt(address[2]) == 2) {
|
||||
return true; // 192.0.2.0/24 TEST-NET-1
|
||||
}
|
||||
if (first == 192 && second == 168) {
|
||||
return true; // 192.168.0.0/16 Private
|
||||
}
|
||||
if (first == 198 && (second == 18 || second == 19)) {
|
||||
return true; // 198.18.0.0/15 Benchmark tests
|
||||
}
|
||||
if (first == 198 && second == 51 && Byte.toUnsignedInt(address[2]) == 100) {
|
||||
return true; // 198.51.100.0/24 TEST-NET-2
|
||||
}
|
||||
if (first == 203 && second == 0 && Byte.toUnsignedInt(address[2]) == 113) {
|
||||
return true; // 203.0.113.0/24 TEST-NET-3
|
||||
}
|
||||
if (first == 10) {
|
||||
return true; // 10.0.0.0/8 Private
|
||||
}
|
||||
if (first >= 224) {
|
||||
return true; // 224.0.0.0/4 Multicast and 240.0.0.0/4 Reserved for future use
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an IPv6 address is a Unique Local Address (ULA, fc00::/7). Any malformed input
|
||||
* defaults to {@code true} (conservative) to avoid misuse.
|
||||
*
|
||||
* @param address 16-byte IPv6 address
|
||||
* @return {@code true} if ULA
|
||||
*/
|
||||
private boolean isUniqueLocalIPv6(byte[] address) {
|
||||
if (address == null || address.length != 16) {
|
||||
return true;
|
||||
}
|
||||
int first = Byte.toUnsignedInt(address[0]);
|
||||
return (first & 0xFE) == 0xFC; // fc00::/7 Unique local addresses
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an IPv6 address is an IPv4-mapped address (::ffff:0:0/96). Any malformed input
|
||||
* defaults to {@code false} (conservative) to avoid misuse.
|
||||
*
|
||||
* @param address 16-byte IPv6 address
|
||||
* @return {@code true} if IPv4-mapped
|
||||
*/
|
||||
private boolean isIPv4MappedAddress(byte[] address) {
|
||||
if (address == null || address.length != 16) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < 10; i++) {
|
||||
if (address[i] != 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return address[10] == (byte) 0xFF && address[11] == (byte) 0xFF;
|
||||
}
|
||||
|
||||
/*
|
||||
* Improved multipart file conversion using the shared helper method.
|
||||
*
|
||||
@@ -513,12 +365,6 @@ public class GeneralUtils {
|
||||
while ((bytesRead = in.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, bytesRead);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
try {
|
||||
Files.deleteIfExists(tempFile);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return tempFile.toFile();
|
||||
}
|
||||
@@ -540,31 +386,44 @@ public class GeneralUtils {
|
||||
throw new IllegalArgumentException("Invalid default unit: " + defaultUnit);
|
||||
}
|
||||
|
||||
sizeStr = sizeStr.trim().toUpperCase(Locale.ROOT);
|
||||
sizeStr = sizeStr.trim().toUpperCase();
|
||||
sizeStr = sizeStr.replace(",", ".").replace(" ", "");
|
||||
|
||||
try {
|
||||
if (sizeStr.endsWith("TB")) {
|
||||
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 2)), 4);
|
||||
return (long)
|
||||
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
|
||||
* 1024L
|
||||
* 1024L
|
||||
* 1024L
|
||||
* 1024L);
|
||||
} else if (sizeStr.endsWith("GB")) {
|
||||
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 2)), 3);
|
||||
return (long)
|
||||
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
|
||||
* 1024L
|
||||
* 1024L
|
||||
* 1024L);
|
||||
} else if (sizeStr.endsWith("MB")) {
|
||||
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 2)), 2);
|
||||
return (long)
|
||||
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
|
||||
* 1024L
|
||||
* 1024L);
|
||||
} else if (sizeStr.endsWith("KB")) {
|
||||
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 2)), 1);
|
||||
return (long)
|
||||
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2)) * 1024L);
|
||||
} else if (!sizeStr.isEmpty() && sizeStr.charAt(sizeStr.length() - 1) == 'B') {
|
||||
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 1)), 0);
|
||||
return Long.parseLong(sizeStr.substring(0, sizeStr.length() - 1));
|
||||
} else {
|
||||
// Use provided default unit or fall back to MB
|
||||
String unit = defaultUnit != null ? defaultUnit.toUpperCase(Locale.ROOT) : "MB";
|
||||
BigDecimal value = parseSizeValue(sizeStr);
|
||||
String unit = defaultUnit != null ? defaultUnit.toUpperCase() : "MB";
|
||||
double value = Double.parseDouble(sizeStr);
|
||||
return switch (unit) {
|
||||
case "TB" -> toBytes(value, 4);
|
||||
case "GB" -> toBytes(value, 3);
|
||||
case "MB" -> toBytes(value, 2);
|
||||
case "KB" -> toBytes(value, 1);
|
||||
case "B" -> toBytes(value, 0);
|
||||
default -> toBytes(value, 2); // Default to MB
|
||||
case "TB" -> (long) (value * 1024L * 1024L * 1024L * 1024L);
|
||||
case "GB" -> (long) (value * 1024L * 1024L * 1024L);
|
||||
case "MB" -> (long) (value * 1024L * 1024L);
|
||||
case "KB" -> (long) (value * 1024L);
|
||||
case "B" -> (long) value;
|
||||
default -> (long) (value * 1024L * 1024L); // Default to MB
|
||||
};
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
@@ -583,30 +442,6 @@ public class GeneralUtils {
|
||||
return convertSizeToBytes(sizeStr, "MB");
|
||||
}
|
||||
|
||||
private Long toBytes(BigDecimal value, int powerOf1024) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value.compareTo(BigDecimal.ZERO) < 0) {
|
||||
log.warn("Size value cannot be negative: {}", value);
|
||||
return null;
|
||||
}
|
||||
if (powerOf1024 < 0 || powerOf1024 > 4) {
|
||||
throw new IllegalArgumentException("Invalid power for size conversion: " + powerOf1024);
|
||||
}
|
||||
BigDecimal multiplier = powerOf1024 == 0 ? BigDecimal.ONE : KIB.pow(powerOf1024);
|
||||
BigDecimal bytes = value.multiply(multiplier).setScale(0, RoundingMode.DOWN);
|
||||
if (bytes.compareTo(LONG_MAX_DECIMAL) > 0) {
|
||||
log.warn("Size value too large to fit in long: {}", bytes);
|
||||
return null;
|
||||
}
|
||||
return bytes.longValue();
|
||||
}
|
||||
|
||||
private BigDecimal parseSizeValue(String value) {
|
||||
return new BigDecimal(value);
|
||||
}
|
||||
|
||||
/* Validates if a string represents a valid size unit. */
|
||||
private boolean isValidSizeUnit(String unit) {
|
||||
// Use a precomputed Set for O(1) lookup, normalize using a locale-safe toUpperCase
|
||||
@@ -622,14 +457,13 @@ public class GeneralUtils {
|
||||
if (bytes < 1024) {
|
||||
return bytes + " B";
|
||||
} else if (bytes < 1024L * 1024L) {
|
||||
return String.format(Locale.ROOT, "%.2f KB", bytes / 1024.0);
|
||||
return String.format(Locale.US, "%.2f KB", bytes / 1024.0);
|
||||
} else if (bytes < 1024L * 1024L * 1024L) {
|
||||
return String.format(Locale.ROOT, "%.2f MB", bytes / (1024.0 * 1024.0));
|
||||
return String.format(Locale.US, "%.2f MB", bytes / (1024.0 * 1024.0));
|
||||
} else if (bytes < 1024L * 1024L * 1024L * 1024L) {
|
||||
return String.format(Locale.ROOT, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0));
|
||||
return String.format(Locale.US, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0));
|
||||
} else {
|
||||
return String.format(
|
||||
Locale.ROOT, "%.2f TB", bytes / (1024.0 * 1024.0 * 1024.0 * 1024.0));
|
||||
return String.format(Locale.US, "%.2f TB", bytes / (1024.0 * 1024.0 * 1024.0 * 1024.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,10 +483,8 @@ public class GeneralUtils {
|
||||
}
|
||||
|
||||
public List<Integer> parsePageList(String[] pages, int totalPages, boolean oneBased) {
|
||||
// Use LinkedHashSet to prevent duplicates from inflating size and triggering maxSize guard
|
||||
Set<Integer> result = new LinkedHashSet<>();
|
||||
List<Integer> result = new ArrayList<>();
|
||||
int offset = oneBased ? 1 : 0;
|
||||
int maxSize = Math.max(1000, totalPages * 3);
|
||||
for (String page : pages) {
|
||||
if ("all".equalsIgnoreCase(page)) {
|
||||
|
||||
@@ -668,12 +500,8 @@ public class GeneralUtils {
|
||||
} else {
|
||||
result.addAll(handlePart(page, totalPages, offset));
|
||||
}
|
||||
if (result.size() > maxSize) {
|
||||
throw new IllegalArgumentException(
|
||||
"Page list exceeds maximum allowed size of " + maxSize);
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -873,36 +701,6 @@ public class GeneralUtils {
|
||||
settingsYaml.saveOverride(settingsPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates multiple settings in a single transaction. This ensures that nested settings (e.g.,
|
||||
* oauth2.client.google.*) don't lose sibling values when partial updates are made.
|
||||
*
|
||||
* <p>Instead of multiple read-update-write cycles (which could cause race conditions), this
|
||||
* method loads the YAML once, applies all updates, and saves once.
|
||||
*
|
||||
* @param settingsMap Map of dotted-notation keys to values to update
|
||||
* @throws IOException if file read/write fails
|
||||
*/
|
||||
public void updateSettingsTransactional(Map<String, Object> settingsMap) throws IOException {
|
||||
if (settingsMap == null || settingsMap.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
|
||||
YamlHelper settingsYaml = new YamlHelper(settingsPath);
|
||||
|
||||
// Apply all updates to the same YamlHelper instance
|
||||
for (Map.Entry<String, Object> entry : settingsMap.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
String[] keyArray = key.split("\\.");
|
||||
settingsYaml.updateValue(Arrays.asList(keyArray), value);
|
||||
}
|
||||
|
||||
// Save only once after all updates are applied
|
||||
settingsYaml.saveOverride(settingsPath);
|
||||
}
|
||||
|
||||
/*
|
||||
* Machine fingerprint generation with better error logging and fallbacks.
|
||||
*
|
||||
@@ -925,7 +723,7 @@ public class GeneralUtils {
|
||||
byte[] mac = net.getHardwareAddress();
|
||||
if (mac != null && mac.length > 0) {
|
||||
for (byte b : mac) {
|
||||
sb.append(String.format(Locale.ROOT, "%02X", b));
|
||||
sb.append(String.format("%02X", b));
|
||||
}
|
||||
break; // Use the first valid network interface
|
||||
}
|
||||
@@ -935,7 +733,7 @@ public class GeneralUtils {
|
||||
byte[] mac = network.getHardwareAddress();
|
||||
if (mac != null) {
|
||||
for (byte b : mac) {
|
||||
sb.append(String.format(Locale.ROOT, "%02X", b));
|
||||
sb.append(String.format("%02X", b));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1142,29 +940,17 @@ public class GeneralUtils {
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
|
||||
.runCommandWithOutputHandling(command);
|
||||
|
||||
ExceptionUtils.GhostscriptException detectedError =
|
||||
ExceptionUtils.detectGhostscriptCriticalError(result.getMessages());
|
||||
if (detectedError != null) {
|
||||
log.warn(
|
||||
"Ghostscript ebook optimization reported a critical error: {}",
|
||||
detectedError.getMessage());
|
||||
throw detectedError;
|
||||
}
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
log.warn(
|
||||
"Ghostscript ebook optimization failed with return code: {}",
|
||||
result.getRc());
|
||||
throw ExceptionUtils.createGhostscriptCompressionException(result.getMessages());
|
||||
throw ExceptionUtils.createGhostscriptCompressionException();
|
||||
}
|
||||
|
||||
return Files.readAllBytes(tempOutput);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Ghostscript ebook optimization failed", e);
|
||||
if (e instanceof ExceptionUtils.GhostscriptException ghostscriptException) {
|
||||
throw ghostscriptException;
|
||||
}
|
||||
throw ExceptionUtils.createGhostscriptCompressionException(e);
|
||||
} finally {
|
||||
if (tempInput != null) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Iterator;
|
||||
import java.util.Locale;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageReader;
|
||||
@@ -63,16 +62,15 @@ public class ImageProcessingUtils {
|
||||
} else {
|
||||
int width = image.getWidth();
|
||||
int height = image.getHeight();
|
||||
int[] pixels = new int[width * height];
|
||||
|
||||
image.getRGB(0, 0, width, height, pixels, 0, width);
|
||||
|
||||
byte[] data = new byte[width * height * 3];
|
||||
int index = 0;
|
||||
for (int rgb : pixels) {
|
||||
data[index++] = (byte) ((rgb >> 16) & 0xFF); // Red
|
||||
data[index++] = (byte) ((rgb >> 8) & 0xFF); // Green
|
||||
data[index++] = (byte) (rgb & 0xFF); // Blue
|
||||
for (int y = 0; y < height; y++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
int rgb = image.getRGB(x, y);
|
||||
data[index++] = (byte) ((rgb >> 16) & 0xFF); // Red
|
||||
data[index++] = (byte) ((rgb >> 8) & 0xFF); // Green
|
||||
data[index++] = (byte) (rgb & 0xFF); // Blue
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -120,7 +118,7 @@ public class ImageProcessingUtils {
|
||||
BufferedImage image = null;
|
||||
String filename = file.getOriginalFilename();
|
||||
|
||||
if (filename != null && filename.toLowerCase(Locale.ROOT).endsWith(".psd")) {
|
||||
if (filename != null && filename.toLowerCase().endsWith(".psd")) {
|
||||
// For PSD files, try explicit ImageReader
|
||||
Iterator<ImageReader> readers = ImageIO.getImageReadersByFormatName("PSD");
|
||||
if (readers.hasNext()) {
|
||||
@@ -136,8 +134,7 @@ public class ImageProcessingUtils {
|
||||
throw new IOException(
|
||||
"Unable to read image from file: "
|
||||
+ filename
|
||||
+ ". Supported PSD formats: RGB/CMYK/Gray 8-32 bit, RLE/ZIP"
|
||||
+ " compression");
|
||||
+ ". Supported PSD formats: RGB/CMYK/Gray 8-32 bit, RLE/ZIP compression");
|
||||
}
|
||||
} else {
|
||||
// For non-PSD files, use standard ImageIO
|
||||
@@ -145,7 +142,7 @@ public class ImageProcessingUtils {
|
||||
}
|
||||
|
||||
if (image == null) {
|
||||
throw ExceptionUtils.createImageReadException(filename);
|
||||
throw new IOException("Unable to read image from file: " + filename);
|
||||
}
|
||||
|
||||
double orientation = extractImageOrientation(file.getInputStream());
|
||||
|
||||
@@ -10,7 +10,6 @@ import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@@ -28,24 +27,15 @@ import io.github.pixee.security.Filenames;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
|
||||
@Slf4j
|
||||
public class PDFToFile {
|
||||
|
||||
private static final Pattern PATTERN =
|
||||
Pattern.compile("(!\\[.*?\\])\\((?!images/)([^/)][^)]*?)\\)");
|
||||
private final TempFileManager tempFileManager;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
public PDFToFile(TempFileManager tempFileManager) {
|
||||
this(tempFileManager, null);
|
||||
}
|
||||
|
||||
public PDFToFile(TempFileManager tempFileManager, RuntimePathConfig runtimePathConfig) {
|
||||
this.tempFileManager = tempFileManager;
|
||||
this.runtimePathConfig = runtimePathConfig;
|
||||
}
|
||||
|
||||
public ResponseEntity<byte[]> processPdfToMarkdown(MultipartFile inputFile)
|
||||
@@ -110,65 +100,56 @@ public class PDFToFile {
|
||||
File[] outputFiles =
|
||||
Objects.requireNonNull(tempOutputDir.getPath().toFile().listFiles());
|
||||
List<File> markdownFiles = new ArrayList<>();
|
||||
List<File> imageFiles = new ArrayList<>();
|
||||
|
||||
// Convert HTML files to Markdown and collect image files
|
||||
// Convert HTML files to Markdown
|
||||
for (File outputFile : outputFiles) {
|
||||
if (outputFile.getName().endsWith(".html")) {
|
||||
String html = Files.readString(outputFile.toPath());
|
||||
String markdown = htmlToMarkdownConverter.convert(html);
|
||||
|
||||
// Update image references to point to images/ folder
|
||||
markdown = updateImageReferences(markdown);
|
||||
|
||||
String mdFileName = outputFile.getName().replace(".html", ".md");
|
||||
File mdFile = new File(tempOutputDir.getPath().toFile(), mdFileName);
|
||||
Files.writeString(mdFile.toPath(), markdown);
|
||||
markdownFiles.add(mdFile);
|
||||
} else if (!outputFile.getName().endsWith(".md")) {
|
||||
// Collect non-HTML, non-MD files as images/assets
|
||||
imageFiles.add(outputFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Always create a ZIP file
|
||||
fileName = pdfBaseName + "ToMarkdown.zip";
|
||||
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
|
||||
// If there's only one markdown file, return it directly
|
||||
if (markdownFiles.size() == 1) {
|
||||
fileName = pdfBaseName + ".md";
|
||||
fileBytes = Files.readAllBytes(markdownFiles.get(0).toPath());
|
||||
} else {
|
||||
// Multiple files - create a zip
|
||||
fileName = pdfBaseName + "ToMarkdown.zip";
|
||||
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
|
||||
|
||||
try (ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream)) {
|
||||
// Add markdown files to root of ZIP
|
||||
for (File mdFile : markdownFiles) {
|
||||
ZipEntry mdEntry = new ZipEntry(mdFile.getName());
|
||||
zipOutputStream.putNextEntry(mdEntry);
|
||||
Files.copy(mdFile.toPath(), zipOutputStream);
|
||||
zipOutputStream.closeEntry();
|
||||
try (ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream)) {
|
||||
// Add markdown files
|
||||
for (File mdFile : markdownFiles) {
|
||||
ZipEntry mdEntry = new ZipEntry(mdFile.getName());
|
||||
zipOutputStream.putNextEntry(mdEntry);
|
||||
Files.copy(mdFile.toPath(), zipOutputStream);
|
||||
zipOutputStream.closeEntry();
|
||||
}
|
||||
|
||||
// Add images and other assets
|
||||
for (File file : outputFiles) {
|
||||
if (!file.getName().endsWith(".html") && !file.getName().endsWith(".md")) {
|
||||
ZipEntry assetEntry = new ZipEntry(file.getName());
|
||||
zipOutputStream.putNextEntry(assetEntry);
|
||||
Files.copy(file.toPath(), zipOutputStream);
|
||||
zipOutputStream.closeEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add images and other assets to images/ folder
|
||||
for (File imageFile : imageFiles) {
|
||||
ZipEntry assetEntry = new ZipEntry("images/" + imageFile.getName());
|
||||
zipOutputStream.putNextEntry(assetEntry);
|
||||
Files.copy(imageFile.toPath(), zipOutputStream);
|
||||
zipOutputStream.closeEntry();
|
||||
}
|
||||
fileBytes = byteArrayOutputStream.toByteArray();
|
||||
}
|
||||
|
||||
fileBytes = byteArrayOutputStream.toByteArray();
|
||||
}
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
fileBytes, fileName, MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates image references in markdown to point to the images/ folder. Matches patterns like
|
||||
*  and converts to 
|
||||
*/
|
||||
private String updateImageReferences(String markdown) {
|
||||
// Match markdown image syntax: 
|
||||
// Only update if the path doesn't already start with images/
|
||||
return PATTERN.matcher(markdown).replaceAll("$1(images/$2)");
|
||||
}
|
||||
|
||||
public ResponseEntity<byte[]> processPdfToHtml(MultipartFile inputFile)
|
||||
throws IOException, InterruptedException {
|
||||
if (!MediaType.APPLICATION_PDF_VALUE.equals(inputFile.getContentType())) {
|
||||
@@ -260,65 +241,31 @@ public class PDFToFile {
|
||||
byte[] fileBytes;
|
||||
String fileName;
|
||||
|
||||
Path libreOfficeProfile = null;
|
||||
try (TempFile inputFileTemp = new TempFile(tempFileManager, ".pdf");
|
||||
TempDirectory outputDirTemp = new TempDirectory(tempFileManager)) {
|
||||
|
||||
Path tempInputFile = inputFileTemp.getPath();
|
||||
Path tempOutputDir = outputDirTemp.getPath();
|
||||
Path unoOutputFile =
|
||||
tempOutputDir.resolve(
|
||||
pdfBaseName + "." + resolvePrimaryExtension(outputFormat));
|
||||
|
||||
// Save the uploaded file to a temporary location
|
||||
inputFile.transferTo(tempInputFile);
|
||||
|
||||
// Run the LibreOffice command
|
||||
ProcessExecutorResult returnCode = null;
|
||||
IOException unoconvertException = null;
|
||||
|
||||
if (isUnoConvertEnabled()) {
|
||||
try {
|
||||
List<String> unoCommand =
|
||||
buildUnoConvertCommand(
|
||||
tempInputFile, unoOutputFile, outputFormat, libreOfficeFilter);
|
||||
returnCode =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
|
||||
.runCommandWithOutputHandling(unoCommand);
|
||||
} catch (IOException e) {
|
||||
unoconvertException = e;
|
||||
log.warn(
|
||||
"Unoconvert command failed ({}). Falling back to soffice command.",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (returnCode == null) {
|
||||
// Run the LibreOffice command as a fallback
|
||||
libreOfficeProfile = Files.createTempDirectory("libreoffice_profile_");
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(runtimePathConfig.getSOfficePath());
|
||||
command.add("-env:UserInstallation=" + libreOfficeProfile.toUri().toString());
|
||||
command.add("--headless");
|
||||
command.add("--nologo");
|
||||
command.add("--infilter=" + libreOfficeFilter);
|
||||
command.add("--convert-to");
|
||||
command.add(outputFormat);
|
||||
command.add("--outdir");
|
||||
command.add(tempOutputDir.toString());
|
||||
command.add(tempInputFile.toString());
|
||||
|
||||
try {
|
||||
returnCode =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
|
||||
.runCommandWithOutputHandling(command);
|
||||
} catch (IOException e) {
|
||||
if (unoconvertException != null) {
|
||||
e.addSuppressed(unoconvertException);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
List<String> command =
|
||||
new ArrayList<>(
|
||||
Arrays.asList(
|
||||
"soffice",
|
||||
"--headless",
|
||||
"--nologo",
|
||||
"--infilter=" + libreOfficeFilter,
|
||||
"--convert-to",
|
||||
outputFormat,
|
||||
"--outdir",
|
||||
tempOutputDir.toString(),
|
||||
tempInputFile.toString()));
|
||||
ProcessExecutorResult returnCode =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
|
||||
.runCommandWithOutputHandling(command);
|
||||
|
||||
// Get output files
|
||||
List<File> outputFiles = Arrays.asList(tempOutputDir.toFile().listFiles());
|
||||
@@ -353,40 +300,8 @@ public class PDFToFile {
|
||||
|
||||
fileBytes = byteArrayOutputStream.toByteArray();
|
||||
}
|
||||
} finally {
|
||||
if (libreOfficeProfile != null) {
|
||||
FileUtils.deleteQuietly(libreOfficeProfile.toFile());
|
||||
}
|
||||
}
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
fileBytes, fileName, MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
|
||||
private boolean isUnoConvertEnabled() {
|
||||
return runtimePathConfig != null
|
||||
&& runtimePathConfig.getUnoConvertPath() != null
|
||||
&& !runtimePathConfig.getUnoConvertPath().isBlank();
|
||||
}
|
||||
|
||||
private List<String> buildUnoConvertCommand(
|
||||
Path inputFile, Path outputFile, String outputFormat, String libreOfficeFilter) {
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(runtimePathConfig.getUnoConvertPath());
|
||||
command.add("--convert-to");
|
||||
command.add(outputFormat);
|
||||
if (libreOfficeFilter != null && !libreOfficeFilter.isBlank()) {
|
||||
command.add("--input-filter=" + libreOfficeFilter);
|
||||
}
|
||||
command.add(inputFile.toString());
|
||||
command.add(outputFile.toString());
|
||||
return command;
|
||||
}
|
||||
|
||||
private String resolvePrimaryExtension(String outputFormat) {
|
||||
if (outputFormat == null) {
|
||||
return "";
|
||||
}
|
||||
int colonIndex = outputFormat.indexOf(':');
|
||||
return colonIndex > 0 ? outputFormat.substring(0, colonIndex) : outputFormat;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +318,7 @@ public class PdfAttachmentHandler {
|
||||
|
||||
private static String normalizeFilename(String filename) {
|
||||
if (filename == null) return "";
|
||||
String normalized = filename.toLowerCase(Locale.ROOT).trim();
|
||||
String normalized = filename.toLowerCase().trim();
|
||||
normalized =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getWhitespacePattern()
|
||||
@@ -560,7 +560,7 @@ public class PdfAttachmentHandler {
|
||||
@Override
|
||||
protected void writeString(String string, List<TextPosition> textPositions)
|
||||
throws IOException {
|
||||
String lowerString = string.toLowerCase(Locale.ROOT);
|
||||
String lowerString = string.toLowerCase();
|
||||
|
||||
if (ATTACHMENT_SECTION_PATTERN.matcher(lowerString).find()) {
|
||||
isInAttachmentSection = true;
|
||||
|
||||
@@ -9,7 +9,6 @@ import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
@@ -35,7 +34,7 @@ public class PdfToCbrUtils {
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
|
||||
if (document.getNumberOfPages() == 0) {
|
||||
throw ExceptionUtils.createPdfNoPages();
|
||||
throw new IllegalArgumentException("PDF file contains no pages");
|
||||
}
|
||||
|
||||
return createCbrFromPdf(document, dpi);
|
||||
@@ -44,23 +43,22 @@ public class PdfToCbrUtils {
|
||||
|
||||
private static void validatePdfFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw ExceptionUtils.createFileNullOrEmptyException();
|
||||
throw new IllegalArgumentException("File cannot be null or empty");
|
||||
}
|
||||
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
throw ExceptionUtils.createFileNoNameException();
|
||||
throw new IllegalArgumentException("File must have a name");
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
if (!"pdf".equals(extension)) {
|
||||
throw ExceptionUtils.createPdfFileRequiredException();
|
||||
throw new IllegalArgumentException("File must be a PDF");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] createCbrFromPdf(PDDocument document, int dpi) throws IOException {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
|
||||
|
||||
Path tempDir = Files.createTempDirectory("stirling-pdf-cbr-");
|
||||
List<Path> generatedImages = new ArrayList<>();
|
||||
@@ -68,36 +66,27 @@ public class PdfToCbrUtils {
|
||||
int totalPages = document.getNumberOfPages();
|
||||
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
final int currentPage = pageIndex;
|
||||
try {
|
||||
BufferedImage image =
|
||||
ExceptionUtils.handleOomRendering(
|
||||
currentPage + 1,
|
||||
dpi,
|
||||
() ->
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
currentPage, dpi, ImageType.RGB));
|
||||
pdfRenderer.renderImageWithDPI(pageIndex, dpi, ImageType.RGB);
|
||||
|
||||
String imageFilename =
|
||||
String.format(Locale.ROOT, "page_%03d.png", currentPage + 1);
|
||||
String imageFilename = String.format("page_%03d.png", pageIndex + 1);
|
||||
Path imagePath = tempDir.resolve(imageFilename);
|
||||
|
||||
ImageIO.write(image, "PNG", imagePath.toFile());
|
||||
generatedImages.add(imagePath);
|
||||
|
||||
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
|
||||
// Re-throw OOM exceptions without wrapping
|
||||
throw e;
|
||||
} catch (IOException e) {
|
||||
// Wrap other IOExceptions with context
|
||||
throw ExceptionUtils.createFileProcessingException(
|
||||
"CBR creation for page " + (currentPage + 1), e);
|
||||
log.warn("Error processing page {}: {}", pageIndex + 1, e.getMessage());
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (generatedImages.isEmpty()) {
|
||||
throw ExceptionUtils.createFileProcessingException(
|
||||
"CBR conversion", new IOException("No pages were successfully rendered"));
|
||||
throw new IOException("Failed to render any pages to images for CBR conversion");
|
||||
}
|
||||
|
||||
return createRarArchive(tempDir, generatedImages);
|
||||
@@ -126,18 +115,15 @@ public class PdfToCbrUtils {
|
||||
ProcessExecutorResult result =
|
||||
executor.runCommandWithOutputHandling(command, tempDir.toFile());
|
||||
if (result.getRc() != 0) {
|
||||
throw ExceptionUtils.createFileProcessingException(
|
||||
"RAR archive creation",
|
||||
new IOException("RAR command failed with code " + result.getRc()));
|
||||
throw new IOException("RAR command failed: " + result.getMessages());
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw ExceptionUtils.createProcessingInterruptedException("RAR creation", e);
|
||||
throw new IOException("RAR command interrupted", e);
|
||||
}
|
||||
|
||||
if (!Files.exists(rarFile)) {
|
||||
throw ExceptionUtils.createFileProcessingException(
|
||||
"RAR archive creation", new IOException("RAR file was not created"));
|
||||
throw new IOException("RAR file was not created");
|
||||
}
|
||||
|
||||
try (FileInputStream fis = new FileInputStream(rarFile.toFile());
|
||||
@@ -181,7 +167,7 @@ public class PdfToCbrUtils {
|
||||
return false;
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase(Locale.ROOT);
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
return "pdf".equals(extension);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user