Compare commits

..
Author SHA1 Message Date
Anthony Stirling d8f27b2041 Update tauri-build.yml 2025-11-24 17:35:45 +00:00
522 changed files with 303658 additions and 258037 deletions
-1
View File
@@ -5,7 +5,6 @@ frontend/dist
frontend/build
frontend/.vite
frontend/.tauri
frontend/src-tauri/target
# Gradle build artifacts
.gradle
@@ -1,6 +1,6 @@
"""
Author: Ludy87
Description: This script processes TOML translation files for localization checks. It compares translation files in a branch with
Description: This script processes JSON translation files for localization checks. It compares translation files in a branch with
a reference file to ensure consistency. The script performs two main checks:
1. Verifies that the number of translation keys in the translation files matches the reference file.
2. Ensures that all keys in the translation files are present in the reference file and vice versa.
@@ -9,10 +9,10 @@ The script also provides functionality to update the translation files to match
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>]
python check_language_json.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
"""
# Sample for Windows:
# python .github/scripts/check_language_toml.py --reference-file frontend/public/locales/en-GB/translation.toml --branch "" --files frontend/public/locales/de-DE/translation.toml frontend/public/locales/fr-FR/translation.toml
# python .github/scripts/check_language_json.py --reference-file frontend/public/locales/en-GB/translation.json --branch "" --files frontend/public/locales/de-DE/translation.json frontend/public/locales/fr-FR/translation.json
import copy
import glob
@@ -20,14 +20,12 @@ import os
import argparse
import re
import json
import tomllib # Python 3.11+ (stdlib)
import tomli_w # For writing TOML files
def find_duplicate_keys(file_path, keys=None, prefix=""):
"""
Identifies duplicate keys in a TOML file (including nested keys).
:param file_path: Path to the TOML file.
Identifies duplicate keys in a JSON file (including nested keys).
:param file_path: Path to the JSON file.
:param keys: Dictionary to track keys (used for recursion).
:param prefix: Prefix for nested keys.
:return: List of tuples (key, first_occurrence_path, duplicate_path).
@@ -37,9 +35,8 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
duplicates = []
# Load TOML file
with open(file_path, 'rb') as file:
data = tomllib.load(file)
with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
def process_dict(obj, current_prefix=""):
for key, value in obj.items():
@@ -57,18 +54,18 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
return duplicates
# Maximum size for TOML files (e.g., 500 KB)
# Maximum size for JSON files (e.g., 500 KB)
MAX_FILE_SIZE = 500 * 1024
def parse_toml_file(file_path):
def parse_json_file(file_path):
"""
Parses a TOML translation file and returns a flat dictionary of all keys.
:param file_path: Path to the TOML file.
Parses a JSON translation file and returns a flat dictionary of all keys.
:param file_path: Path to the JSON file.
:return: Dictionary with flattened keys.
"""
with open(file_path, 'rb') as file:
data = tomllib.load(file)
with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
def flatten_dict(d, parent_key="", sep="."):
items = {}
@@ -102,37 +99,38 @@ def unflatten_dict(d, sep="."):
return result
def write_toml_file(file_path, updated_properties):
def write_json_file(file_path, updated_properties):
"""
Writes updated properties back to the TOML file.
:param file_path: Path to the TOML file.
Writes updated properties back to the JSON file.
:param file_path: Path to the JSON file.
:param updated_properties: Dictionary of updated properties to write.
"""
nested_data = unflatten_dict(updated_properties)
with open(file_path, "wb") as file:
tomli_w.dump(nested_data, file)
with open(file_path, "w", encoding="utf-8", newline="\n") as file:
json.dump(nested_data, file, ensure_ascii=False, indent=2)
file.write("\n") # Add trailing newline
def update_missing_keys(reference_file, file_list, branch=""):
"""
Updates missing keys in the translation files based on the reference file.
:param reference_file: Path to the reference TOML file.
:param reference_file: Path to the reference JSON file.
:param file_list: List of translation files to update.
:param branch: Branch where the files are located.
"""
reference_properties = parse_toml_file(reference_file)
reference_properties = parse_json_file(reference_file)
for file_path in file_list:
basename_current_file = os.path.basename(os.path.join(branch, file_path))
if (
basename_current_file == os.path.basename(reference_file)
or not file_path.endswith(".toml")
or not file_path.endswith(".json")
or not os.path.dirname(file_path).endswith("locales")
):
continue
current_properties = parse_toml_file(os.path.join(branch, file_path))
current_properties = parse_json_file(os.path.join(branch, file_path))
updated_properties = {}
for ref_key, ref_value in reference_properties.items():
@@ -143,16 +141,16 @@ def update_missing_keys(reference_file, file_list, branch=""):
# Add missing key with reference value
updated_properties[ref_key] = ref_value
write_toml_file(os.path.join(branch, file_path), updated_properties)
write_json_file(os.path.join(branch, file_path), updated_properties)
def check_for_missing_keys(reference_file, file_list, branch):
update_missing_keys(reference_file, file_list, branch)
def read_toml_keys(file_path):
def read_json_keys(file_path):
if os.path.isfile(file_path) and os.path.exists(file_path):
return parse_toml_file(file_path)
return parse_json_file(file_path)
return {}
@@ -162,7 +160,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
report = []
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
reference_keys = read_toml_keys(reference_file)
reference_keys = read_json_keys(reference_file)
has_differences = False
only_reference_file = True
@@ -199,12 +197,12 @@ def check_for_differences(reference_file, file_list, branch, actor):
):
continue
if not file_normpath.endswith(".toml") or basename_current_file != "translation.toml":
if not file_normpath.endswith(".json") or basename_current_file != "translation.json":
continue
only_reference_file = False
report.append(f"#### 📃 **File Check:** `{locale_dir}/{basename_current_file}`")
current_keys = read_toml_keys(os.path.join(branch, file_path))
current_keys = read_json_keys(os.path.join(branch, file_path))
reference_key_count = len(reference_keys)
current_key_count = len(current_keys)
@@ -274,7 +272,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
report.append("## ❌ Overall Check Status: **_Failed_**")
report.append("")
report.append(
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/public/locales/en-GB/translation.toml)"
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.json](https://github.com/Stirling-Tools/Stirling-PDF/blob/V2/frontend/public/locales/en-GB/translation.json)"
)
else:
report.append("## ✅ Overall Check Status: **_Success_**")
@@ -288,7 +286,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")
parser.add_argument(
"--actor",
required=False,
@@ -339,9 +337,9 @@ if __name__ == "__main__":
"public",
"locales",
"*",
"translation.toml",
"translation.json",
)
)
update_missing_keys(args.reference_file, file_list)
else:
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
@@ -0,0 +1,403 @@
"""
Author: Ludy87
Description: This script processes .properties files for localization checks. It compares translation files in a branch with
a reference file to ensure consistency. The script performs two main checks:
1. Verifies that the number of lines (including comments and empty lines) in the translation files matches the reference file.
2. Ensures that all keys in the translation files are present in the reference file and vice versa.
The script also provides functionality to update the translation files to match the reference file by adding missing keys and
adjusting the format.
Usage:
python check_language_properties.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
"""
# Sample for Windows:
# python .github/scripts/check_language_properties.py --reference-file src\main\resources\messages_en_GB.properties --branch "" --files src\main\resources\messages_de_DE.properties src\main\resources\messages_uk_UA.properties
import copy
import glob
import os
import argparse
import re
def find_duplicate_keys(file_path):
"""
Identifies duplicate keys in a .properties file.
:param file_path: Path to the .properties file.
:return: List of tuples (key, first_occurrence_line, duplicate_line).
"""
keys = {}
duplicates = []
with open(file_path, "r", encoding="utf-8") as file:
for line_number, line in enumerate(file, start=1):
stripped_line = line.strip()
# Skip empty lines and comments
if not stripped_line or stripped_line.startswith("#"):
continue
# Split the line into key and value
if "=" in stripped_line:
key, _ = stripped_line.split("=", 1)
key = key.strip()
# Check if the key already exists
if key in keys:
duplicates.append((key, keys[key], line_number))
else:
keys[key] = line_number
return duplicates
# Maximum size for properties files (e.g., 200 KB)
MAX_FILE_SIZE = 200 * 1024
def parse_properties_file(file_path):
"""
Parses a .properties file and returns a structured list of its contents.
:param file_path: Path to the .properties file.
:return: List of dictionaries representing each line in the file.
"""
properties_list = []
with open(file_path, "r", encoding="utf-8") as file:
for line_number, line in enumerate(file, start=1):
stripped_line = line.strip()
# Handle empty lines
if not stripped_line:
properties_list.append(
{"line_number": line_number, "type": "empty", "content": ""}
)
continue
# Handle comments
if stripped_line.startswith("#"):
properties_list.append(
{
"line_number": line_number,
"type": "comment",
"content": stripped_line,
}
)
continue
# Handle key-value pairs
match = re.match(r"^([^=]+)=(.*)$", line)
if match:
key, value = match.groups()
properties_list.append(
{
"line_number": line_number,
"type": "entry",
"key": key.strip(),
"value": value.strip(),
}
)
return properties_list
def write_json_file(file_path, updated_properties):
"""
Writes updated properties back to the file in their original format.
:param file_path: Path to the .properties file.
:param updated_properties: List of updated properties to write.
"""
updated_lines = {entry["line_number"]: entry for entry in updated_properties}
# Sort lines by their numbers and retain comments and empty lines
all_lines = sorted(set(updated_lines.keys()))
original_format = []
for line in all_lines:
if line in updated_lines:
entry = updated_lines[line]
else:
entry = None
ref_entry = updated_lines[line]
if ref_entry["type"] in ["comment", "empty"]:
original_format.append(ref_entry)
elif entry is None:
# Add missing entries from the reference file
original_format.append(ref_entry)
elif entry["type"] == "entry":
# Replace entries with those from the current JSON
original_format.append(entry)
# Write the updated content back to the file
with open(file_path, "w", encoding="utf-8", newline="\n") as file:
for entry in original_format:
if entry["type"] == "comment":
file.write(f"{entry['content']}\n")
elif entry["type"] == "empty":
file.write(f"{entry['content']}\n")
elif entry["type"] == "entry":
file.write(f"{entry['key']}={entry['value']}\n")
def update_missing_keys(reference_file, file_list, branch=""):
"""
Updates missing keys in the translation files based on the reference file.
:param reference_file: Path to the reference .properties file.
:param file_list: List of translation files to update.
:param branch: Branch where the files are located.
"""
reference_properties = parse_properties_file(reference_file)
for file_path in file_list:
basename_current_file = os.path.basename(os.path.join(branch, file_path))
if (
basename_current_file == os.path.basename(reference_file)
or not file_path.endswith(".properties")
or not basename_current_file.startswith("messages_")
):
continue
current_properties = parse_properties_file(os.path.join(branch, file_path))
updated_properties = []
for ref_entry in reference_properties:
ref_entry_copy = copy.deepcopy(ref_entry)
for current_entry in current_properties:
if current_entry["type"] == "entry":
if ref_entry_copy["type"] != "entry":
continue
if ref_entry_copy["key"].lower() == current_entry["key"].lower():
ref_entry_copy["value"] = current_entry["value"]
updated_properties.append(ref_entry_copy)
write_json_file(os.path.join(branch, file_path), updated_properties)
def check_for_missing_keys(reference_file, file_list, branch):
update_missing_keys(reference_file, file_list, branch)
def read_properties(file_path):
if os.path.isfile(file_path) and os.path.exists(file_path):
with open(file_path, "r", encoding="utf-8") as file:
return file.read().splitlines()
return [""]
def check_for_differences(reference_file, file_list, branch, actor):
reference_branch = reference_file.split("/")[0]
basename_reference_file = os.path.basename(reference_file)
report = []
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
reference_lines = read_properties(reference_file)
has_differences = False
only_reference_file = True
file_arr = file_list
if len(file_list) == 1:
file_arr = file_list[0].split()
base_dir = os.path.abspath(
os.path.join(os.getcwd(), "app", "core", "src", "main", "resources")
)
for file_path in file_arr:
file_normpath = os.path.normpath(file_path)
absolute_path = os.path.abspath(file_normpath)
# Verify that file is within the expected directory
if not absolute_path.startswith(base_dir):
raise ValueError(f"Unsafe file found: {file_normpath}")
# Verify file size before processing
if os.path.getsize(os.path.join(branch, file_normpath)) > MAX_FILE_SIZE:
raise ValueError(
f"The file {file_normpath} is too large and could pose a security risk."
)
basename_current_file = os.path.basename(os.path.join(branch, file_normpath))
if (
basename_current_file == basename_reference_file
or (
# only local windows command
not file_normpath.startswith(
os.path.join(
"", "app", "core", "src", "main", "resources", "messages_"
)
)
and not file_normpath.startswith(
os.path.join(
os.getcwd(),
"app",
"core",
"src",
"main",
"resources",
"messages_",
)
)
)
or not file_normpath.endswith(".properties")
or not basename_current_file.startswith("messages_")
):
continue
only_reference_file = False
report.append(f"#### 📃 **File Check:** `{basename_current_file}`")
current_lines = read_properties(os.path.join(branch, file_path))
reference_line_count = len(reference_lines)
current_line_count = len(current_lines)
if reference_line_count != current_line_count:
report.append("")
report.append("1. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
has_differences = True
if reference_line_count > current_line_count:
report.append(
f" - **_Mismatched line count_**: {reference_line_count} (reference) vs {current_line_count} (current). Comments, empty lines, or translation strings are missing."
)
elif reference_line_count < current_line_count:
report.append(
f" - **_Too many lines_**: {reference_line_count} (reference) vs {current_line_count} (current). Please verify if there is an additional line that needs to be removed."
)
else:
report.append("1. **Test Status:** ✅ **_Passed_**")
# Check for missing or extra keys
current_keys = []
reference_keys = []
for line in current_lines:
if not line.startswith("#") and line != "" and "=" in line:
key, _ = line.split("=", 1)
current_keys.append(key)
for line in reference_lines:
if not line.startswith("#") and line != "" and "=" in line:
key, _ = line.split("=", 1)
reference_keys.append(key)
current_keys_set = set(current_keys)
reference_keys_set = set(reference_keys)
missing_keys = current_keys_set.difference(reference_keys_set)
extra_keys = reference_keys_set.difference(current_keys_set)
missing_keys_list = list(missing_keys)
extra_keys_list = list(extra_keys)
if missing_keys_list or extra_keys_list:
has_differences = True
missing_keys_str = "`, `".join(missing_keys_list)
extra_keys_str = "`, `".join(extra_keys_list)
report.append("2. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
if missing_keys_list:
spaces_keys_list = []
for key in missing_keys_list:
if " " in key:
spaces_keys_list.append(key)
if spaces_keys_list:
spaces_keys_str = "`, `".join(spaces_keys_list)
report.append(
f" - **_Keys containing unnecessary spaces_**: `{spaces_keys_str}`!"
)
report.append(
f" - **_Extra keys in `{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**."
)
if extra_keys_list:
report.append(
f" - **_Missing keys in `{basename_reference_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_current_file}`_**."
)
else:
report.append("2. **Test Status:** ✅ **_Passed_**")
if find_duplicate_keys(os.path.join(branch, file_normpath)):
has_differences = True
output = "\n".join(
[
f" - `{key}`: first at line {first}, duplicate at `line {duplicate}`"
for key, first, duplicate in find_duplicate_keys(
os.path.join(branch, file_normpath)
)
]
)
report.append("3. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
report.append(" - duplicate entries were found:")
report.append(output)
else:
report.append("3. **Test Status:** ✅ **_Passed_**")
report.append("")
report.append("---")
report.append("")
if has_differences:
report.append("## ❌ Overall Check Status: **_Failed_**")
report.append("")
report.append(
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/messages_en_GB.properties)"
)
else:
report.append("## ✅ Overall Check Status: **_Success_**")
report.append("")
report.append(
f"Thanks @{actor} for your help in keeping the translations up to date."
)
if not only_reference_file:
print("\n".join(report))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Find missing keys")
parser.add_argument(
"--actor",
required=False,
help="Actor from PR.",
)
parser.add_argument(
"--reference-file",
required=True,
help="Path to the reference file.",
)
parser.add_argument(
"--branch",
type=str,
required=True,
help="Branch name.",
)
parser.add_argument(
"--check-file",
type=str,
required=False,
help="List of changed files, separated by spaces.",
)
parser.add_argument(
"--files",
nargs="+",
required=False,
help="List of changed files, separated by spaces.",
)
args = parser.parse_args()
# Sanitize --actor input to avoid injection attacks
if args.actor:
args.actor = re.sub(r"[^a-zA-Z0-9_\\-]", "", args.actor)
# Sanitize --branch input to avoid injection attacks
if args.branch:
args.branch = re.sub(r"[^a-zA-Z0-9\\-]", "", args.branch)
file_list = args.files
if file_list is None:
if args.check_file:
file_list = [args.check_file]
else:
file_list = glob.glob(
os.path.join(
os.getcwd(),
"app",
"core",
"src",
"main",
"resources",
"messages_*.properties",
)
)
update_missing_keys(args.reference_file, file_list)
else:
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
@@ -180,7 +180,7 @@ jobs:
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./docker/embedded/Dockerfile
file: ./Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }}
build-args: VERSION_TAG=alpha
+2 -2
View File
@@ -262,7 +262,7 @@ jobs:
strategy:
fail-fast: false
matrix:
docker-rev: ["docker/embedded/Dockerfile", "docker/embedded/Dockerfile.ultra-lite", "docker/embedded/Dockerfile.fat"]
docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "Dockerfile.fat"]
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
@@ -301,7 +301,7 @@ jobs:
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./${{ matrix.docker-rev }}
file: ./docker/backend/${{ matrix.docker-rev }}
push: false
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -1,14 +1,19 @@
name: Check TOML Translation Files on PR
# This workflow validates TOML translation files
name: Check Properties Files on PR
on:
pull_request_target:
types: [opened, synchronize, reopened]
paths:
- "frontend/public/locales/*/translation.toml"
- "app/core/src/main/resources/messages_*.properties"
# 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
@@ -68,22 +73,22 @@ jobs:
run: |
echo "Fetching PR changed files..."
echo "Getting list of changed files from PR..."
# Check if PR number exists
if [ -z "${{ steps.get-pr-data.outputs.pr_number }}" ]; then
echo "Error: PR number is empty"
exit 1
fi
# Get changed files and filter for TOML translation files
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^frontend/public/locales/[a-zA-Z-]+/translation\.toml$' > changed_files.txt || echo "No matching TOML files found in PR"
# Check if any files were found
if [ ! -s changed_files.txt ]; then
echo "No TOML translation files changed in this PR"
echo "Workflow will exit early as no relevant files to check"
exit 0
fi
echo "Found $(wc -l < changed_files.txt) matching TOML files"
# Check if PR number exists
if [ -z "${{ steps.get-pr-data.outputs.pr_number }}" ]; then
echo "Error: PR number is empty"
exit 1
fi
# Get changed files and filter for properties files, handle case where no matches are found
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^app/core/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$' > changed_files.txt || echo "No matching properties files found in PR"
# Check if any files were found
if [ ! -s changed_files.txt ]; then
echo "No properties files changed in this PR"
echo "Workflow will exit early as no relevant files to check"
exit 0
fi
echo "Found $(wc -l < changed_files.txt) matching properties files"
- name: Determine reference file
- name: Determine reference file test
id: determine-file
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
@@ -120,11 +125,11 @@ jobs:
pull_number: prNumber,
});
// Filter for relevant TOML files based on the PR changes
// Filter for relevant files based on the PR changes
const changedFiles = files
.filter(file =>
file.status !== "removed" &&
/^frontend\/public\/locales\/[a-zA-Z-]+\/translation\.toml$/.test(file.filename)
/^app\/core\/src\/main\/resources\/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$/.test(file.filename)
)
.map(file => file.filename);
@@ -164,16 +169,16 @@ jobs:
// Determine reference file
let referenceFilePath;
if (changedFiles.includes("frontend/public/locales/en-GB/translation.toml")) {
if (changedFiles.includes("app/core/src/main/resources/messages_en_GB.properties")) {
console.log("Using PR branch reference file.");
const { data: fileContent } = await github.rest.repos.getContent({
owner: prRepoOwner,
repo: prRepoName,
path: "frontend/public/locales/en-GB/translation.toml",
path: "app/core/src/main/resources/messages_en_GB.properties",
ref: branch,
});
referenceFilePath = "pr-branch-translation-en-GB.toml";
referenceFilePath = "pr-branch-messages_en_GB.properties";
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
fs.writeFileSync(referenceFilePath, content);
} else {
@@ -181,11 +186,11 @@ jobs:
const { data: fileContent } = await github.rest.repos.getContent({
owner: repoOwner,
repo: repoName,
path: "frontend/public/locales/en-GB/translation.toml",
path: "app/core/src/main/resources/messages_en_GB.properties",
ref: "main",
});
referenceFilePath = "main-branch-translation-en-GB.toml";
referenceFilePath = "main-branch-messages_en_GB.properties";
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
fs.writeFileSync(referenceFilePath, content);
}
@@ -193,20 +198,11 @@ jobs:
console.log(`Reference file path: ${referenceFilePath}`);
core.exportVariable("REFERENCE_FILE", referenceFilePath);
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: "3.12"
- name: Install Python dependencies
run: |
pip install tomli-w
- name: Run Python script to check files
id: run-check
run: |
echo "Running Python script to check TOML files..."
python .github/scripts/check_language_toml.py \
echo "Running Python script to check files..."
python .github/scripts/check_language_properties.py \
--actor ${{ github.event.pull_request.user.login }} \
--reference-file "${REFERENCE_FILE}" \
--branch "pr-branch" \
@@ -217,7 +213,7 @@ jobs:
id: capture-output
run: |
if [ -f result.txt ] && [ -s result.txt ]; then
echo "Capturing output..."
echo "Test, capturing output..."
SCRIPT_OUTPUT=$(cat result.txt)
echo "SCRIPT_OUTPUT<<EOF" >> $GITHUB_ENV
echo "$SCRIPT_OUTPUT" >> $GITHUB_ENV
@@ -231,7 +227,7 @@ jobs:
echo "FAIL_JOB=false" >> $GITHUB_ENV
fi
else
echo "No output found."
echo "No update found."
echo "SCRIPT_OUTPUT=" >> $GITHUB_ENV
echo "FAIL_JOB=false" >> $GITHUB_ENV
fi
@@ -253,7 +249,7 @@ jobs:
issue_number: issueNumber
});
const comment = comments.data.find(c => c.body.includes("## 🌐 TOML Translation Verification Summary"));
const comment = comments.data.find(c => c.body.includes("## 🚀 Translation Verification Summary"));
// Only update or create comments by the action user
const expectedActor = "${{ steps.setup-bot.outputs.app-slug }}[bot]";
@@ -264,7 +260,7 @@ jobs:
owner: repoOwner,
repo: repoName,
comment_id: comment.id,
body: `## 🌐 TOML Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
body: `## 🚀 Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
});
console.log("Updated existing comment.");
} else if (!comment) {
@@ -273,7 +269,7 @@ jobs:
owner: repoOwner,
repo: repoName,
issue_number: issueNumber,
body: `## 🌐 TOML Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
body: `## 🚀 Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
});
console.log("Created new comment.");
} else {
@@ -291,6 +287,6 @@ jobs:
run: |
echo "Cleaning up temporary files..."
rm -rf pr-branch
rm -f pr-branch-translation-en-GB.toml main-branch-translation-en-GB.toml changed_files.txt result.txt
rm -f pr-branch-messages_en_GB.properties main-branch-messages_en_GB.properties changed_files.txt result.txt
echo "Cleanup complete."
continue-on-error: true # Ensure cleanup runs even if previous steps fail
+238 -495
View File
@@ -1,4 +1,4 @@
name: Multi-OS Tauri Releases
name: Test Installers Build
on:
workflow_dispatch:
@@ -6,23 +6,7 @@ on:
test_mode:
description: "Run in test mode (skip release step)"
required: false
default: "true"
type: choice
options:
- "true"
- "false"
platform:
description: "Platform to build (windows, macos, linux, or all)"
required: true
default: "all"
type: choice
options:
- all
- windows
- macos
- linux
push:
branches: [main, V2, V2-demo, V2-master]
default: "false"
release:
types: [created]
@@ -30,89 +14,60 @@ permissions:
contents: read
jobs:
determine-matrix:
read_versions:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
version: ${{ steps.versionNumber.outputs.versionNumber }}
versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
- name: Set up JDK
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
distribution: 'temurin'
java-version: '21'
# ✅ Get version from Gradle
- name: Get version number
id: versionNumber
run: |
echo "Running gradlew printVersion..."
./gradlew printVersion --quiet
VERSION=$(./gradlew printVersion --quiet | tail -1)
echo "Extracted version: $VERSION"
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Determine build matrix
id: set-matrix
# ✅ Get Mac-specific version from Gradle
- name: Get version number mac
id: versionNumberMac
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/release events, build all platforms
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
fi
VERSION_MAC=$(./gradlew printMacVersion --quiet | tail -1)
echo "versionNumberMac=$VERSION_MAC" >> $GITHUB_OUTPUT
build-jars:
needs: determine-matrix
build-portable:
needs: read_versions
runs-on: ubuntu-latest
strategy:
matrix:
variant:
- name: "default"
disable_security: true
build_frontend: true
file_suffix: ""
- name: "with-login"
disable_security: false
build_frontend: true
disable_security: [true, false]
include:
- disable_security: false
file_suffix: "-with-login"
- name: "server-only"
disable_security: true
build_frontend: false
file_suffix: "-server"
- disable_security: true
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
distribution: "temurin"
@@ -121,409 +76,226 @@ jobs:
with:
gradle-version: 8.14
- name: Setup Node.js
if: matrix.variant.build_frontend == true
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Build JAR
run: ./gradlew clean build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
- name: Generate jar (Disable Security=${{ matrix.disable_security }})
run: ./gradlew clean createExe
env:
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.variant.disable_security }}
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.disable_security }}
STIRLING_PDF_DESKTOP_UI: false
- name: Rename JAR
- name: Rename binaries
run: |
echo "Version from determine-matrix: ${{ needs.determine-matrix.outputs.version }}"
echo "Looking for: app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar"
ls -la app/core/build/libs/
mkdir -p ./jar-dist
cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar
- name: Upload JAR artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: jar${{ matrix.variant.file_suffix }}
path: ./jar-dist/*.jar
retention-days: 1
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.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
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.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
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.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
shell: powershell
run: |
if ($env:WINDOWS_CERTIFICATE) {
Write-Host "Importing Windows Code Signing Certificate..."
# Decode base64 certificate and save to file
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
[IO.File]::WriteAllBytes($certPath, $certBytes)
# Import certificate to CurrentUser\My store
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
# Extract and set thumbprint as environment variable
$thumbprint = $cert.Thumbprint
Write-Host "Certificate imported with thumbprint: $thumbprint"
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
# Clean up certificate file
Remove-Item $certPath
Write-Host "Windows certificate import completed."
} else {
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
}
- name: Import Apple Developer Certificate
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master')
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
echo "Importing Apple Developer Certificate..."
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
# Create temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Import certificate
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Clean up
rm certificate.p12
- name: Verify Certificate
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master')
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application")
echo "Certificate Info: $CERT_INFO"
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
echo "Certificate ID: $CERT_ID"
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported successfully."
- name: Build Tauri app
uses: tauri-apps/tauri-action@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 release or V2-master
SIGN: ${{ (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') && (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
CI: true
with:
projectPath: ./frontend
tauriScript: npx tauri
args: ${{ matrix.args }}
# Sign with DigiCert KeyLocker (post-build)
- name: Sign Windows binaries with DigiCert KeyLocker
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
shell: pwsh
run: |
Write-Host "=== DigiCert KeyLocker Signing ==="
# Test smctl connectivity first
Write-Host "Testing smctl connection..."
$healthCheck = & smctl healthcheck 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "[SUCCESS] Connected to DigiCert KeyLocker"
} else {
Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker"
Write-Host $healthCheck
exit 1
}
Write-Host ""
# Sync certificates to Windows certificate store
Write-Host "Syncing certificates to Windows certificate store..."
$syncOutput = & smctl windows certsync 2>&1
Write-Host "Cert sync result: $syncOutput"
Write-Host ""
# Find only the files we need to sign
$filesToSign = @()
# Main application executable
$mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue
if ($mainExe) { $filesToSign += $mainExe }
# MSI installer
$msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
$filesToSign += $msiFiles
if ($filesToSign.Count -eq 0) {
Write-Host "[ERROR] No files found to sign"
exit 1
}
Write-Host "Found $($filesToSign.Count) files to sign:"
foreach ($f in $filesToSign) { Write-Host " - $($f.Name)" }
Write-Host ""
$signedCount = 0
foreach ($file in $filesToSign) {
Write-Host "Signing: $($file.Name)"
# Get PKCS11 config file path
$pkcs11Config = $env:PKCS11_CONFIG
if (-not $pkcs11Config) {
Write-Host "[ERROR] PKCS11_CONFIG environment variable not set"
exit 1
}
Write-Host "Using PKCS11 config: $pkcs11Config"
# Try signing with certificate fingerprint first (if available)
$fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}"
if ($fingerprint -and $fingerprint -ne "") {
Write-Host "Attempting to sign with certificate fingerprint..."
$output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
$exitCode = $LASTEXITCODE
} else {
Write-Host "No fingerprint provided, using keypair alias..."
$output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
$exitCode = $LASTEXITCODE
}
Write-Host "Exit code: $exitCode"
Write-Host "Output: $output"
if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") {
Write-Host "[ERROR] Signing failed for $($file.Name)"
exit 1
}
if ($exitCode -ne 0) {
Write-Host "[ERROR] Failed to sign $($file.Name)"
Write-Host "Full error output:"
Write-Host $output
exit 1
}
$signedCount++
Write-Host "[SUCCESS] Signed: $($file.Name)"
Write-Host ""
}
Write-Host "=== Summary ==="
Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully"
- name: Rename artifacts
shell: bash
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
mkdir ./binaries
mv ./build/launch4j/Stirling-PDF.exe ./binaries/win-Stirling-PDF-portable-Server${{ matrix.file_suffix }}.exe
mv ./app/core/build/libs/stirling-pdf-${{ needs.read_versions.outputs.version }}.jar ./binaries/Stirling-PDF${{ matrix.file_suffix }}.jar
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: Stirling-PDF-${{ matrix.name }}
path: ./dist/*
retention-days: 1
if-no-files-found: error
name: stirling${{ matrix.file_suffix }}-binaries
path: |
./binaries/*
sign_verify-portable:
needs: [build-portable, read_versions]
runs-on: ubuntu-latest
strategy:
matrix:
disable_security: [true, false]
include:
- disable_security: false
file_suffix: "with-login-"
- disable_security: true
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Download build artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: stirling-${{ matrix.file_suffix }}binaries
- name: Display structure of downloaded files
run: ls -R
- name: Upload signed artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
retention-days: 1
if-no-files-found: error
name: stirling-${{ matrix.file_suffix }}signed
path: |
./*
!cosign.*
build-installers:
needs: read_versions
strategy:
matrix:
include:
- os: windows-latest
platform: win-
- os: macos-latest
platform: mac-
# - os: ubuntu-latest
# platform: linux-
runs-on: ${{ matrix.os }}
permissions:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21
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
# Install Windows dependencies
- name: Install WiX Toolset
if: matrix.os == 'windows-latest'
run: |
curl -L -o wix.exe https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314.exe
.\wix.exe /install /quiet
# Build installer
- name: Build Installer
run: ./gradlew build jpackage -x test --info
env:
DISABLE_ADDITIONAL_FEATURES: true
STIRLING_PDF_DESKTOP_UI: true
BROWSER_OPEN: true
- name: Set up JDK (x86_64)
if: matrix.os == 'macos-latest'
run: |
curl -L -o jdk.tar.gz https://cdn.azul.com/zulu/bin/zulu17.56.15-ca-jdk17.0.14-macosx_x64.tar.gz
mkdir -p zulu17
tar -xzf jdk.tar.gz -C zulu17 --strip-components=1
echo "JAVA_HOME=$PWD/zulu17" >> $GITHUB_ENV
echo "$PWD/zulu17/bin" >> $GITHUB_PATH
- name: Verify JDK architecture
if: matrix.os == 'macos-latest'
run: file $JAVA_HOME/bin/java
- name: Build project and run jpackage (x86_64)
if: matrix.os == 'macos-latest'
run: arch -x86_64 ./gradlew jpackageMacX64
# Rename and collect artifacts based on OS
- name: Prepare artifacts
id: prepare
shell: bash
run: |
ls -lah ./build/jpackage/
mkdir ./binaries
if [ "${{ matrix.os }}" = "windows-latest" ]; then
mv "./build/jpackage/Stirling PDF-${{ needs.read_versions.outputs.version }}.exe" "./binaries/Stirling-PDF-win-installer.exe"
elif [ "${{ matrix.os }}" = "macos-latest" ]; then
mv "./build/jpackage/Stirling PDF-${{ needs.read_versions.outputs.versionMac }}.dmg" "./binaries/Stirling-PDF-mac-installer.dmg"
mv "./build/jpackage/x86_64/Stirling PDF (x86_64)-${{ needs.read_versions.outputs.versionMac }}.dmg" "./binaries/Stirling-PDF-mac-x86_64-installer.dmg"
else
mv "./build/jpackage/stirling-pdf_${{ needs.read_versions.outputs.version }}-1_amd64.deb" "./binaries/Stirling-PDF-linux-installer.deb"
fi
- name: Display structure of downloaded files
run: ls -R ./binaries
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
retention-days: 1
if-no-files-found: error
name: ${{ matrix.platform }}binaries
path: |
./binaries/*
sign_verify:
needs: [read_versions, build-installers]
strategy:
matrix:
include:
- os: windows-latest
platform: win-
- os: macos-latest
platform: mac-
# - os: ubuntu-latest
# platform: linux-
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Download build artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: ${{ matrix.platform }}binaries
- name: Display structure of downloaded files
run: ls -R
- name: Install Cosign
if: matrix.os == 'windows-latest'
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
- name: Generate key pair
if: matrix.os == 'windows-latest'
run: cosign generate-key-pair
- name: Sign and generate attestations
if: matrix.os == 'windows-latest'
run: |
cosign sign-blob \
--key ./cosign.key \
--yes \
--output-signature ./Stirling-PDF-win-installer.exe.sig \
./Stirling-PDF-win-installer.exe
cosign attest-blob \
--predicate - \
--key ./cosign.key \
--yes \
--output-attestation ./Stirling-PDF-win-installer.exe.intoto.jsonl \
./Stirling-PDF-win-installer.exe
cosign verify-blob \
--key ./cosign.pub \
--signature ./Stirling-PDF-win-installer.exe.sig \
./Stirling-PDF-win-installer.exe
- name: Display structure of downloaded files
run: ls -R
- name: Upload signed artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
retention-days: 1
if-no-files-found: error
name: ${{ matrix.platform }}signed
path: |
./Stirling-PDF-${{ matrix.platform }}installer.*
./Stirling-PDF-${{ matrix.platform }}x86_64-installer.*
!cosign.*
create-release:
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master'
needs: [determine-matrix, build, build-jars]
if: github.event_name != 'workflow_dispatch' || github.event.inputs.test_mode != 'true'
needs: [read_versions, sign_verify, sign_verify-portable]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -533,43 +305,14 @@ jobs:
with:
egress-policy: audit
- name: Download all Tauri artifacts
- name: Download signed artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
pattern: Stirling-PDF-*
path: ./artifacts/tauri
- name: Download JAR artifact (default)
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: jar
path: ./artifacts/jars
- name: Download JAR artifact (with login)
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@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: jar-server
path: ./artifacts/jars
- name: Display structure of downloaded files
run: ls -R ./artifacts
- name: Upload binaries to Release
run: ls -R
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
with:
tag_name: v${{ needs.determine-matrix.outputs.version }}
tag_name: v${{ needs.read_versions.outputs.version }}
generate_release_notes: true
files: |
./artifacts/**/*.jar
./artifacts/**/*.msi
./artifacts/**/*.dmg
./artifacts/**/*.deb
./artifacts/**/*.AppImage
draft: false
prerelease: false
./*signed/*
-245
View File
@@ -1,245 +0,0 @@
name: Push Docker Image - V2 Branch
on:
workflow_dispatch:
push:
branches:
- V2-master
- alljavadocker
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
push:
runs-on: ubuntu-24.04-8core
permissions:
packages: write
id-token: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Get version number
id: versionNumber
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
- name: Install cosign
if: github.ref == 'refs/heads/V2-master'
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
with:
cosign-release: "v2.4.1"
- name: Login to Docker Hub
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Convert repository owner to lowercase
id: repoowner
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
- name: Generate tags for latest (V2-master branch - production)
id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-master'
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
type=raw,value=latest
- name: Generate tags for latest (alljavadocker branch - test)
id: meta-test
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/alljavadocker'
with:
images: |
ghcr.io/stirling-tools/stirling-pdf-test
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
type=raw,value=latest
- name: Build and push Unified Dockerfile (latest variant)
id: build-push-latest
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/embedded/Dockerfile
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta.outputs.tags || steps.meta-test.outputs.tags }}
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta.outputs.labels || steps.meta-test.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Sign regular images
if: github.ref == 'refs/heads/V2-master'
env:
DIGEST: ${{ steps.build-push-latest.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
cosign sign --yes \
--key env://COSIGN_PRIVATE_KEY \
"${tag}@${DIGEST}"
done
- name: Generate tags for latest-fat (V2-master branch - production)
id: meta-fat
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-master'
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
type=raw,value=latest-fat
- name: Generate tags for latest-fat (alljavadocker branch - test)
id: meta-fat-test
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/alljavadocker'
with:
images: |
ghcr.io/stirling-tools/stirling-pdf-test
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
type=raw,value=latest-fat
- name: Build and push Unified Dockerfile (fat variant)
id: build-push-fat
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/embedded/Dockerfile.fat
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-fat.outputs.tags || steps.meta-fat-test.outputs.tags }}
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-fat.outputs.labels || steps.meta-fat-test.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Sign fat images
if: github.ref == 'refs/heads/V2-master'
env:
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
TAGS: ${{ steps.meta-fat.outputs.tags }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
done
- name: Generate tags for ultra-lite (V2-master branch - production)
id: meta-lite
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-master'
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
type=raw,value=latest-ultra-lite
- name: Generate tags for ultra-lite (alljavadocker branch - test)
id: meta-lite-test
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/alljavadocker'
with:
images: |
ghcr.io/stirling-tools/stirling-pdf-test
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
type=raw,value=latest-ultra-lite
- name: Build and push Unified Dockerfile (ultra-lite variant)
id: build-push-lite
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/embedded/Dockerfile.ultra-lite
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-lite.outputs.tags || steps.meta-lite-test.outputs.tags }}
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-lite.outputs.labels || steps.meta-lite-test.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Sign ultra-lite images
if: github.ref == 'refs/heads/V2-master'
env:
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
TAGS: ${{ steps.meta-lite.outputs.tags }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
done
+3 -3
View File
@@ -107,7 +107,7 @@ jobs:
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/embedded/Dockerfile
file: ./Dockerfile
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -152,7 +152,7 @@ jobs:
if: github.ref != 'refs/heads/main'
with:
context: .
file: ./docker/embedded/Dockerfile.ultra-lite
file: ./Dockerfile.ultra-lite
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -183,7 +183,7 @@ jobs:
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/embedded/Dockerfile.fat
file: ./Dockerfile.fat
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
+100 -455
View File
@@ -2,445 +2,85 @@ 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]
release:
types: [created]
permissions:
contents: read
jobs:
determine-matrix:
build:
runs-on: ubuntu-latest
strategy:
matrix:
disable_security: [true, false]
include:
- disable_security: false
file_suffix: "-with-login"
- disable_security: true
file_suffix: ""
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
version: ${{ steps.versionNumber.outputs.versionNumber }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
- name: Generate jar (Disable Security=${{ matrix.disable_security }})
run: ./gradlew clean createExe
env:
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.disable_security }}
STIRLING_PDF_DESKTOP_UI: false
- name: Get version number
id: versionNumber
run: |
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Determine build matrix
id: set-matrix
- name: Rename binaries
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
mv ./build/launch4j/Stirling-PDF.exe ./build/launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
mv ./build/libs/Stirling-PDF-${{ steps.versionNumber.outputs.versionNumber }}.jar ./build/libs/Stirling-PDF${{ matrix.file_suffix }}.jar
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'
- name: Debug build artifacts
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
echo "Current Directory: $(pwd)"
ls -R ./build/libs
ls -R ./build/launch4j
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: Stirling-PDF-${{ matrix.name }}
path: ./dist/*
retention-days: 30
name: binaries${{ matrix.file_suffix }}
path: |
./build/launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.*
./build/libs/Stirling-PDF${{ matrix.file_suffix }}.*
sign_verify:
needs: build
runs-on: ubuntu-latest
if: success()
strategy:
fail-fast: false
matrix:
name: [windows-x86_64, macos-aarch64, macos-x86_64, linux-x86_64]
disable_security: [true, false]
include:
- disable_security: false
file_suffix: "-with-login"
- disable_security: true
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
@@ -450,8 +90,7 @@ jobs:
- name: Download build artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: Stirling-PDF-${{ matrix.name }}
name: binaries${{ matrix.file_suffix }}
- name: Display structure of downloaded files
run: ls -R
@@ -462,74 +101,80 @@ jobs:
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"
cosign sign-blob \
--key ./cosign.key \
--yes \
--output-signature ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.sig \
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
# Sign the artifact
cosign sign-blob \
--key ./cosign.key \
--yes \
--output-signature "${file}.sig" \
"$file"
cosign attest-blob \
--predicate - \
--key ./cosign.key \
--yes \
--output-attestation ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.intoto.jsonl \
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
# Generate attestation
cosign attest-blob \
--predicate - \
--key ./cosign.key \
--yes \
--output-attestation "${file}.intoto.jsonl" \
"$file"
cosign verify-blob \
--key ./cosign.pub \
--signature ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.sig \
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
# Verify the signature
cosign verify-blob \
--key ./cosign.pub \
--signature "${file}.sig" \
"$file"
cosign sign-blob \
--key ./cosign.key \
--yes \
--output-signature ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.sig \
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
echo "✅ Signed and verified: $file"
fi
done
cosign attest-blob \
--predicate - \
--key ./cosign.key \
--yes \
--output-attestation ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.intoto.jsonl \
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
cosign verify-blob \
--key ./cosign.pub \
--signature ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.sig \
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
- name: Upload signed artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: Stirling-PDF-${{ matrix.name }}-signed
name: signed${{ matrix.file_suffix }}
path: |
*
!cosign.key
!cosign.pub
retention-days: 30
./libs/Stirling-PDF${{ matrix.file_suffix }}.*
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.*
release:
needs: [determine-matrix, build, sign_verify]
needs: [build, sign_verify]
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
permissions:
contents: write
strategy:
matrix:
disable_security: [true, false]
include:
- disable_security: false
file_suffix: "-with-login"
- disable_security: true
file_suffix: ""
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Download all signed artifacts
- name: Download signed artifacts
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
pattern: Stirling-PDF-*-signed
path: ./artifacts
name: signed${{ matrix.file_suffix }}
- name: Display structure of downloaded files
run: ls -R ./artifacts
- name: Create GitHub Release
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
with:
tag_name: v${{ needs.determine-matrix.outputs.version }}
tag_name: v${{ needs.build.outputs.version }}
generate_release_notes: true
files: ./artifacts/**/*
draft: false
prerelease: false
files: |
./libs/Stirling-PDF*
./launch4j/Stirling-PDF-Server*
+122
View File
@@ -0,0 +1,122 @@
name: Sync Files
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "build.gradle"
- "README.md"
- "app/core/src/main/resources/messages_*.properties"
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
- "scripts/ignore_translation.toml"
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
sync-files:
runs-on: ubuntu-latest
env:
# Prevents sdist builds → no tar extraction
PIP_ONLY_BINARY: ":all:"
PIP_DISABLE_PIP_VERSION_CHECK: "1"
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup GitHub App Bot
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
- name: Sync translation property files
run: |
python .github/scripts/check_language_properties.py --reference-file "app/core/src/main/resources/messages_en_GB.properties" --branch main
- name: Commit translation files
run: |
git add app/core/src/main/resources/messages_*.properties
git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "No changes detected"
- name: Install dependencies
# Wheels-only + Hash-Pinning
run: |
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
- name: Sync README.md
run: |
python scripts/counter_translation.py
- name: Run git add
run: |
git add README.md scripts/ignore_translation.toml
git diff --staged --quiet || git commit -m ":memo: Sync README.md & scripts/ignore_translation.toml" || echo "No changes detected"
- name: Create Pull Request
if: always()
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: Update files
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: sync_readme
title: ":globe_with_meridians: Sync Translations + Update README Progress Table"
body: |
### Description of Changes
This Pull Request was automatically generated to synchronize updates to translation files and documentation. Below are the details of the changes made:
#### **1. Synchronization of Translation Files**
- Updated translation files (`messages_*.properties`) to reflect changes in the reference file `messages_en_GB.properties`.
- Ensured consistency and synchronization across all supported language files.
- Highlighted any missing or incomplete translations.
#### **2. Update README.md**
- Generated the translation progress table in `README.md`.
- Added a summary of the current translation status for all supported languages.
- Included up-to-date statistics on translation coverage.
#### **Why these changes are necessary**
- Keeps translation files aligned with the latest reference updates.
- Ensures the documentation reflects the current translation progress.
---
Auto-generated by [create-pull-request][1].
[1]: https://github.com/peter-evans/create-pull-request
draft: false
delete-branch: true
labels: github-actions
sign-commits: true
add-paths: |
README.md
app/core/src/main/resources/messages_*.properties
+16 -22
View File
@@ -1,15 +1,15 @@
name: Sync Files (TOML)
name: Sync Files V2
on:
workflow_dispatch:
push:
branches:
- main
- V2
- syncLangTest
paths:
- "build.gradle"
- "README.md"
- "frontend/public/locales/*/translation.toml"
- "frontend/public/locales/*/translation.json"
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
- "scripts/ignore_translation.toml"
@@ -52,25 +52,21 @@ jobs:
python-version: "3.12"
cache: "pip" # caching pip dependencies
- name: Install Python dependencies
- name: Sync translation JSON files
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
python .github/scripts/check_language_json.py --reference-file "frontend/public/locales/en-GB/translation.json" --branch V2
- 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"
git add frontend/public/locales/*/translation.json
git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "No changes detected"
- name: Install README dependencies
- name: Install dependencies
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
- name: Sync README.md
run: |
python scripts/counter_translation_v3.py
python scripts/counter_translation_v2.py
- name: Run git add
run: |
@@ -86,22 +82,21 @@ jobs:
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: sync_readme_v3
base: main
title: ":globe_with_meridians: Sync Translations + Update README Progress Table"
branch: sync_readme_v2
base: V2
title: ":globe_with_meridians: [V2] Sync Translations + Update README Progress Table"
body: |
### Description of Changes
This Pull Request was automatically generated to synchronize updates to translation files and documentation. Below are the details of the changes made:
This Pull Request was automatically generated to synchronize updates to translation files and documentation for the **V2 branch**. Below are the details of the changes made:
#### **1. Synchronization of Translation Files**
- Updated translation files (`frontend/public/locales/*/translation.toml`) to reflect changes in the reference file `en-GB/translation.toml`.
- Updated translation files (`frontend/public/locales/*/translation.json`) to reflect changes in the reference file `en-GB/translation.json`.
- Ensured consistency and synchronization across all supported language files.
- Highlighted any missing or incomplete translations.
- **Format**: TOML
#### **2. Update README.md**
- Generated the translation progress table in `README.md` using `counter_translation_v3.py`.
- Generated the translation progress table in `README.md`.
- Added a summary of the current translation status for all supported languages.
- Included up-to-date statistics on translation coverage.
@@ -120,5 +115,4 @@ jobs:
sign-commits: true
add-paths: |
README.md
frontend/public/locales/*/translation.toml
scripts/ignore_translation.toml
frontend/public/locales/*/translation.json
+1 -1
View File
@@ -66,7 +66,7 @@ jobs:
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./docker/embedded/Dockerfile
file: ./Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
+2 -16
View File
@@ -17,8 +17,8 @@ local.properties
version.properties
#### Stirling-PDF Files ###
pipeline/
!pipeline/.gitkeep
pipeline/watchedFolders/
pipeline/finishedFolders/
customFiles/
configs/
watchedFolders/
@@ -31,20 +31,6 @@ exampleYmlFiles/stirling/
/testing/file_snapshots
SwaggerDoc.json
# Frontend build artifacts copied to backend static resources
# These are generated by npm build and should not be committed
app/core/src/main/resources/static/assets/
app/core/src/main/resources/static/index.html
app/core/src/main/resources/static/locales/
app/core/src/main/resources/static/Login/
app/core/src/main/resources/static/classic-logo/
app/core/src/main/resources/static/modern-logo/
app/core/src/main/resources/static/og_images/
app/core/src/main/resources/static/samples/
app/core/src/main/resources/static/manifest-classic.json
app/core/src/main/resources/static/robots.txt
# Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc.
# Gradle
.gradle
.gradle-home
+3 -3
View File
@@ -202,10 +202,10 @@ const [ToolName] = (props: BaseToolProps) => {
## 5. Add Translations
Update translation files. **Important: Only update `en-GB` files** - other languages are handled separately.
**File to update:** `frontend/public/locales/en-GB/translation.toml`
**File to update:** `frontend/public/locales/en-GB/translation.json`
**Required Translation Keys**:
```toml
```json
{
"home": {
"[toolName]": {
@@ -251,7 +251,7 @@ Update translation files. **Important: Only update `en-GB` files** - other langu
```
**Translation Notes:**
- **Only update `en-GB/translation.toml`** - other locale files are managed separately
- **Only update `en-GB/translation.json`** - other locale files are managed separately
- Use descriptive keys that match your component's `t()` calls
- Include tooltip translations if you created tooltip hooks
- Add `options.*` keys if your tool has settings with descriptions
-4
View File
@@ -6,10 +6,6 @@ Portions of this software are licensed as follows:
* All content that resides under the "app/proprietary/" directory of this repository,
if that directory exists, is licensed under the license defined in "app/proprietary/LICENSE".
* All content that resides under the "frontend/src/proprietary/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/proprietary/LICENSE".
* All content that resides under the "frontend/src/desktop/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/desktop/LICENSE".
* Content outside of the above mentioned directories or restrictions above is
available under the MIT License as defined below.
+152 -48
View File
@@ -1,69 +1,173 @@
<p align="center">
<img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling.png" width="80" alt="Stirling PDF logo">
</p>
<p align="center"><img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling.png" width="80"></p>
<h1 align="center">Stirling-PDF</h1>
<h1 align="center">Stirling PDF - The Open-Source PDF Platform</h1>
[![Docker Pulls](https://img.shields.io/docker/pulls/frooodle/s-pdf)](https://hub.docker.com/r/frooodle/s-pdf)
[![Discord](https://img.shields.io/discord/1068636748814483718?label=Discord)](https://discord.gg/HYmhKj45pU)
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/Stirling-Tools/Stirling-PDF/badge)](https://scorecard.dev/viewer/?uri=github.com/Stirling-Tools/Stirling-PDF)
[![GitHub Repo stars](https://img.shields.io/github/stars/stirling-tools/stirling-pdf?style=social)](https://github.com/Stirling-Tools/stirling-pdf)
Stirling PDF is a powerful, open-source PDF editing platform. Run it as a personal desktop app, in the browser, or deploy it on your own servers with a private API. Edit, sign, redact, convert, and automate PDFs without sending documents to external services.
<a href="https://www.producthunt.com/posts/stirling-pdf?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-stirling&#0045;pdf" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=641239&theme=light" alt="Stirling&#0032;PDF - Open&#0032;source&#0032;locally&#0032;hosted&#0032;web&#0032;PDF&#0032;editor | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
[![Deploy to DO](https://www.deploytodo.com/do-btn-blue.svg)](https://cloud.digitalocean.com/apps/new?repo=https://github.com/Stirling-Tools/Stirling-PDF/tree/digitalOcean&refcode=c3210994b1af)
<p align="center">
<a href="https://hub.docker.com/r/stirlingtools/stirling-pdf">
<img src="https://img.shields.io/docker/pulls/frooodle/s-pdf" alt="Docker Pulls">
</a>
<a href="https://discord.gg/HYmhKj45pU">
<img src="https://img.shields.io/discord/1068636748814483718?label=Discord" alt="Discord">
</a>
<a href="https://scorecard.dev/viewer/?uri=github.com/Stirling-Tools/Stirling-PDF">
<img src="https://api.scorecard.dev/projects/github.com/Stirling-Tools/Stirling-PDF/badge" alt="OpenSSF Scorecard">
</a>
<a href="https://github.com/Stirling-Tools/stirling-pdf">
<img src="https://img.shields.io/github/stars/stirling-tools/stirling-pdf?style=social" alt="GitHub Repo stars">
</a>
</p>
[Stirling-PDF](https://www.stirlingpdf.com) is a robust, locally hosted web-based PDF manipulation tool using Docker. It enables you to carry out various operations on PDF files, including splitting, merging, converting, reorganizing, adding images, rotating, compressing, and more. This locally hosted web application has evolved to encompass a comprehensive set of features, addressing all your PDF requirements.
![Stirling PDF - Dashboard](images/home-light.png)
All files and PDFs exist either exclusively on the client side, reside in server memory only during task execution, or temporarily reside in a file solely for the execution of the task. Any file downloaded by the user will have been deleted from the server by that point.
## Key Capabilities
Homepage: [https://stirlingpdf.com](https://stirlingpdf.com)
- **Everywhere you work** - Desktop client, browser UI, and self-hosted server with a private API.
- **50+ PDF tools** - Edit, merge, split, sign, redact, convert, OCR, compress, and more.
- **Automation & workflows** - No-code pipelines direct in UI with APIs to process millions of PDFs.
- **Enterprisegrade** - SSO, auditing, and flexible onprem deployments.
- **Developer platform** - REST APIs available for nearly all tools to integrate into your existing systems.
- **Global UI** - Interface available in 40+ languages.
All documentation available at [https://docs.stirlingpdf.com/](https://docs.stirlingpdf.com/)
For a full feature list, see the docs: **https://docs.stirlingpdf.com**
![stirling-home](images/stirling-home.jpg)
## Quick Start
## Features
```bash
docker run -p 8080:8080 docker.stirlingpdf.com/stirlingtools/stirling-pdf
```
- 50+ PDF Operations
- Parallel file processing and downloads
- Dark mode support
- Custom download options
- Custom 'Pipelines' to run multiple features in a automated queue
- API for integration with external scripts
- Optional Login and Authentication support (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/System%20and%20Security) for documentation)
- Database Backup and Import (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/DATABASE) for documentation)
- Enterprise features like SSO (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration) for documentation)
Then open: http://localhost:8080
## PDF Features
For full installation options (including desktop and Kubernetes), see our [Documentation Guide](https://docs.stirlingpdf.com/#documentation-guide).
### Page Operations
## Resources
- View and modify PDFs - View multi-page PDFs with custom viewing, sorting, and searching. Plus, on-page edit features like annotating, drawing, and adding text and images. (Using PDF.js with Joxit and Liberation fonts)
- Full interactive GUI for merging/splitting/rotating/moving PDFs and their pages
- Merge multiple PDFs into a single resultant file
- Split PDFs into multiple files at specified page numbers or extract all pages as individual files
- Reorganize PDF pages into different orders
- Rotate PDFs in 90-degree increments
- Remove pages
- Multi-page layout (format PDFs into a multi-paged page)
- Scale page contents size by set percentage
- Adjust contrast
- Crop PDF
- Auto-split PDF (with physically scanned page dividers)
- Extract page(s)
- Convert PDF to a single page
- Overlay PDFs on top of each other
- PDF to a single page
- Split PDF by sections
- [**Documentation**](https://docs.stirlingpdf.com)
- [**Homepage**](https://stirling.com)
- [**API Docs**](https://registry.scalar.com/@stirlingpdf/apis/stirling-pdf-processing-api/)
- [**Server Plan & Enterprise**](https://docs.stirlingpdf.com/Paid-Offerings)
### Conversion Operations
## Support
- Convert PDFs to and from images
- Convert any common file to PDF (using LibreOffice)
- Convert PDF to Word/PowerPoint/others (using LibreOffice)
- Convert HTML to PDF
- Convert PDF to XML
- Convert PDF to CSV
- URL to PDF
- Markdown to PDF
- **Community** [Discord](https://discord.gg/HYmhKj45pU)
- **Bug Reports**: [Github issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
### Security & Permissions
## Contributing
- Add and remove passwords
- Change/set PDF permissions
- Add watermark(s)
- Certify/sign PDFs
- Sanitize PDFs
- Auto-redact text
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
### Other Operations
For development setup, see the [Developer Guide](DeveloperGuide.md).
- Add/generate/write signatures
- Split by Size or PDF
- Repair PDFs
- Detect and remove blank pages
- Compare two PDFs and show differences in text
- Add images to PDFs
- Compress PDFs to decrease their filesize (using qpdf)
- Extract images from PDF
- Remove images from PDF
- Extract images from scans
- Remove annotations
- Add page numbers
- Auto-rename files by detecting PDF header text
- OCR on PDF (using Tesseract OCR)
- PDF/A conversion (using LibreOffice)
- Edit metadata
- Flatten PDFs
- Get all information on a PDF to view or export as JSON
- Show/detect embedded JavaScript
For adding translations, see the [Translation Guide](devGuide/HowToAddNewLanguage.md).
## License
Stirling PDF is open-core. See [LICENSE](LICENSE) for details.
# 📖 Get Started
Visit our comprehensive documentation at [docs.stirlingpdf.com](https://docs.stirlingpdf.com) for:
- Installation guides for all platforms
- Configuration options
- Feature documentation
- API reference
- Security setup
- Enterprise features
## Supported Languages
Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![94%](https://geps.dev/progress/94) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![93%](https://geps.dev/progress/93) |
| Basque (Euskara) (eu_ES) | ![93%](https://geps.dev/progress/93) |
| Bulgarian (Български) (bg_BG) | ![94%](https://geps.dev/progress/94) |
| Catalan (Català) (ca_CA) | ![93%](https://geps.dev/progress/93) |
| Croatian (Hrvatski) (hr_HR) | ![93%](https://geps.dev/progress/93) |
| Czech (Česky) (cs_CZ) | ![91%](https://geps.dev/progress/91) |
| Danish (Dansk) (da_DK) | ![92%](https://geps.dev/progress/92) |
| Dutch (Nederlands) (nl_NL) | ![93%](https://geps.dev/progress/93) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![93%](https://geps.dev/progress/93) |
| German (Deutsch) (de_DE) | ![93%](https://geps.dev/progress/93) |
| Greek (Ελληνικά) (el_GR) | ![93%](https://geps.dev/progress/93) |
| Hindi (हिंदी) (hi_IN) | ![94%](https://geps.dev/progress/94) |
| Hungarian (Magyar) (hu_HU) | ![94%](https://geps.dev/progress/94) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![93%](https://geps.dev/progress/93) |
| Irish (Gaeilge) (ga_IE) | ![94%](https://geps.dev/progress/94) |
| Italian (Italiano) (it_IT) | ![93%](https://geps.dev/progress/93) |
| Japanese (日本語) (ja_JP) | ![94%](https://geps.dev/progress/94) |
| Korean (한국어) (ko_KR) | ![94%](https://geps.dev/progress/94) |
| Norwegian (Norsk) (no_NB) | ![93%](https://geps.dev/progress/93) |
| Persian (فارسی) (fa_IR) | ![94%](https://geps.dev/progress/94) |
| Polish (Polski) (pl_PL) | ![93%](https://geps.dev/progress/93) |
| Portuguese (Português) (pt_PT) | ![93%](https://geps.dev/progress/93) |
| Portuguese Brazilian (Português) (pt_BR) | ![93%](https://geps.dev/progress/93) |
| Romanian (Română) (ro_RO) | ![93%](https://geps.dev/progress/93) |
| Russian (Русский) (ru_RU) | ![94%](https://geps.dev/progress/94) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![93%](https://geps.dev/progress/93) |
| Simplified Chinese (简体中文) (zh_CN) | ![94%](https://geps.dev/progress/94) |
| Slovakian (Slovensky) (sk_SK) | ![93%](https://geps.dev/progress/93) |
| Slovenian (Slovenščina) (sl_SI) | ![94%](https://geps.dev/progress/94) |
| Spanish (Español) (es_ES) | ![94%](https://geps.dev/progress/94) |
| Swedish (Svenska) (sv_SE) | ![93%](https://geps.dev/progress/93) |
| Thai (ไทย) (th_TH) | ![93%](https://geps.dev/progress/93) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) |
| Traditional Chinese (繁體中文) (zh_TW) | ![94%](https://geps.dev/progress/94) |
| Turkish (Türkçe) (tr_TR) | ![94%](https://geps.dev/progress/94) |
| Ukrainian (Українська) (uk_UA) | ![94%](https://geps.dev/progress/94) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![93%](https://geps.dev/progress/93) |
| Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) |
## Stirling PDF Enterprise
Stirling PDF offers an Enterprise edition of its software. This is the same great software but with added features, support and comforts.
Check out our [Enterprise docs](https://docs.stirlingpdf.com/Pro)
## 🤝 Looking to contribute?
Join our community:
- [Contribution Guidelines](CONTRIBUTING.md)
- [Translation Guide (How to add custom languages)](devGuide/HowToAddNewLanguage.md)
- [Developer Guide](devGuide/DeveloperGuide.md)
- [Issue Tracker](https://github.com/Stirling-Tools/Stirling-PDF/issues)
- [Discord Community](https://discord.gg/HYmhKj45pU)
@@ -22,13 +22,25 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Analysis",
description =
"""
Read-only inspection of PDFs: page count, page sizes, fonts, form fields, annotations, document properties, and security details.
Use these endpoints to understand what's inside a document without changing it.
Document analysis and information extraction services for content intelligence and insights.
Typical uses:
• Get page counts and dimensions for layout or print rules
• List fonts and annotations to spot compatibility issues
• Inspect form fields before deciding how to fill or modify them
Pull metadata and security settings for audits or reports
This endpoint group provides analytical capabilities to understand document structure,
extract information, and generate insights from PDF content for automated processing.
Common use cases:
Document inventory management and content audit for compliance verification
• Quality assurance workflows and business intelligence analytics
• Migration planning, accessibility evaluation, and document forensics
Business applications:
• Legal discovery, financial document review, and healthcare records analysis
• Academic research, government processing, and publishing optimization
Operational scenarios:
• Large-scale profiling, migration assessment, and performance optimization
• Automated quality control and content strategy development
Target users: Data analysts, QA teams, administrators, and business intelligence
professionals requiring detailed document insights.
""")
public @interface AnalysisApi {}
@@ -22,13 +22,25 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Convert",
description =
"""
Convert PDFs to and from other formats (Word, images, HTML, Markdown, PDF/A, CBZ/CBR, EML, etc.).
This group also powers the text-editor / jobId-based editing flow for incremental PDF edits.
Document format transformation services for cross-platform compatibility and workflow integration.
Typical uses:
• Turn PDFs into Word or text for editing
• Convert office files, images, HTML, or email (EML) into PDFs
• Create PDF/A for long-term archiving
Export PDFs as images, HTML, CSV, or Markdown for search, analysis, or reuse
This endpoint group enables transformation between various formats, supporting
diverse business workflows and system integrations for mixed document ecosystems.
Common use cases:
Legacy system integration, document migration, and cross-platform sharing
• Archive standardization, publishing preparation, and content adaptation
• Accessibility compliance and mobile-friendly document preparation
Business applications:
• Enterprise content management, digital publishing, and educational platforms
• Legal document processing, healthcare interoperability, and government standardization
Integration scenarios:
• API-driven pipelines, automated workflow preparation, and batch conversions
• Real-time format adaptation for user requests
Target users: System integrators, content managers, digital archivists, and
organizations requiring flexible document format interoperability.
""")
public @interface ConvertApi {}
@@ -22,13 +22,25 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Filter",
description =
"""
Check basic properties of PDFs before you process them: page count, file size, page size/rotation, and whether they contain text or images.
Use these endpoints as a "pre-check" step to decide what to do with a file next.
Document content filtering and search operations for information discovery and organization.
Typical uses:
• Reject files that are too big or too small
• Detect image-only PDFs that should go through OCR
• Ensure a document has enough pages before it enters a workflow
Check orientation of pages before printing or merging
This endpoint group enables intelligent content discovery and organization within
document collections for content-based processing and information extraction.
Common use cases:
Legal discovery, research organization, and compliance auditing
• Content moderation, academic research, and business intelligence
• Quality assurance and content validation workflows
Business applications:
• Contract analysis, financial review, and healthcare records organization
• Government processing, educational curation, and IP protection
Workflow scenarios:
• Large-scale processing, automated classification, and information extraction
• Document preparation for further processing or analysis
Target users: Legal professionals, researchers, compliance officers, and
organizations requiring intelligent document content discovery and organization.
""")
public @interface FilterApi {}
@@ -22,13 +22,21 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "General",
description =
"""
Page-level PDF editing: split, merge, rotate, crop, rearrange, and scale pages.
These endpoints handle most daily "I opened a PDF editor just to…" type tasks.
Core PDF processing operations for fundamental document manipulation workflows.
Typical uses:
• Split a large PDF into smaller files (by pages, chapters, or size)
• Merge several PDFs into one report or pack
• Rotate or reorder pages before sending or archiving
Turn a multi-page document into one long scrolling page
This endpoint group provides essential PDF functionality that forms the foundation
of most document processing workflows across various industries.
Common use cases:
Document preparation for archival systems and content organization
• File preparation for distribution, accessibility compliance, and batch processing
• Document consolidation for reporting and legal compliance workflows
Typical applications:
• Content management, publishing workflows, and educational content distribution
• Business process automation and archive management
Target users: Content managers, document processors, and organizations requiring
reliable foundational PDF manipulation capabilities.
""")
public @interface GeneralApi {}
@@ -22,15 +22,25 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Misc",
description =
"""
Tools that don't fit neatly elsewhere: OCR, compress, repair, flatten, extract images, update metadata, add stamps/page numbers/images, and more.
These endpoints help fix problem PDFs and prepare them for sharing, storage, or further processing.
Specialized utilities and supplementary tools for enhanced document processing workflows.
Typical uses:
• Repair a damaged PDF or remove blank pages
• Run OCR on scanned PDFs so they become searchable
Compress large PDFs for email or web download
Extract embedded images or scans
Add page numbers, stamps, or overlay an image (e.g. logo, seal)
Update PDF metadata (title, author, etc.)
This endpoint group provides utility operations that support core document processing
tasks and address specific workflow needs in real-world scenarios.
Common use cases:
Document optimization for bandwidth-limited environments and storage cost management
Document repair, content extraction, and validation for quality assurance
Accessibility improvement and custom processing for specialized needs
Business applications:
• Web publishing optimization, email attachment management, and archive efficiency
• Mobile compatibility, print production, and legacy document recovery
Operational scenarios:
• Batch processing, quality control, and performance optimization
• Troubleshooting and recovery of problematic documents
Target users: System administrators, document specialists, and organizations requiring
specialized document processing and optimization tools.
""")
public @interface MiscApi {}
@@ -22,12 +22,25 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Pipeline",
description =
"""
Run several PDF operations in one configured pipeline instead of calling multiple endpoints yourself.
Useful when you always do the same steps in sequence (for example: convert → OCR → compress → watermark).
Automated document processing workflows for complex multi-stage business operations.
Typical uses:
• Process incoming invoices in one go (clean, OCR, compress, stamp, etc.)
• Normalise documents before they enter an archive
• Wrap a complex document flow behind a single API call for your own apps
This endpoint group enables organizations to create sophisticated document processing
workflows that combine multiple operations into streamlined, repeatable processes.
Common use cases:
• Invoice processing, legal document review, and healthcare records standardization
• Government processing, educational content preparation, and publishing automation
• Contract lifecycle management and approval processes
Business applications:
• Automated compliance reporting, large-scale migration, and quality assurance
• Archive preparation, content delivery, and document approval workflows
Operational scenarios:
• Scheduled batch processing and event-driven document processing
• Multi-department coordination and business system integration
Target users: Business process managers, IT automation specialists, and organizations
requiring consistent, repeatable document processing workflows.
""")
public @interface PipelineApi {}
@@ -22,13 +22,25 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Security",
description =
"""
Protect and clean PDFs: passwords, digital signatures, redaction, and sanitizing.
These endpoints help you control who can open a file, what they can do with it, and remove sensitive content when needed.
Document security and protection services for confidential and sensitive content.
Typical uses:
• Add or remove a password on a PDF
• Redact personal or confidential information (manually or automatically)
• Validate or remove digital signatures
Sanitize a PDF to strip scripts and embedded content
This endpoint group provides essential security operations for organizations handling
sensitive documents and materials requiring controlled access.
Common use cases:
Legal confidentiality, healthcare privacy (HIPAA), and financial regulatory compliance
• Government classified handling, corporate IP protection, and educational privacy (FERPA)
• Contract security for business transactions
Business applications:
• Document authentication, confidential sharing, and secure archiving
• Content watermarking, access control, and privacy protection through redaction
Industry scenarios:
• Legal discovery, medical records exchange, financial audit documentation
• Enterprise policy enforcement and data governance
Target users: Legal professionals, healthcare administrators, compliance officers,
government agencies, and enterprises handling sensitive content.
""")
public @interface SecurityApi {}
@@ -1,21 +0,0 @@
package stirling.software.common.service;
import java.io.IOException;
/**
* Interface for personal signature access (proprietary feature). Implemented only in proprietary
* module to provide authenticated users access to their personal signatures.
*/
public interface PersonalSignatureServiceInterface {
/**
* Get a personal signature from the user's folder. Only checks personal folder, not shared
* folder.
*
* @param username Username of the signature owner
* @param fileName Signature filename
* @return Personal signature image bytes
* @throws IOException If file not found or read error
*/
byte[] getPersonalSignatureBytes(String username, String fileName) throws IOException;
}
@@ -7,104 +7,23 @@ public class RequestUriUtils {
}
public static boolean isStaticResource(String contextPath, String requestURI) {
if (requestURI == null) {
return false;
}
String normalizedUri = stripContextPath(contextPath, requestURI);
// API routes are never static except for the public status endpoint
if (normalizedUri.startsWith("/api/")) {
return normalizedUri.startsWith("/api/v1/info/status");
}
// Well-known static asset directories (backend + React build artifacts)
if (normalizedUri.startsWith("/css/")
|| normalizedUri.startsWith("/fonts/")
|| normalizedUri.startsWith("/js/")
|| normalizedUri.startsWith("/images/")
|| normalizedUri.startsWith("/public/")
|| normalizedUri.startsWith("/pdfjs/")
|| normalizedUri.startsWith("/pdfjs-legacy/")
|| normalizedUri.startsWith("/assets/")
|| normalizedUri.startsWith("/locales/")
|| normalizedUri.startsWith("/Login/")
|| normalizedUri.startsWith("/samples/")
|| normalizedUri.startsWith("/classic-logo/")
|| normalizedUri.startsWith("/modern-logo/")
|| normalizedUri.startsWith("/og_images/")) {
return true;
}
// Specific static files bundled with the frontend
if (normalizedUri.equals("/robots.txt")
|| normalizedUri.equals("/favicon.ico")
|| normalizedUri.equals("/manifest.json")
|| normalizedUri.equals("/site.webmanifest")
|| normalizedUri.equals("/manifest-classic.json")
|| normalizedUri.equals("/index.html")) {
return true;
}
// Login/error pages remain public
if (normalizedUri.startsWith("/login") || normalizedUri.startsWith("/error")) {
return true;
}
// Treat common static file extensions as static resources
return normalizedUri.endsWith(".svg")
|| normalizedUri.endsWith(".png")
|| normalizedUri.endsWith(".ico")
|| normalizedUri.endsWith(".txt")
|| normalizedUri.endsWith(".webmanifest")
|| normalizedUri.endsWith(".js")
|| normalizedUri.endsWith(".css")
|| normalizedUri.endsWith(".mjs")
|| normalizedUri.endsWith(".html")
|| normalizedUri.endsWith(".toml");
}
public static boolean isFrontendRoute(String contextPath, String requestURI) {
if (requestURI == null) {
return false;
}
String normalizedUri = stripContextPath(contextPath, requestURI);
// APIs are never treated as frontend routes
if (normalizedUri.startsWith("/api/")) {
return false;
}
// Blocklist of backend/non-frontend paths that should still go through filters
String[] backendOnlyPrefixes = {
"/register",
"/invite",
"/pipeline",
"/pdfjs",
"/pdfjs-legacy",
"/fonts",
"/images",
"/files",
"/css",
"/js",
"/swagger",
"/v1/api-docs",
"/actuator"
};
for (String prefix : backendOnlyPrefixes) {
if (normalizedUri.equals(prefix) || normalizedUri.startsWith(prefix + "/")) {
return false;
}
}
if (normalizedUri.isBlank()) {
return false;
}
// Allow root and any extensionless path (React Router will handle these)
return !normalizedUri.contains(".");
return requestURI.startsWith(contextPath + "/css/")
|| requestURI.startsWith(contextPath + "/fonts/")
|| requestURI.startsWith(contextPath + "/js/")
|| requestURI.endsWith(contextPath + "robots.txt")
|| requestURI.startsWith(contextPath + "/images/")
|| requestURI.startsWith(contextPath + "/public/")
|| requestURI.startsWith(contextPath + "/pdfjs/")
|| requestURI.startsWith(contextPath + "/pdfjs-legacy/")
|| requestURI.startsWith(contextPath + "/login")
|| requestURI.startsWith(contextPath + "/error")
|| requestURI.startsWith(contextPath + "/favicon")
|| requestURI.endsWith(".svg")
|| requestURI.endsWith(".png")
|| requestURI.endsWith(".ico")
|| requestURI.endsWith(".txt")
|| requestURI.endsWith(".webmanifest")
|| requestURI.startsWith(contextPath + "/api/v1/info/status");
}
public static boolean isTrackableResource(String requestURI) {
@@ -124,7 +43,6 @@ public class RequestUriUtils {
|| requestURI.endsWith(".svg")
|| requestURI.endsWith("popularity.txt")
|| requestURI.endsWith(".js")
|| requestURI.endsWith(".toml")
|| requestURI.contains("swagger")
|| requestURI.startsWith("/api/v1/info")
|| requestURI.startsWith("/site.webmanifest")
@@ -160,18 +78,9 @@ public class RequestUriUtils {
|| trimmedUri.startsWith(
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
// enableLogin)
|| trimmedUri.startsWith(
"/api/v1/ui-data/footer-info") // Public footer configuration
|| trimmedUri.startsWith("/v1/api-docs")
|| trimmedUri.startsWith("/api/v1/invite/validate")
|| trimmedUri.startsWith("/api/v1/invite/accept")
|| trimmedUri.contains("/v1/api-docs");
}
private static String stripContextPath(String contextPath, String requestURI) {
if (contextPath != null && !contextPath.isBlank() && requestURI.startsWith(contextPath)) {
return requestURI.substring(contextPath.length());
}
return requestURI;
}
}
@@ -49,27 +49,6 @@ public class RequestUriUtilsTest {
"API products should not be static");
}
@Test
void testIsFrontendRoute() {
assertTrue(
RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route");
assertTrue(
RequestUriUtils.isFrontendRoute("", "/app/dashboard"),
"React routes without extensions should be frontend routes");
assertFalse(
RequestUriUtils.isFrontendRoute("", "/api/v1/users"),
"API routes should not be frontend routes");
assertFalse(
RequestUriUtils.isFrontendRoute("", "/register"),
"Register should not be treated as a frontend route");
assertFalse(
RequestUriUtils.isFrontendRoute("", "/pipeline/jobs"),
"Pipeline should not be treated as a frontend route");
assertFalse(
RequestUriUtils.isFrontendRoute("", "/files/download"),
"Files path should not be treated as a frontend route");
}
@Test
void testIsStaticResourceWithContextPath() {
String contextPath = "/myapp";
@@ -104,7 +83,6 @@ public class RequestUriUtilsTest {
"/favicon.ico",
"/icon.svg",
"/image.png",
"/locales/en/translation.toml",
"/site.webmanifest",
"/app/logo.svg",
"/downloads/document.png",
-125
View File
@@ -1,7 +1,5 @@
apply plugin: 'org.springframework.boot'
import org.apache.tools.ant.taskdefs.condition.Os
repositories {
maven { url = 'https://build.shibboleth.net/maven/releases' }
maven { url = 'https://maven.pkg.github.com/jcefmaven/jcefmaven' }
@@ -17,7 +15,6 @@ configurations {
spotless {
java {
target 'src/**/java/**/*.java'
targetExclude 'src/main/resources/static/**'
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
@@ -28,14 +25,12 @@ spotless {
}
yaml {
target '**/*.yml', '**/*.yaml'
targetExclude 'src/main/resources/static/**'
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
}
format 'gradle', {
target '**/gradle/*.gradle', '**/*.gradle'
targetExclude 'src/main/resources/static/**'
trimTrailingWhitespace()
leadingTabsToSpaces()
endWithNewline()
@@ -162,125 +157,5 @@ springBoot {
mainClass = 'stirling.software.SPDF.SPDFApplication'
}
// Frontend build tasks - only enabled with -PbuildWithFrontend=true
def buildWithFrontend = project.hasProperty('buildWithFrontend') && project.property('buildWithFrontend') == 'true'
def frontendDir = file('../../frontend')
def frontendDistDir = file('../../frontend/dist')
def resourcesStaticDir = file('src/main/resources/static')
def generatedFrontendPaths = [
'assets',
'index.html',
'locales',
'Login',
'classic-logo',
'modern-logo',
'og_images',
'samples',
'manifest-classic.json'
]
tasks.register('npmInstall', Exec) {
enabled = buildWithFrontend
group = 'frontend'
description = 'Install frontend dependencies'
workingDir frontendDir
commandLine = Os.isFamily(Os.FAMILY_WINDOWS) ? ['cmd', '/c', 'npm', 'ci', '--prefer-offline'] : ['npm', 'ci', '--prefer-offline']
inputs.file(new File(frontendDir, 'package.json'))
inputs.file(new File(frontendDir, 'package-lock.json'))
outputs.dir(new File(frontendDir, 'node_modules'))
// Show live output
standardOutput = System.out
errorOutput = System.err
// Skip if node_modules exists and is up-to-date
onlyIf {
def nodeModules = new File(frontendDir, 'node_modules')
if (!nodeModules.exists()) {
println "node_modules not found, will install..."
return true
}
def packageJson = new File(frontendDir, 'package.json')
def packageLock = new File(frontendDir, 'package-lock.json')
def isOutdated = nodeModules.lastModified() < packageJson.lastModified() ||
nodeModules.lastModified() < packageLock.lastModified()
if (isOutdated) {
println "package.json or package-lock.json changed, will reinstall..."
} else {
println "node_modules is up-to-date, skipping npm install"
}
return isOutdated
}
doFirst {
println "Installing npm dependencies in ${frontendDir}..."
}
}
tasks.register('npmBuild', Exec) {
enabled = buildWithFrontend
group = 'frontend'
description = 'Build frontend application'
workingDir frontendDir
commandLine = Os.isFamily(Os.FAMILY_WINDOWS) ? ['cmd', '/c', 'npm', 'run', 'build'] : ['npm', 'run', 'build']
dependsOn npmInstall
inputs.dir(new File(frontendDir, 'src'))
inputs.file(new File(frontendDir, 'package.json'))
outputs.dir(frontendDistDir)
// Show live output
standardOutput = System.out
errorOutput = System.err
// Override VITE_API_BASE_URL to use relative paths for production builds
// This ensures JARs work regardless of how they're deployed (direct, proxied, etc.)
environment 'VITE_API_BASE_URL', '/'
doFirst {
println "Building frontend application for production (VITE_API_BASE_URL=/)"
}
}
tasks.register('copyFrontendAssets', Copy) {
enabled = buildWithFrontend
group = 'frontend'
description = 'Copy frontend build to static resources'
dependsOn npmBuild
from(frontendDistDir) {
// Exclude files that conflict with backend static resources
exclude 'robots.txt' // Backend already has this
exclude 'favicon.ico' // Backend already has this
}
into resourcesStaticDir
duplicatesStrategy = DuplicatesStrategy.INCLUDE // Let frontend overwrite when needed
doFirst {
println "Copying frontend build from ${frontendDistDir} to ${resourcesStaticDir}..."
println "Backend static resources will be preserved"
}
doLast {
println "Frontend assets copied successfully!"
}
}
tasks.register('cleanFrontendAssets', Delete) {
group = 'frontend'
description = 'Remove previously generated frontend assets from static resources'
delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) }
}
// Ensure copyFrontendAssets runs after spotless tasks
tasks.named('copyFrontendAssets').configure {
mustRunAfter tasks.matching { it.name.startsWith('spotless') }
}
if (buildWithFrontend) {
println "Frontend build enabled - JAR will include React frontend"
processResources.dependsOn copyFrontendAssets
} else {
println "Frontend build disabled - JAR will be backend-only"
// When not building the UI, ensure any stale frontend assets are removed
processResources.dependsOn cleanFrontendAssets
}
bootJar.dependsOn ':common:jar'
bootJar.dependsOn ':proprietary:jar'
@@ -25,7 +25,6 @@ public class ExternalAppDepConfig {
private final String weasyprintPath;
private final String unoconvPath;
private final Map<String, List<String>> commandToGroupMapping;
private volatile boolean dependenciesChecked = false;
public ExternalAppDepConfig(
EndpointConfiguration endpointConfiguration, RuntimePathConfig runtimePathConfig) {
@@ -112,10 +111,6 @@ public class ExternalAppDepConfig {
}
}
public boolean isDependenciesChecked() {
return dependenciesChecked;
}
@PostConstruct
public void checkDependencies() {
// Check core dependencies
@@ -167,7 +162,5 @@ public class ExternalAppDepConfig {
}
}
endpointConfiguration.logDisabledEndpointsSummary();
dependenciesChecked = true;
log.info("Dependency checks completed");
}
}
@@ -21,9 +21,6 @@ public class SpringDocConfig {
"/api/v1/user/**",
"/api/v1/settings/**",
"/api/v1/team/**",
"/api/v1/auth/**",
"/api/v1/invite/**",
"/api/v1/audit/**",
"/api/v1/ui-data/**",
"/api/v1/proprietary/ui-data/**",
"/api/v1/info/**",
@@ -36,7 +33,7 @@ public class SpringDocConfig {
openApi.getInfo()
.title("Stirling PDF - Processing API")
.description(
"APIs for converting, editing, securing, and analysing PDF documents. Use these endpoints to automate common PDF tasks (like split, merge, convert, OCR) and plug them into your own apps and backend jobs."));
"API documentation for PDF processing operations including conversion, manipulation, security, and utilities."));
})
.build();
}
@@ -50,17 +47,14 @@ public class SpringDocConfig {
"/api/v1/admin/**",
"/api/v1/user/**",
"/api/v1/settings/**",
"/api/v1/team/**",
"/api/v1/auth/**",
"/api/v1/invite/**",
"/api/v1/audit/**")
"/api/v1/team/**")
.addOpenApiCustomizer(
openApi -> {
openApi.info(
openApi.getInfo()
.title("Stirling PDF - Management API")
.title("Stirling PDF - Admin API")
.description(
"Endpoints for authentication, user management, invitations, audit logging, and system configuration."));
"API documentation for administrative functions, user management, and system configuration."));
})
.build();
}
@@ -82,7 +76,7 @@ public class SpringDocConfig {
openApi.getInfo()
.title("Stirling PDF - System API")
.description(
"System information, UI metadata, job status, and file management endpoints."));
"API documentation for system information, UI data, and utility endpoints."));
})
.build();
}
@@ -42,7 +42,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
// Tauri v1 uses tauri://localhost, v2 uses http(s)://tauri.localhost
logger.info("Tauri mode detected - enabling CORS for Tauri protocols (v1 and v2)");
registry.addMapping("/**")
.allowedOriginPatterns(
.allowedOrigins(
"tauri://localhost",
"http://tauri.localhost",
"https://tauri.localhost")
@@ -26,7 +26,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.Dependency;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.SPDF.service.SharedSignatureService;
import stirling.software.SPDF.service.SignatureService;
import stirling.software.common.annotations.api.UiDataApi;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.RuntimePathConfig;
@@ -40,14 +40,14 @@ import stirling.software.common.util.GeneralUtils;
public class UIDataController {
private final ApplicationProperties applicationProperties;
private final SharedSignatureService signatureService;
private final SignatureService signatureService;
private final UserServiceInterface userService;
private final ResourceLoader resourceLoader;
private final RuntimePathConfig runtimePathConfig;
public UIDataController(
ApplicationProperties applicationProperties,
SharedSignatureService signatureService,
SignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService,
ResourceLoader resourceLoader,
RuntimePathConfig runtimePathConfig) {
@@ -58,21 +58,6 @@ public class UIDataController {
this.runtimePathConfig = runtimePathConfig;
}
@GetMapping("/footer-info")
@Operation(summary = "Get public footer configuration data")
public ResponseEntity<FooterData> getFooterData() {
FooterData data = new FooterData();
data.setAnalyticsEnabled(applicationProperties.getSystem().getEnableAnalytics());
data.setTermsAndConditions(applicationProperties.getLegal().getTermsAndConditions());
data.setPrivacyPolicy(applicationProperties.getLegal().getPrivacyPolicy());
data.setAccessibilityStatement(
applicationProperties.getLegal().getAccessibilityStatement());
data.setCookiePolicy(applicationProperties.getLegal().getCookiePolicy());
data.setImpressum(applicationProperties.getLegal().getImpressum());
return ResponseEntity.ok(data);
}
@GetMapping("/home")
@Operation(summary = "Get home page data")
public ResponseEntity<HomeData> getHomeData() {
@@ -252,16 +237,6 @@ public class UIDataController {
}
// Data classes
@Data
public static class FooterData {
private Boolean analyticsEnabled;
private String termsAndConditions;
private String privacyPolicy;
private String accessibilityStatement;
private String cookiePolicy;
private String impressum;
}
@Data
public static class HomeData {
private boolean showSurveyFromDocker;
@@ -35,7 +35,6 @@ public class ConfigController {
private final EndpointConfiguration endpointConfiguration;
private final ServerCertificateServiceInterface serverCertificateService;
private final UserServiceInterface userService;
private final stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig;
public ConfigController(
ApplicationProperties applicationProperties,
@@ -44,14 +43,12 @@ public class ConfigController {
@org.springframework.beans.factory.annotation.Autowired(required = false)
ServerCertificateServiceInterface serverCertificateService,
@org.springframework.beans.factory.annotation.Autowired(required = false)
UserServiceInterface userService,
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) {
UserServiceInterface userService) {
this.applicationProperties = applicationProperties;
this.applicationContext = applicationContext;
this.endpointConfiguration = endpointConfiguration;
this.serverCertificateService = serverCertificateService;
this.userService = userService;
this.externalAppDepConfig = externalAppDepConfig;
}
@GetMapping("/app-config")
@@ -59,9 +56,6 @@ public class ConfigController {
Map<String, Object> configData = new HashMap<>();
try {
// Add dependency check status
configData.put("dependenciesReady", externalAppDepConfig.isDependenciesChecked());
// Get AppConfig bean
AppConfig appConfig = applicationContext.getBean(AppConfig.class);
@@ -25,7 +25,7 @@ import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.SPDF.service.SharedSignatureService;
import stirling.software.SPDF.service.SignatureService;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.UserServiceInterface;
@@ -37,13 +37,13 @@ import stirling.software.common.util.GeneralUtils;
@Slf4j
public class GeneralWebController {
private final SharedSignatureService signatureService;
private final SignatureService signatureService;
private final UserServiceInterface userService;
private final ResourceLoader resourceLoader;
private final RuntimePathConfig runtimePathConfig;
public GeneralWebController(
SharedSignatureService signatureService,
SignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService,
ResourceLoader resourceLoader,
RuntimePathConfig runtimePathConfig) {
@@ -1,89 +1,17 @@
package stirling.software.SPDF.controller.web;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import jakarta.servlet.http.HttpServletRequest;
@Controller
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
public class ReactRoutingController {
@Value("${server.servlet.context-path:/}")
private String contextPath;
private String cachedIndexHtml;
private boolean indexHtmlExists = false;
@PostConstruct
public void init() {
// Only cache if index.html exists (production builds)
ClassPathResource resource = new ClassPathResource("static/index.html");
if (resource.exists()) {
try {
this.cachedIndexHtml = processIndexHtml();
this.indexHtmlExists = true;
} catch (IOException e) {
// Failed to cache, will process on each request
this.indexHtmlExists = false;
}
}
@GetMapping("/{path:^(?!api|static|robots\\.txt|favicon\\.ico)[^\\.]*$}")
public String forwardRootPaths() {
return "forward:/index.html";
}
private String processIndexHtml() throws IOException {
ClassPathResource resource = new ClassPathResource("static/index.html");
try (InputStream inputStream = resource.getInputStream()) {
String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
// Replace %BASE_URL% with the actual context path for base href
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
html = html.replace("%BASE_URL%", baseUrl);
// Also rewrite any existing <base> tag (Vite may have baked one in)
html =
html.replaceFirst(
"<base href=\\\"[^\\\"]*\\\"\\s*/?>",
"<base href=\\\"" + baseUrl + "\\\" />");
// Inject context path as a global variable for API calls
String contextPathScript =
"<script>window.STIRLING_PDF_API_BASE_URL = '" + baseUrl + "';</script>";
html = html.replace("</head>", contextPathScript + "</head>");
return html;
}
}
@GetMapping(value = {"/", "/index.html"}, produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request)
throws IOException {
if (indexHtmlExists && cachedIndexHtml != null) {
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml);
}
// Fallback: process on each request (dev mode or cache failed)
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(processIndexHtml());
}
@GetMapping(
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request)
throws IOException {
return serveIndexHtml(request);
}
@GetMapping(
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
throws IOException {
return serveIndexHtml(request);
@GetMapping("/{path:^(?!api|static)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public String forwardNestedPaths() {
return "forward:/index.html";
}
}
@@ -0,0 +1,48 @@
package stirling.software.SPDF.controller.web;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import stirling.software.SPDF.service.SignatureService;
import stirling.software.common.service.UserServiceInterface;
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
@RequestMapping("/api/v1/general")
public class SignatureController {
private final SignatureService signatureService;
private final UserServiceInterface userService;
public SignatureController(
SignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService) {
this.signatureService = signatureService;
this.userService = userService;
}
@GetMapping("/sign/{fileName}")
public ResponseEntity<byte[]> getSignature(@PathVariable(name = "fileName") String fileName)
throws IOException {
String username = "NON_SECURITY_USER";
if (userService != null) {
username = userService.getCurrentUsername();
}
// Verify access permission
if (!signatureService.hasAccessToFile(username, fileName)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
byte[] imageBytes = signatureService.getSignatureBytes(username, fileName);
return ResponseEntity.ok()
.contentType( // Adjust based on file type
MediaType.IMAGE_JPEG)
.body(imageBytes);
}
}
@@ -1,84 +0,0 @@
package stirling.software.SPDF.controller.web;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.service.SharedSignatureService;
import stirling.software.common.service.PersonalSignatureServiceInterface;
import stirling.software.common.service.UserServiceInterface;
/**
* Unified signature image controller that works for both authenticated and unauthenticated users.
* Uses composition pattern: - Core SharedSignatureService (always available): reads shared
* signatures - PersonalSignatureService (proprietary, optional): reads personal signatures For
* authenticated signature management (save/delete), see proprietary SignatureController.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/general")
public class SignatureImageController {
private final SharedSignatureService sharedSignatureService;
private final PersonalSignatureServiceInterface personalSignatureService;
private final UserServiceInterface userService;
public SignatureImageController(
SharedSignatureService sharedSignatureService,
@Autowired(required = false) PersonalSignatureServiceInterface personalSignatureService,
@Autowired(required = false) UserServiceInterface userService) {
this.sharedSignatureService = sharedSignatureService;
this.personalSignatureService = personalSignatureService;
this.userService = userService;
}
/**
* Get a signature image (works for both authenticated and unauthenticated users). -
* Authenticated with proprietary: tries personal first, then shared - Unauthenticated or
* community: tries shared only
*/
@GetMapping("/signatures/{fileName}")
public ResponseEntity<byte[]> getSignature(@PathVariable(name = "fileName") String fileName) {
try {
byte[] imageBytes = null;
// If proprietary service available and user authenticated, try personal folder first
if (personalSignatureService != null && userService != null) {
try {
String username = userService.getCurrentUsername();
imageBytes =
personalSignatureService.getPersonalSignatureBytes(username, fileName);
} catch (Exception e) {
// Not found in personal folder or not authenticated, will try shared
log.debug("Personal signature not found, trying shared: {}", e.getMessage());
}
}
// If not found in personal (or no personal service), try shared
if (imageBytes == null) {
imageBytes = sharedSignatureService.getSharedSignatureBytes(fileName);
}
// Determine content type from file extension
MediaType contentType = MediaType.IMAGE_PNG; // Default
String lowerFileName = fileName.toLowerCase();
if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) {
contentType = MediaType.IMAGE_JPEG;
}
return ResponseEntity.ok().contentType(contentType).body(imageBytes);
} catch (IOException e) {
log.debug("Signature not found: {}", fileName);
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
}
@@ -1,18 +0,0 @@
package stirling.software.SPDF.model.api.signature;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
public class SavedSignatureRequest {
private String id;
private String label;
private String type; // "canvas", "image", "text"
private String scope; // "personal", "shared"
private String dataUrl; // For canvas and image types
private String signerName; // For text type
private String fontFamily; // For text type
private Integer fontSize; // For text type
private String textColor; // For text type
}
@@ -1,22 +0,0 @@
package stirling.software.SPDF.model.api.signature;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SavedSignatureResponse {
private String id;
private String label;
private String type; // "canvas", "image", "text"
private String scope; // "personal", "shared"
private String dataUrl; // For canvas and image types (or URL to fetch image)
private String signerName; // For text type
private String fontFamily; // For text type
private Integer fontSize; // For text type
private String textColor; // For text type
private Long createdAt;
private Long updatedAt;
}
@@ -1,308 +0,0 @@
package stirling.software.SPDF.service;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.SPDF.model.api.signature.SavedSignatureRequest;
import stirling.software.SPDF.model.api.signature.SavedSignatureResponse;
import stirling.software.common.configuration.InstallationPathConfig;
@Service
@Slf4j
public class SharedSignatureService {
private final String SIGNATURE_BASE_PATH;
private final String ALL_USERS_FOLDER = "ALL_USERS";
private final ObjectMapper objectMapper;
public SharedSignatureService() {
SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath();
this.objectMapper = new ObjectMapper();
}
public boolean hasAccessToFile(String username, String fileName) throws IOException {
validateFileName(fileName);
// Check if file exists in user's personal folder or ALL_USERS folder
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
return Files.exists(userPath) || Files.exists(allUsersPath);
}
public List<SignatureFile> getAvailableSignatures(String username) {
List<SignatureFile> signatures = new ArrayList<>();
// Get signatures from user's personal folder
if (StringUtils.hasText(username)) {
Path userFolder = Paths.get(SIGNATURE_BASE_PATH, username);
if (Files.exists(userFolder)) {
try {
signatures.addAll(getSignaturesFromFolder(userFolder, "Personal"));
} catch (IOException e) {
log.error("Error reading user signatures folder", e);
}
}
}
// Get signatures from ALL_USERS folder
Path allUsersFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
if (Files.exists(allUsersFolder)) {
try {
signatures.addAll(getSignaturesFromFolder(allUsersFolder, "Shared"));
} catch (IOException e) {
log.error("Error reading shared signatures folder", e);
}
}
return signatures;
}
private List<SignatureFile> getSignaturesFromFolder(Path folder, String category)
throws IOException {
try (Stream<Path> stream = Files.list(folder)) {
return stream.filter(this::isImageFile)
.map(path -> new SignatureFile(path.getFileName().toString(), category))
.toList();
}
}
/**
* Get a signature from the shared (ALL_USERS) folder. This is always available for both
* authenticated and unauthenticated users.
*/
public byte[] getSharedSignatureBytes(String fileName) throws IOException {
validateFileName(fileName);
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
if (!Files.exists(allUsersPath)) {
throw new FileNotFoundException("Shared signature file not found");
}
return Files.readAllBytes(allUsersPath);
}
private boolean isImageFile(Path path) {
String fileName = path.getFileName().toString().toLowerCase();
return fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") || fileName.endsWith(".png");
}
private void validateFileName(String fileName) {
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
throw new IllegalArgumentException("Invalid filename");
}
// Only allow alphanumeric, hyphen, underscore, and dot (for extensions)
if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) {
throw new IllegalArgumentException("Filename contains invalid characters");
}
}
private String validateAndNormalizeExtension(String extension) {
String normalized = extension.toLowerCase().trim();
// Whitelist only safe image extensions
if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) {
return normalized;
}
throw new IllegalArgumentException("Unsupported image extension: " + extension);
}
private void verifyPathWithinDirectory(Path resolvedPath, Path targetDirectory)
throws IOException {
Path canonicalTarget = targetDirectory.toAbsolutePath().normalize();
Path canonicalResolved = resolvedPath.toAbsolutePath().normalize();
if (!canonicalResolved.startsWith(canonicalTarget)) {
throw new IOException("Resolved path is outside the target directory");
}
}
/** Save a signature as image file */
public SavedSignatureResponse saveSignature(String username, SavedSignatureRequest request)
throws IOException {
validateFileName(request.getId());
// Determine folder based on scope
String scope = request.getScope();
if (scope == null || scope.isEmpty()) {
scope = "personal"; // Default to personal
}
String folderName = "shared".equals(scope) ? ALL_USERS_FOLDER : username;
Path targetFolder = Paths.get(SIGNATURE_BASE_PATH, folderName);
Files.createDirectories(targetFolder);
long timestamp = System.currentTimeMillis();
SavedSignatureResponse response = new SavedSignatureResponse();
response.setId(request.getId());
response.setLabel(request.getLabel());
response.setType(request.getType());
response.setScope(scope);
response.setCreatedAt(timestamp);
response.setUpdatedAt(timestamp);
// Extract and save image data
String dataUrl = request.getDataUrl();
if (dataUrl != null && dataUrl.startsWith("data:image/")) {
// Extract base64 data
String base64Data = dataUrl.substring(dataUrl.indexOf(",") + 1);
byte[] imageBytes = Base64.getDecoder().decode(base64Data);
// Determine and validate file extension from data URL
String mimeType = dataUrl.substring(dataUrl.indexOf(":") + 1, dataUrl.indexOf(";"));
String rawExtension = mimeType.substring(mimeType.indexOf("/") + 1);
String extension = validateAndNormalizeExtension(rawExtension);
// Save image file only
String imageFileName = request.getId() + "." + extension;
Path imagePath = targetFolder.resolve(imageFileName);
// Verify path is within target directory
verifyPathWithinDirectory(imagePath, targetFolder);
Files.write(
imagePath,
imageBytes,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
// Store reference to image file
response.setDataUrl("/api/v1/general/signatures/" + imageFileName);
}
log.info("Saved signature {} for user {}", request.getId(), username);
return response;
}
/** Get all saved signatures for a user */
public List<SavedSignatureResponse> getSavedSignatures(String username) throws IOException {
List<SavedSignatureResponse> signatures = new ArrayList<>();
// Load personal signatures
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
if (Files.exists(personalFolder)) {
try (Stream<Path> stream = Files.list(personalFolder)) {
stream.filter(this::isImageFile)
.forEach(
path -> {
try {
String fileName = path.getFileName().toString();
String id =
fileName.substring(0, fileName.lastIndexOf('.'));
SavedSignatureResponse sig = new SavedSignatureResponse();
sig.setId(id);
sig.setLabel(id); // Use ID as label
sig.setType("image"); // Default type
sig.setScope("personal");
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
sig.setCreatedAt(
Files.getLastModifiedTime(path).toMillis());
sig.setUpdatedAt(
Files.getLastModifiedTime(path).toMillis());
signatures.add(sig);
} catch (IOException e) {
log.error("Error reading signature file: " + path, e);
}
});
}
}
// Load shared signatures
Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
if (Files.exists(sharedFolder)) {
try (Stream<Path> stream = Files.list(sharedFolder)) {
stream.filter(this::isImageFile)
.forEach(
path -> {
try {
String fileName = path.getFileName().toString();
String id =
fileName.substring(0, fileName.lastIndexOf('.'));
SavedSignatureResponse sig = new SavedSignatureResponse();
sig.setId(id);
sig.setLabel(id); // Use ID as label
sig.setType("image"); // Default type
sig.setScope("shared");
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
sig.setCreatedAt(
Files.getLastModifiedTime(path).toMillis());
sig.setUpdatedAt(
Files.getLastModifiedTime(path).toMillis());
signatures.add(sig);
} catch (IOException e) {
log.error("Error reading signature file: " + path, e);
}
});
}
}
return signatures;
}
/** Delete a saved signature */
public void deleteSignature(String username, String signatureId) throws IOException {
validateFileName(signatureId);
// Try to find and delete image file in personal folder
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
boolean deleted = false;
if (Files.exists(personalFolder)) {
try (Stream<Path> stream = Files.list(personalFolder)) {
List<Path> matchingFiles =
stream.filter(
path ->
path.getFileName()
.toString()
.startsWith(signatureId + "."))
.toList();
for (Path file : matchingFiles) {
Files.delete(file);
deleted = true;
}
}
}
// Try shared folder if not found in personal
if (!deleted) {
Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
if (Files.exists(sharedFolder)) {
try (Stream<Path> stream = Files.list(sharedFolder)) {
List<Path> matchingFiles =
stream.filter(
path ->
path.getFileName()
.toString()
.startsWith(signatureId + "."))
.toList();
for (Path file : matchingFiles) {
Files.delete(file);
deleted = true;
}
}
}
}
if (!deleted) {
throw new FileNotFoundException("Signature not found");
}
log.info("Deleted signature {} for user {}", signatureId, username);
}
}
@@ -0,0 +1,107 @@
package stirling.software.SPDF.service;
import java.io.FileNotFoundException;
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.List;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.common.configuration.InstallationPathConfig;
@Service
@Slf4j
public class SignatureService {
private final String SIGNATURE_BASE_PATH;
private final String ALL_USERS_FOLDER = "ALL_USERS";
public SignatureService() {
SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath();
}
public boolean hasAccessToFile(String username, String fileName) throws IOException {
validateFileName(fileName);
// Check if file exists in user's personal folder or ALL_USERS folder
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
return Files.exists(userPath) || Files.exists(allUsersPath);
}
public List<SignatureFile> getAvailableSignatures(String username) {
List<SignatureFile> signatures = new ArrayList<>();
// Get signatures from user's personal folder
if (StringUtils.hasText(username)) {
Path userFolder = Paths.get(SIGNATURE_BASE_PATH, username);
if (Files.exists(userFolder)) {
try {
signatures.addAll(getSignaturesFromFolder(userFolder, "Personal"));
} catch (IOException e) {
log.error("Error reading user signatures folder", e);
}
}
}
// Get signatures from ALL_USERS folder
Path allUsersFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
if (Files.exists(allUsersFolder)) {
try {
signatures.addAll(getSignaturesFromFolder(allUsersFolder, "Shared"));
} catch (IOException e) {
log.error("Error reading shared signatures folder", e);
}
}
return signatures;
}
private List<SignatureFile> getSignaturesFromFolder(Path folder, String category)
throws IOException {
try (Stream<Path> stream = Files.list(folder)) {
return stream.filter(this::isImageFile)
.map(path -> new SignatureFile(path.getFileName().toString(), category))
.toList();
}
}
public byte[] getSignatureBytes(String username, String fileName) throws IOException {
validateFileName(fileName);
// First try user's personal folder
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
if (Files.exists(userPath)) {
return Files.readAllBytes(userPath);
}
// Then try ALL_USERS folder
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
if (Files.exists(allUsersPath)) {
return Files.readAllBytes(allUsersPath);
}
throw new FileNotFoundException("Signature file not found");
}
private boolean isImageFile(Path path) {
String fileName = path.getFileName().toString().toLowerCase();
return fileName.endsWith(".jpg")
|| fileName.endsWith(".jpeg")
|| fileName.endsWith(".png")
|| fileName.endsWith(".gif");
}
private void validateFileName(String fileName) {
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
throw new IllegalArgumentException("Invalid filename");
}
}
}
@@ -52,7 +52,7 @@ server.servlet.session.timeout:30m
springdoc.api-docs.path=/v1/api-docs
# Set the URL of the OpenAPI JSON for the Swagger UI
springdoc.swagger-ui.url=/v1/api-docs
springdoc.swagger-ui.path=/swagger-ui.html
springdoc.swagger-ui.path=/index.html
# Force OpenAPI 3.0 specification version
springdoc.api-docs.version=OPENAPI_3_0
posthog.api.key=phc_fiR65u5j6qmXTYL56MNrLZSWqLaDW74OrZH0Insd2xq
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -11,7 +11,7 @@
#############################################################################################################
security:
enableLogin: true # set to 'true' to enable login
enableLogin: false # set to 'true' to enable login
csrfDisabled: false # set to 'true' to disable CSRF protection (not recommended for production)
loginAttemptCount: 5 # lock user account after 5 tries; when using e.g. Fail2Ban you can deactivate the function with -1
loginResetTimeMinutes: 120 # lock account for 2 hours after x attempts
@@ -24,8 +24,8 @@
--cc-toggle-on-knob-bg: var(--cc-btn-primary-color);
--cc-toggle-off-knob-bg: var(--cc-btn-primary-color);
--cc-toggle-enabled-icon-color: var(--cc-btn-primary-color);
--cc-toggle-disabled-icon-color: var(--cc-btn-primary-color);
--cc-toggle-enabled-icon-color: var(--cc-btn-primary-color);
--cc-toggle-disabled-icon-color: var(--cc-btn-primary-color);
--cc-toggle-readonly-bg: var(--md-sys-color-surface);
--cc-toggle-readonly-knob-bg: var(--md-sys-color-outline);
@@ -34,10 +34,10 @@
--cc-section-category-border: var(--md-sys-color-outline);
--cc-cookie-category-block-bg: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-border: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-border: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-hover-bg: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-hover-border: var(--cc-btn-secondary-bg);
--cc-cookie-category-expanded-block-bg: var(--cc-btn-secondary-bg);
--cc-cookie-category-expanded-block-hover-bg: var(--cc-toggle-readonly-bg);
@@ -47,7 +47,7 @@
*/
--cc-footer-bg: var(--cc-bg);
--cc-footer-color: var(--cc-primary-color);
--cc-footer-border-color: var(--cc-bg);
--cc-footer-border-color: var(--cc-bg);
}
.cm__body{
max-width: 90% !important;
@@ -76,9 +76,4 @@
.cm__btns{
padding-top: 1rem !important;
}
}
/* Lower z-index so cookie banner appears behind onboarding modals */
#cc-main {
z-index: 100 !important;
}
}
@@ -23,7 +23,7 @@ import stirling.software.common.configuration.InstallationPathConfig;
class SignatureServiceTest {
@TempDir Path tempDir;
private SharedSignatureService signatureService;
private SignatureService signatureService;
private Path personalSignatureFolder;
private Path sharedSignatureFolder;
private final String ALL_USERS_FOLDER = "ALL_USERS";
@@ -53,7 +53,7 @@ class SignatureServiceTest {
.thenReturn(tempDir.toString());
// Initialize the service with our temp directory
signatureService = new SharedSignatureService();
signatureService = new SignatureService();
}
}
@@ -165,7 +165,7 @@ class SignatureServiceTest {
}
@Test
void testGetSharedSignatureBytes_SharedFile() throws IOException {
void testGetSignatureBytes_PersonalFile() throws IOException {
// Mock static method for each test
try (MockedStatic<InstallationPathConfig> mockedConfig =
mockStatic(InstallationPathConfig.class)) {
@@ -173,8 +173,28 @@ class SignatureServiceTest {
.when(InstallationPathConfig::getSignaturesPath)
.thenReturn(tempDir.toString());
// Test - core service only reads shared signatures
byte[] bytes = signatureService.getSharedSignatureBytes("shared.jpg");
// Test
byte[] bytes = signatureService.getSignatureBytes(TEST_USER, "personal.png");
// Verify
assertEquals(
"personal signature content",
new String(bytes),
"Should return the correct content for personal file");
}
}
@Test
void testGetSignatureBytes_SharedFile() throws IOException {
// Mock static method for each test
try (MockedStatic<InstallationPathConfig> mockedConfig =
mockStatic(InstallationPathConfig.class)) {
mockedConfig
.when(InstallationPathConfig::getSignaturesPath)
.thenReturn(tempDir.toString());
// Test
byte[] bytes = signatureService.getSignatureBytes(TEST_USER, "shared.jpg");
// Verify
assertEquals(
@@ -185,7 +205,7 @@ class SignatureServiceTest {
}
@Test
void testGetSharedSignatureBytes_FileNotFound() {
void testGetSignatureBytes_FileNotFound() {
// Mock static method for each test
try (MockedStatic<InstallationPathConfig> mockedConfig =
mockStatic(InstallationPathConfig.class)) {
@@ -196,13 +216,13 @@ class SignatureServiceTest {
// Test and verify
assertThrows(
FileNotFoundException.class,
() -> signatureService.getSharedSignatureBytes("nonexistent.png"),
() -> signatureService.getSignatureBytes(TEST_USER, "nonexistent.png"),
"Should throw exception for non-existent files");
}
}
@Test
void testGetSharedSignatureBytes_InvalidFileName() {
void testGetSignatureBytes_InvalidFileName() {
// Mock static method for each test
try (MockedStatic<InstallationPathConfig> mockedConfig =
mockStatic(InstallationPathConfig.class)) {
@@ -213,28 +233,11 @@ class SignatureServiceTest {
// Test and verify
assertThrows(
IllegalArgumentException.class,
() -> signatureService.getSharedSignatureBytes("../invalid.png"),
() -> signatureService.getSignatureBytes(TEST_USER, "../invalid.png"),
"Should throw exception for file names with directory traversal");
}
}
@Test
void testGetSharedSignatureBytes_CannotAccessPersonalFiles() {
// Mock static method for each test
try (MockedStatic<InstallationPathConfig> mockedConfig =
mockStatic(InstallationPathConfig.class)) {
mockedConfig
.when(InstallationPathConfig::getSignaturesPath)
.thenReturn(tempDir.toString());
// Test and verify - core service should NOT be able to read personal files
assertThrows(
FileNotFoundException.class,
() -> signatureService.getSharedSignatureBytes("personal.png"),
"Core service should not have access to personal signatures");
}
}
@Test
void testGetAvailableSignatures_EmptyUsername() throws IOException {
// Mock static method for each test
@@ -120,37 +120,6 @@ public class ProprietaryUIDataController {
// Add enableLogin flag so frontend doesn't need to call /app-config
data.setEnableLogin(securityProps.getEnableLogin());
// Check if this is first-time setup with default credentials
// The isFirstLogin flag captures: default username/password usage and unchanged state
boolean isFirstTimeSetup = false;
boolean showDefaultCredentials = false;
List<User> allUsers = userRepository.findAll();
List<User> realUsers =
allUsers.stream()
.filter(
user ->
!Role.INTERNAL_API_USER
.getRoleId()
.equals(user.getUsername()))
.toList();
long userCount = realUsers.size();
if (userCount == 0) {
isFirstTimeSetup = true;
showDefaultCredentials = true;
} else if (userCount == 1) {
Optional<User> adminUser = userRepository.findByUsernameIgnoreCase("admin");
if (adminUser.isPresent() && Boolean.TRUE.equals(adminUser.get().getIsFirstLogin())) {
isFirstTimeSetup = true;
showDefaultCredentials = true;
}
}
data.setFirstTimeSetup(isFirstTimeSetup);
data.setShowDefaultCredentials(showDefaultCredentials);
OAUTH2 oauth = securityProps.getOauth2();
if (oauth != null && oauth.getEnabled()) {
@@ -397,8 +366,7 @@ public class ProprietaryUIDataController {
Map<Long, Date> teamLastRequest = new HashMap<>();
for (Object[] result : teamActivities) {
Long teamId = (Long) result[0];
Instant instant = (Instant) result[1];
Date lastActivity = instant != null ? Date.from(instant) : null;
Date lastActivity = (Date) result[1];
teamLastRequest.put(teamId, lastActivity);
}
@@ -442,8 +410,7 @@ public class ProprietaryUIDataController {
Map<String, Date> userLastRequest = new HashMap<>();
for (Object[] result : userSessions) {
String username = (String) result[0];
Instant instant = (Instant) result[1];
Date lastRequest = instant != null ? Date.from(instant) : null;
Date lastRequest = (Date) result[1];
userLastRequest.put(username, lastRequest);
}
@@ -489,8 +456,6 @@ public class ProprietaryUIDataController {
private Map<String, String> providerList;
private String loginMethod;
private boolean altLogin;
private boolean firstTimeSetup;
private boolean showDefaultCredentials;
}
@Data
@@ -1,195 +0,0 @@
package stirling.software.proprietary.controller.api;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.UserApi;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.proprietary.model.api.signature.SavedSignatureRequest;
import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.service.SignatureService;
/**
* Controller for managing user signatures in proprietary/authenticated mode only. Requires user
* authentication and enforces per-user storage limits. All endpoints require authentication
* via @PreAuthorize("isAuthenticated()").
*/
@UserApi
@Slf4j
@RestController
@RequestMapping("/api/v1/proprietary/signatures")
@RequiredArgsConstructor
@PreAuthorize("isAuthenticated()")
public class SignatureController {
private final SignatureService signatureService;
private final UserService userService;
private static final String ALL_USERS_FOLDER = "ALL_USERS";
/**
* Save a new signature for the authenticated user. Enforces storage limits and authentication
* requirements.
*/
@PostMapping
public ResponseEntity<SavedSignatureResponse> saveSignature(
@RequestBody SavedSignatureRequest request) {
try {
String username = userService.getCurrentUsername();
// Validate request
if (request.getDataUrl() == null || request.getDataUrl().isEmpty()) {
log.warn("User {} attempted to save signature without dataUrl", username);
return ResponseEntity.badRequest().build();
}
SavedSignatureResponse response = signatureService.saveSignature(username, request);
log.info("User {} saved signature {}", username, request.getId());
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
log.warn("Invalid signature save request: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (IOException e) {
log.error("Failed to save signature", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* List all signatures accessible to the authenticated user. Includes both personal and shared
* signatures.
*/
@GetMapping
public ResponseEntity<List<SavedSignatureResponse>> listSignatures() {
try {
String username = userService.getCurrentUsername();
List<SavedSignatureResponse> signatures = signatureService.getSavedSignatures(username);
return ResponseEntity.ok(signatures);
} catch (IOException e) {
log.error("Failed to list signatures for user", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Update a signature label. Users can update labels for their own personal signatures and for
* shared signatures.
*/
@PostMapping("/{signatureId}/label")
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
public ResponseEntity<Void> updateSignatureLabel(
@PathVariable String signatureId, @RequestBody Map<String, String> body) {
try {
String username = userService.getCurrentUsername();
String newLabel = body.get("label");
if (newLabel == null || newLabel.trim().isEmpty()) {
log.warn("Invalid label update request");
return ResponseEntity.badRequest().build();
}
signatureService.updateSignatureLabel(username, signatureId, newLabel);
log.info("User {} updated label for signature {}", username, signatureId);
return ResponseEntity.noContent().build();
} catch (IOException e) {
log.warn("Failed to update signature label: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
/**
* Delete a signature owned by the authenticated user. Users can delete their own personal
* signatures. Admins can also delete shared signatures.
*/
@DeleteMapping("/{signatureId}")
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
public ResponseEntity<Void> deleteSignature(@PathVariable String signatureId) {
try {
String username = userService.getCurrentUsername();
boolean isAdmin = userService.isCurrentUserAdmin();
// Validate filename to prevent path traversal
if (signatureId.contains("..")
|| signatureId.contains("/")
|| signatureId.contains("\\")) {
log.warn("Invalid signature ID: {}", signatureId);
return ResponseEntity.badRequest().build();
}
// Try to delete from personal folder first
try {
signatureService.deleteSignature(username, signatureId);
log.info("User {} deleted personal signature {}", username, signatureId);
return ResponseEntity.noContent().build();
} catch (IOException e) {
// If not found in personal folder, check if it's in shared folder
if (isAdmin) {
// Admin can delete from shared folder
if (deleteFromSharedFolder(signatureId)) {
log.info("Admin {} deleted shared signature {}", username, signatureId);
return ResponseEntity.noContent().build();
}
}
// If not admin or not found in shared folder either, return 404
throw e;
}
} catch (IOException e) {
log.warn("Failed to delete signature {} for user: {}", signatureId, e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
/**
* Delete a signature from the shared (ALL_USERS) folder. Only admins should call this method.
*/
private boolean deleteFromSharedFolder(String signatureId) throws IOException {
String signatureBasePath = InstallationPathConfig.getSignaturesPath();
Path sharedFolder = Paths.get(signatureBasePath, ALL_USERS_FOLDER);
boolean deleted = false;
if (Files.exists(sharedFolder)) {
try (Stream<Path> stream = Files.list(sharedFolder)) {
List<Path> matchingFiles =
stream.filter(
path ->
path.getFileName()
.toString()
.startsWith(signatureId + "."))
.toList();
for (Path file : matchingFiles) {
Files.delete(file);
deleted = true;
log.info("Deleted shared signature file: {}", file);
}
}
// Also delete metadata file if it exists
Path metadataPath = sharedFolder.resolve(signatureId + ".json");
if (Files.exists(metadataPath)) {
Files.delete(metadataPath);
log.info("Deleted shared signature metadata: {}", metadataPath);
}
}
return deleted;
}
}
@@ -34,19 +34,7 @@ import stirling.software.proprietary.util.FormUtils;
@RestController
@RequestMapping("/api/v1/form")
@Tag(
name = "Forms",
description =
"""
Work with PDF form fields: read them, fill them, edit them, or remove them.
Treats a PDF as a structured form instead of just flat pages.
Typical uses:
• Inspect which form fields exist in a PDF
• Autofill forms from your own systems (e.g. CRM, ERP)
• Change or delete form fields before sending out a final, non-editable copy
• Unlock read-only form fields when you need to update them
""")
@Tag(name = "Forms", description = "PDF form APIs")
@RequiredArgsConstructor
public class FormFillController {
@@ -1,18 +0,0 @@
package stirling.software.proprietary.model.api.signature;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
public class SavedSignatureRequest {
private String id;
private String label;
private String type; // "canvas", "image", "text"
private String scope; // "personal", "shared"
private String dataUrl; // For canvas and image types
private String signerName; // For text type
private String fontFamily; // For text type
private Integer fontSize; // For text type
private String textColor; // For text type
}
@@ -1,22 +0,0 @@
package stirling.software.proprietary.model.api.signature;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SavedSignatureResponse {
private String id;
private String label;
private String type; // "canvas", "image", "text"
private String scope; // "personal", "shared"
private String dataUrl; // For canvas and image types (or URL to fetch image)
private String signerName; // For text type
private String fontFamily; // For text type
private Integer fontSize; // For text type
private String textColor; // For text type
private Long createdAt;
private Long updatedAt;
}
@@ -238,12 +238,6 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
path = "errorOAuth=oAuth2AutoCreateDisabled";
} else if (request.getParameter("oAuth2AdminBlockedUser") != null) {
path = "errorOAuth=oAuth2AdminBlockedUser";
} else if (request.getParameter("oAuth2RequiresLicense") != null) {
path = "errorOAuth=oAuth2RequiresLicense";
} else if (request.getParameter("saml2RequiresLicense") != null) {
path = "errorOAuth=saml2RequiresLicense";
} else if (request.getParameter("maxUsersReached") != null) {
path = "errorOAuth=maxUsersReached";
} else if (request.getParameter("userIsDisabled") != null) {
path = "errorOAuth=userIsDisabled";
} else if ((errorMessage = request.getParameter("error")) != null) {
@@ -62,7 +62,6 @@ public class InitialSecuritySetup {
private void initializeUserLicenseSettings() {
licenseSettingsService.initializeGrandfatheredCount();
licenseSettingsService.updateLicenseMaxUsers();
licenseSettingsService.grandfatherExistingOAuthUsers();
}
private void configureJWTSettings() {
@@ -23,7 +23,7 @@ public class PremiumEndpointAspect {
public Object checkPremiumAccess(ProceedingJoinPoint joinPoint) throws Throwable {
if (!runningProOrHigher) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN, "This endpoint requires a Server or Enterprise license");
HttpStatus.FORBIDDEN, "This endpoint requires a Pro or higher license");
}
return joinPoint.proceed();
}
@@ -35,40 +35,15 @@ public class MailConfig {
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
mailSender.setHost(mailProperties.getHost());
mailSender.setPort(mailProperties.getPort());
mailSender.setUsername(mailProperties.getUsername());
mailSender.setPassword(mailProperties.getPassword());
mailSender.setDefaultEncoding("UTF-8");
// Only set username and password if they are provided
String username = mailProperties.getUsername();
String password = mailProperties.getPassword();
boolean hasCredentials =
(username != null && !username.trim().isEmpty())
|| (password != null && !password.trim().isEmpty());
if (username != null && !username.trim().isEmpty()) {
mailSender.setUsername(username);
log.info("SMTP username configured");
} else {
log.info("SMTP username not configured - using anonymous connection");
}
if (password != null && !password.trim().isEmpty()) {
mailSender.setPassword(password);
log.info("SMTP password configured");
} else {
log.info("SMTP password not configured");
}
// Retrieves the JavaMail properties to configure additional SMTP parameters
Properties props = mailSender.getJavaMailProperties();
// Only enable SMTP authentication if credentials are provided
if (hasCredentials) {
props.put("mail.smtp.auth", "true");
log.info("SMTP authentication enabled");
} else {
props.put("mail.smtp.auth", "false");
log.info("SMTP authentication disabled - no credentials provided");
}
// Enables SMTP authentication
props.put("mail.smtp.auth", "true");
// Enables STARTTLS to encrypt the connection if supported by the SMTP server
props.put("mail.smtp.starttls.enable", "true");
@@ -84,8 +84,6 @@ public class SecurityConfiguration {
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations;
private final OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver;
private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService;
public SecurityConfiguration(
PersistentLoginRepository persistentLoginRepository,
@@ -105,9 +103,7 @@ public class SecurityConfiguration {
@Autowired(required = false)
RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations,
@Autowired(required = false)
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver,
stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService) {
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver) {
this.userDetailsService = userDetailsService;
this.userService = userService;
this.loginEnabledValue = loginEnabledValue;
@@ -124,11 +120,10 @@ public class SecurityConfiguration {
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations;
this.saml2AuthenticationRequestResolver = saml2AuthenticationRequestResolver;
this.licenseSettingsService = licenseSettingsService;
}
@Bean
public static PasswordEncoder passwordEncoder() {
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@@ -324,14 +319,10 @@ public class SecurityConfiguration {
.authenticated());
// Handle User/Password Logins
if (securityProperties.isUserPass()) {
// v2: Authentication is handled via API (/api/v1/auth/login), not form login
// We configure form login to handle Spring Security redirects,
// but use /perform_login as the processing URL so /login remains a React route
http.formLogin(
formLogin ->
formLogin
.loginPage("/login") // Redirect here when unauthenticated
.loginProcessingUrl("/perform_login") // Process form posts here (not /login)
.loginPage("/login")
.successHandler(
new CustomAuthenticationSuccessHandler(
loginAttemptService,
@@ -363,8 +354,7 @@ public class SecurityConfiguration {
loginAttemptService,
securityProperties.getOauth2(),
userService,
jwtService,
licenseSettingsService))
jwtService))
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
// Add existing Authorities from the database
.userInfoEndpoint(
@@ -405,8 +395,7 @@ public class SecurityConfiguration {
loginAttemptService,
securityProperties.getSaml2(),
userService,
jwtService,
licenseSettingsService))
jwtService))
.failureHandler(
new CustomSaml2AuthenticationFailureHandler())
.authenticationRequestResolver(
@@ -32,8 +32,7 @@ public class EEAppConfig {
@Bean(name = "runningProOrHigher")
@Primary
public boolean runningProOrHigher() {
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
return license == License.SERVER || license == License.ENTERPRISE;
return licenseKeyChecker.getPremiumLicenseEnabledResult() != License.NORMAL;
}
@Profile("security")
@@ -30,7 +30,7 @@ public class KeygenLicenseVerifier {
public enum License {
NORMAL,
SERVER,
PRO,
ENTERPRISE
}
@@ -76,7 +76,7 @@ public class KeygenLicenseVerifier {
log.info("Detected certificate-based license. Processing...");
boolean isValid = verifyCertificateLicense(licenseKeyOrCert, context);
if (isValid) {
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.SERVER;
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.PRO;
} else {
license = License.NORMAL;
}
@@ -84,7 +84,7 @@ public class KeygenLicenseVerifier {
log.info("Detected JWT-style license key. Processing...");
boolean isValid = verifyJWTLicense(licenseKeyOrCert, context);
if (isValid) {
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.SERVER;
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.PRO;
} else {
license = License.NORMAL;
}
@@ -92,7 +92,7 @@ public class KeygenLicenseVerifier {
log.info("Detected standard license key. Processing...");
boolean isValid = verifyStandardLicense(licenseKeyOrCert, context);
if (isValid) {
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.SERVER;
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.PRO;
} else {
license = License.NORMAL;
}
@@ -261,23 +261,10 @@ public class KeygenLicenseVerifier {
// Extract metadata
JSONObject metadataObj = attributesObj.optJSONObject("metadata");
if (metadataObj != null) {
// Check if this is an old license (no planType) with isEnterprise flag
context.isEnterpriseLicense = metadataObj.optBoolean("isEnterprise", false);
// Extract user count - default based on license type
// Old licenses: Only had isEnterprise flag
// New licenses: Have planType field with "server" or "enterprise"
int users = metadataObj.optInt("users", context.isEnterpriseLicense ? 1 : 0);
// SERVER license (isEnterprise=false, users=0) = unlimited
// ENTERPRISE license (isEnterprise=true, users>0) = limited seats
if (!context.isEnterpriseLicense && users == 0) {
log.info("SERVER license detected with unlimited users");
} else if (context.isEnterpriseLicense && users > 0) {
log.info("ENTERPRISE license allows for {} users", users);
}
int users = metadataObj.optInt("users", 1);
applicationProperties.getPremium().setMaxUsers(users);
log.info("License allows for {} users", users);
context.isEnterpriseLicense = metadataObj.optBoolean("isEnterprise", false);
}
// Check license status if available
@@ -444,31 +431,28 @@ public class KeygenLicenseVerifier {
}
// Extract max users and isEnterprise from policy or metadata
int users = policyObj.optInt("users", 1);
context.isEnterpriseLicense = policyObj.optBoolean("isEnterprise", false);
int users = policyObj.optInt("users", -1);
if (users == -1) {
// Try to get users from metadata if not at policy level
if (users > 0) {
applicationProperties.getPremium().setMaxUsers(users);
log.info("License allows for {} users", users);
} else {
// Try to get users from metadata if present
Object metadataObj = policyObj.opt("metadata");
if (metadataObj instanceof JSONObject metadata) {
context.isEnterpriseLicense =
metadata.optBoolean("isEnterprise", context.isEnterpriseLicense);
users = metadata.optInt("users", context.isEnterpriseLicense ? 1 : 0);
users = metadata.optInt("users", 1);
applicationProperties.getPremium().setMaxUsers(users);
log.info("License allows for {} users (from metadata)", users);
// Check for isEnterprise flag in metadata
context.isEnterpriseLicense = metadata.optBoolean("isEnterprise", false);
} else {
// Default based on license type
users = context.isEnterpriseLicense ? 1 : 0;
// Default value
applicationProperties.getPremium().setMaxUsers(1);
log.info("Using default of 1 user for license");
}
}
// SERVER license (isEnterprise=false, users=0) = unlimited
// ENTERPRISE license (isEnterprise=true, users>0) = limited seats
if (!context.isEnterpriseLicense && users == 0) {
log.info("SERVER license detected with unlimited users");
} else if (context.isEnterpriseLicense && users > 0) {
log.info("ENTERPRISE license allows for {} users", users);
}
applicationProperties.getPremium().setMaxUsers(users);
}
return true;
@@ -600,7 +584,17 @@ public class KeygenLicenseVerifier {
context.maxMachines);
}
// Extract isEnterprise flag first
// Extract user count, default to 1 if not specified
int users =
jsonResponse
.path("data")
.path("attributes")
.path("metadata")
.path("users")
.asInt(1);
applicationProperties.getPremium().setMaxUsers(users);
// Extract isEnterprise flag
context.isEnterpriseLicense =
jsonResponse
.path("data")
@@ -609,24 +603,6 @@ public class KeygenLicenseVerifier {
.path("isEnterprise")
.asBoolean(false);
// Extract user count - default based on license type
int users =
jsonResponse
.path("data")
.path("attributes")
.path("metadata")
.path("users")
.asInt(context.isEnterpriseLicense ? 1 : 0);
// SERVER license (isEnterprise=false, users=0) = unlimited
// ENTERPRISE license (isEnterprise=true, users>0) = limited seats
if (!context.isEnterpriseLicense && users == 0) {
log.info("SERVER license detected with unlimited users");
} else if (context.isEnterpriseLicense && users > 0) {
log.info("ENTERPRISE license allows for {} users", users);
}
applicationProperties.getPremium().setMaxUsers(users);
log.debug(applicationProperties.toString());
} else {
@@ -68,8 +68,8 @@ public class LicenseKeyChecker {
premiumEnabledResult = licenseService.verifyLicense(licenseKey);
if (License.ENTERPRISE == premiumEnabledResult) {
log.info("License key is Enterprise.");
} else if (License.SERVER == premiumEnabledResult) {
log.info("License key is Server.");
} else if (License.PRO == premiumEnabledResult) {
log.info("License key is Pro.");
} else {
log.info("License key is invalid, defaulting to non pro license.");
}
@@ -1,32 +1,22 @@
package stirling.software.proprietary.security.controller.api;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.GeneralUtils;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier;
@@ -252,159 +242,4 @@ public class AdminLicenseController {
.body(Map.of("error", "Failed to retrieve license information"));
}
}
/**
* Upload a license certificate file for offline activation. Accepts .lic or .cert files,
* validates the certificate format, saves to configs directory, and activates the license.
*
* @param file The license certificate file to upload
* @return Response with success status, license type, and file information
*/
@PostMapping(value = "/license-file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Upload license certificate file",
description =
"Upload a license certificate file (.lic, .cert) for offline activation."
+ " Validates the file format and activates the license.")
public ResponseEntity<Map<String, Object>> uploadLicenseFile(
@RequestParam("file") MultipartFile file) {
// Validate file exists
if (file == null || file.isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "File is empty"));
}
String filename = file.getOriginalFilename();
if (filename == null || filename.trim().isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "Invalid filename"));
}
// Prevent path traversal and enforce single filename component
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
return ResponseEntity.badRequest()
.body(
Map.of(
"success",
false,
"error",
"Filename must not contain path separators or '..'"));
}
// Validate file extension
if (!isValidLicenseFile(filename)) {
return ResponseEntity.badRequest()
.body(
Map.of(
"success",
false,
"error",
"Invalid file type. Expected .lic or .cert"));
}
// Check file size (max 1MB for license files)
if (file.getSize() > 1_048_576) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "File too large. Maximum 1MB allowed"));
}
try {
// Validate certificate format by reading content
byte[] fileBytes = file.getBytes();
String content = new String(fileBytes, StandardCharsets.UTF_8);
if (!content.trim().startsWith("-----BEGIN LICENSE FILE-----")) {
return ResponseEntity.badRequest()
.body(
Map.of(
"success",
false,
"error",
"Invalid license certificate format"));
}
// Get config directory and target path
Path configPath = Paths.get(InstallationPathConfig.getConfigPath());
Path targetPath = configPath.resolve(filename).normalize();
// Prevent directory traversal: ensure targetPath is inside configPath
if (!targetPath.startsWith(configPath.normalize().toAbsolutePath())) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "Invalid file path"));
}
// Backup existing file if present
if (Files.exists(targetPath)) {
Path backupDir = configPath.resolve("backup");
Files.createDirectories(backupDir);
String backupFilename = filename + ".bak." + System.currentTimeMillis();
Path backupPath = backupDir.resolve(backupFilename);
Files.copy(targetPath, backupPath, StandardCopyOption.REPLACE_EXISTING);
log.info("Backed up existing license file to: {}", backupPath);
}
// Write new license file
Files.write(targetPath, fileBytes);
log.info("License file saved to: {}", targetPath);
// assume premium enabled when setting license key
applicationProperties.getPremium().setEnabled(true);
// Update settings with file reference (relative path)
String fileReference = "file:configs/" + filename;
licenseKeyChecker.updateLicenseKey(fileReference);
// Get license status after activation
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("licenseType", license.name());
response.put("filename", filename);
response.put("filePath", "configs/" + filename);
response.put("enabled", applicationProperties.getPremium().isEnabled());
response.put("maxUsers", applicationProperties.getPremium().getMaxUsers());
response.put("message", "License file uploaded and activated");
log.info(
"License file uploaded and activated: filename={}, type={}",
filename,
license.name());
return ResponseEntity.ok(response);
} catch (IOException e) {
log.error("Failed to save license file", e);
return ResponseEntity.internalServerError()
.body(
Map.of(
"success",
false,
"error",
"Failed to save license file: " + e.getMessage()));
} catch (Exception e) {
log.error("Failed to activate license from file", e);
return ResponseEntity.badRequest()
.body(
Map.of(
"success",
false,
"error",
"Failed to activate license: " + e.getMessage()));
}
}
/**
* Validates if the filename has a valid license file extension (.lic or .cert)
*
* @param filename The filename to validate
* @return true if the filename ends with .lic or .cert (case-insensitive)
*/
private boolean isValidLicenseFile(String filename) {
if (filename == null) {
return false;
}
String lower = filename.toLowerCase();
return lower.endsWith(".lic") || lower.endsWith(".cert");
}
}

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