mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a05659c878 | ||
|
|
43eaa84a8f | ||
|
|
2cd4175689 | ||
|
|
d6a83fe6a1 | ||
|
|
3c92cb7c2b | ||
|
|
0076acb2ba | ||
|
|
92a3465ae1 | ||
|
|
99c0fda4da | ||
|
|
ac96335ef0 | ||
|
|
b83888c74a | ||
|
|
787d0d21c9 | ||
|
|
7b26b184d1 | ||
|
|
f17ad56def | ||
|
|
de438d00e1 | ||
|
|
6787169583 | ||
|
|
291e1a392b | ||
|
|
7c5aa3685f | ||
|
|
9c03914edd | ||
|
|
c980ee10c0 | ||
|
|
fa4d2bc09a | ||
|
|
7faf7e50fa | ||
|
|
bb201ef9c1 | ||
|
|
82dbcfbb9b | ||
|
|
9fd8fd89ed | ||
|
|
3a2370ea1f | ||
|
|
e7db714091 | ||
|
|
c6b4a2b141 | ||
|
|
7459463a3c | ||
|
|
c9bf436895 | ||
|
|
f8dbf171e1 | ||
|
|
e59c717dc0 | ||
|
|
f2bffe2dc6 | ||
|
|
5d827df08c | ||
|
|
bdb3c887f3 | ||
|
|
f902e8aca9 | ||
|
|
65a3eeca76 | ||
|
|
f72538d30f | ||
|
|
88c5fb46ad | ||
|
|
8e2f9546a5 | ||
|
|
f2f4bd5230 | ||
|
|
f3cc30d0c2 | ||
|
|
ba7c75aff4 | ||
|
|
a53d73ef51 | ||
|
|
c2a63cf425 | ||
|
|
c3456adc2b | ||
|
|
179b569769 | ||
|
|
341adaa07d | ||
|
|
feebfe82fa | ||
|
|
1e72416d55 | ||
|
|
959d14f075 | ||
|
|
8f6fcee428 | ||
|
|
651f17f1c6 | ||
|
|
fde449e738 | ||
|
|
85e9121745 | ||
|
|
12f1fd485e | ||
|
|
b49e8a2355 | ||
|
|
d908bc6785 | ||
|
|
4ae79d92ae | ||
|
|
85d9b5b83d |
@@ -5,6 +5,7 @@ frontend/dist
|
||||
frontend/build
|
||||
frontend/.vite
|
||||
frontend/.tauri
|
||||
frontend/src-tauri/target
|
||||
|
||||
# Gradle build artifacts
|
||||
.gradle
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# CI Configuration
|
||||
|
||||
## CI Lite Mode
|
||||
|
||||
Skip non-essential CI workflows by setting a repository variable:
|
||||
|
||||
**Settings → Secrets and variables → Actions → Variables → New repository variable**
|
||||
|
||||
- Name: `CI_PROFILE`
|
||||
- Value: `lite`
|
||||
|
||||
Skips resource-intensive builds, releases, and OSS-specific workflows. Useful for deployment-only forks or faster CI runs.
|
||||
@@ -1,403 +0,0 @@
|
||||
"""
|
||||
Author: Ludy87
|
||||
Description: This script processes .properties files for localization checks. It compares translation files in a branch with
|
||||
a reference file to ensure consistency. The script performs two main checks:
|
||||
1. Verifies that the number of lines (including comments and empty lines) in the translation files matches the reference file.
|
||||
2. Ensures that all keys in the translation files are present in the reference file and vice versa.
|
||||
|
||||
The script also provides functionality to update the translation files to match the reference file by adding missing keys and
|
||||
adjusting the format.
|
||||
|
||||
Usage:
|
||||
python check_language_properties.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
|
||||
"""
|
||||
# Sample for Windows:
|
||||
# python .github/scripts/check_language_properties.py --reference-file src\main\resources\messages_en_GB.properties --branch "" --files src\main\resources\messages_de_DE.properties src\main\resources\messages_uk_UA.properties
|
||||
|
||||
import copy
|
||||
import glob
|
||||
import os
|
||||
import argparse
|
||||
import re
|
||||
|
||||
|
||||
def find_duplicate_keys(file_path):
|
||||
"""
|
||||
Identifies duplicate keys in a .properties file.
|
||||
:param file_path: Path to the .properties file.
|
||||
:return: List of tuples (key, first_occurrence_line, duplicate_line).
|
||||
"""
|
||||
keys = {}
|
||||
duplicates = []
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
for line_number, line in enumerate(file, start=1):
|
||||
stripped_line = line.strip()
|
||||
|
||||
# Skip empty lines and comments
|
||||
if not stripped_line or stripped_line.startswith("#"):
|
||||
continue
|
||||
|
||||
# Split the line into key and value
|
||||
if "=" in stripped_line:
|
||||
key, _ = stripped_line.split("=", 1)
|
||||
key = key.strip()
|
||||
|
||||
# Check if the key already exists
|
||||
if key in keys:
|
||||
duplicates.append((key, keys[key], line_number))
|
||||
else:
|
||||
keys[key] = line_number
|
||||
|
||||
return duplicates
|
||||
|
||||
|
||||
# Maximum size for properties files (e.g., 200 KB)
|
||||
MAX_FILE_SIZE = 200 * 1024
|
||||
|
||||
|
||||
def parse_properties_file(file_path):
|
||||
"""
|
||||
Parses a .properties file and returns a structured list of its contents.
|
||||
:param file_path: Path to the .properties file.
|
||||
:return: List of dictionaries representing each line in the file.
|
||||
"""
|
||||
properties_list = []
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
for line_number, line in enumerate(file, start=1):
|
||||
stripped_line = line.strip()
|
||||
|
||||
# Handle empty lines
|
||||
if not stripped_line:
|
||||
properties_list.append(
|
||||
{"line_number": line_number, "type": "empty", "content": ""}
|
||||
)
|
||||
continue
|
||||
|
||||
# Handle comments
|
||||
if stripped_line.startswith("#"):
|
||||
properties_list.append(
|
||||
{
|
||||
"line_number": line_number,
|
||||
"type": "comment",
|
||||
"content": stripped_line,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Handle key-value pairs
|
||||
match = re.match(r"^([^=]+)=(.*)$", line)
|
||||
if match:
|
||||
key, value = match.groups()
|
||||
properties_list.append(
|
||||
{
|
||||
"line_number": line_number,
|
||||
"type": "entry",
|
||||
"key": key.strip(),
|
||||
"value": value.strip(),
|
||||
}
|
||||
)
|
||||
|
||||
return properties_list
|
||||
|
||||
|
||||
def write_json_file(file_path, updated_properties):
|
||||
"""
|
||||
Writes updated properties back to the file in their original format.
|
||||
:param file_path: Path to the .properties file.
|
||||
:param updated_properties: List of updated properties to write.
|
||||
"""
|
||||
updated_lines = {entry["line_number"]: entry for entry in updated_properties}
|
||||
|
||||
# Sort lines by their numbers and retain comments and empty lines
|
||||
all_lines = sorted(set(updated_lines.keys()))
|
||||
|
||||
original_format = []
|
||||
for line in all_lines:
|
||||
if line in updated_lines:
|
||||
entry = updated_lines[line]
|
||||
else:
|
||||
entry = None
|
||||
ref_entry = updated_lines[line]
|
||||
if ref_entry["type"] in ["comment", "empty"]:
|
||||
original_format.append(ref_entry)
|
||||
elif entry is None:
|
||||
# Add missing entries from the reference file
|
||||
original_format.append(ref_entry)
|
||||
elif entry["type"] == "entry":
|
||||
# Replace entries with those from the current JSON
|
||||
original_format.append(entry)
|
||||
|
||||
# Write the updated content back to the file
|
||||
with open(file_path, "w", encoding="utf-8", newline="\n") as file:
|
||||
for entry in original_format:
|
||||
if entry["type"] == "comment":
|
||||
file.write(f"{entry['content']}\n")
|
||||
elif entry["type"] == "empty":
|
||||
file.write(f"{entry['content']}\n")
|
||||
elif entry["type"] == "entry":
|
||||
file.write(f"{entry['key']}={entry['value']}\n")
|
||||
|
||||
|
||||
def update_missing_keys(reference_file, file_list, branch=""):
|
||||
"""
|
||||
Updates missing keys in the translation files based on the reference file.
|
||||
:param reference_file: Path to the reference .properties file.
|
||||
:param file_list: List of translation files to update.
|
||||
:param branch: Branch where the files are located.
|
||||
"""
|
||||
reference_properties = parse_properties_file(reference_file)
|
||||
for file_path in file_list:
|
||||
basename_current_file = os.path.basename(os.path.join(branch, file_path))
|
||||
if (
|
||||
basename_current_file == os.path.basename(reference_file)
|
||||
or not file_path.endswith(".properties")
|
||||
or not basename_current_file.startswith("messages_")
|
||||
):
|
||||
continue
|
||||
|
||||
current_properties = parse_properties_file(os.path.join(branch, file_path))
|
||||
updated_properties = []
|
||||
for ref_entry in reference_properties:
|
||||
ref_entry_copy = copy.deepcopy(ref_entry)
|
||||
for current_entry in current_properties:
|
||||
if current_entry["type"] == "entry":
|
||||
if ref_entry_copy["type"] != "entry":
|
||||
continue
|
||||
if ref_entry_copy["key"].lower() == current_entry["key"].lower():
|
||||
ref_entry_copy["value"] = current_entry["value"]
|
||||
updated_properties.append(ref_entry_copy)
|
||||
write_json_file(os.path.join(branch, file_path), updated_properties)
|
||||
|
||||
|
||||
def check_for_missing_keys(reference_file, file_list, branch):
|
||||
update_missing_keys(reference_file, file_list, branch)
|
||||
|
||||
|
||||
def read_properties(file_path):
|
||||
if os.path.isfile(file_path) and os.path.exists(file_path):
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
return file.read().splitlines()
|
||||
return [""]
|
||||
|
||||
|
||||
def check_for_differences(reference_file, file_list, branch, actor):
|
||||
reference_branch = reference_file.split("/")[0]
|
||||
basename_reference_file = os.path.basename(reference_file)
|
||||
|
||||
report = []
|
||||
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
|
||||
reference_lines = read_properties(reference_file)
|
||||
has_differences = False
|
||||
|
||||
only_reference_file = True
|
||||
|
||||
file_arr = file_list
|
||||
|
||||
if len(file_list) == 1:
|
||||
file_arr = file_list[0].split()
|
||||
base_dir = os.path.abspath(
|
||||
os.path.join(os.getcwd(), "app", "core", "src", "main", "resources")
|
||||
)
|
||||
|
||||
for file_path in file_arr:
|
||||
file_normpath = os.path.normpath(file_path)
|
||||
absolute_path = os.path.abspath(file_normpath)
|
||||
# Verify that file is within the expected directory
|
||||
if not absolute_path.startswith(base_dir):
|
||||
raise ValueError(f"Unsafe file found: {file_normpath}")
|
||||
# Verify file size before processing
|
||||
if os.path.getsize(os.path.join(branch, file_normpath)) > MAX_FILE_SIZE:
|
||||
raise ValueError(
|
||||
f"The file {file_normpath} is too large and could pose a security risk."
|
||||
)
|
||||
|
||||
basename_current_file = os.path.basename(os.path.join(branch, file_normpath))
|
||||
if (
|
||||
basename_current_file == basename_reference_file
|
||||
or (
|
||||
# only local windows command
|
||||
not file_normpath.startswith(
|
||||
os.path.join(
|
||||
"", "app", "core", "src", "main", "resources", "messages_"
|
||||
)
|
||||
)
|
||||
and not file_normpath.startswith(
|
||||
os.path.join(
|
||||
os.getcwd(),
|
||||
"app",
|
||||
"core",
|
||||
"src",
|
||||
"main",
|
||||
"resources",
|
||||
"messages_",
|
||||
)
|
||||
)
|
||||
)
|
||||
or not file_normpath.endswith(".properties")
|
||||
or not basename_current_file.startswith("messages_")
|
||||
):
|
||||
continue
|
||||
only_reference_file = False
|
||||
report.append(f"#### 📃 **File Check:** `{basename_current_file}`")
|
||||
current_lines = read_properties(os.path.join(branch, file_path))
|
||||
reference_line_count = len(reference_lines)
|
||||
current_line_count = len(current_lines)
|
||||
|
||||
if reference_line_count != current_line_count:
|
||||
report.append("")
|
||||
report.append("1. **Test Status:** ❌ **_Failed_**")
|
||||
report.append(" - **Issue:**")
|
||||
has_differences = True
|
||||
if reference_line_count > current_line_count:
|
||||
report.append(
|
||||
f" - **_Mismatched line count_**: {reference_line_count} (reference) vs {current_line_count} (current). Comments, empty lines, or translation strings are missing."
|
||||
)
|
||||
elif reference_line_count < current_line_count:
|
||||
report.append(
|
||||
f" - **_Too many lines_**: {reference_line_count} (reference) vs {current_line_count} (current). Please verify if there is an additional line that needs to be removed."
|
||||
)
|
||||
else:
|
||||
report.append("1. **Test Status:** ✅ **_Passed_**")
|
||||
|
||||
# Check for missing or extra keys
|
||||
current_keys = []
|
||||
reference_keys = []
|
||||
for line in current_lines:
|
||||
if not line.startswith("#") and line != "" and "=" in line:
|
||||
key, _ = line.split("=", 1)
|
||||
current_keys.append(key)
|
||||
for line in reference_lines:
|
||||
if not line.startswith("#") and line != "" and "=" in line:
|
||||
key, _ = line.split("=", 1)
|
||||
reference_keys.append(key)
|
||||
|
||||
current_keys_set = set(current_keys)
|
||||
reference_keys_set = set(reference_keys)
|
||||
missing_keys = current_keys_set.difference(reference_keys_set)
|
||||
extra_keys = reference_keys_set.difference(current_keys_set)
|
||||
missing_keys_list = list(missing_keys)
|
||||
extra_keys_list = list(extra_keys)
|
||||
|
||||
if missing_keys_list or extra_keys_list:
|
||||
has_differences = True
|
||||
missing_keys_str = "`, `".join(missing_keys_list)
|
||||
extra_keys_str = "`, `".join(extra_keys_list)
|
||||
report.append("2. **Test Status:** ❌ **_Failed_**")
|
||||
report.append(" - **Issue:**")
|
||||
if missing_keys_list:
|
||||
spaces_keys_list = []
|
||||
for key in missing_keys_list:
|
||||
if " " in key:
|
||||
spaces_keys_list.append(key)
|
||||
if spaces_keys_list:
|
||||
spaces_keys_str = "`, `".join(spaces_keys_list)
|
||||
report.append(
|
||||
f" - **_Keys containing unnecessary spaces_**: `{spaces_keys_str}`!"
|
||||
)
|
||||
report.append(
|
||||
f" - **_Extra keys in `{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**."
|
||||
)
|
||||
if extra_keys_list:
|
||||
report.append(
|
||||
f" - **_Missing keys in `{basename_reference_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_current_file}`_**."
|
||||
)
|
||||
else:
|
||||
report.append("2. **Test Status:** ✅ **_Passed_**")
|
||||
|
||||
if find_duplicate_keys(os.path.join(branch, file_normpath)):
|
||||
has_differences = True
|
||||
output = "\n".join(
|
||||
[
|
||||
f" - `{key}`: first at line {first}, duplicate at `line {duplicate}`"
|
||||
for key, first, duplicate in find_duplicate_keys(
|
||||
os.path.join(branch, file_normpath)
|
||||
)
|
||||
]
|
||||
)
|
||||
report.append("3. **Test Status:** ❌ **_Failed_**")
|
||||
report.append(" - **Issue:**")
|
||||
report.append(" - duplicate entries were found:")
|
||||
report.append(output)
|
||||
else:
|
||||
report.append("3. **Test Status:** ✅ **_Passed_**")
|
||||
|
||||
report.append("")
|
||||
report.append("---")
|
||||
report.append("")
|
||||
if has_differences:
|
||||
report.append("## ❌ Overall Check Status: **_Failed_**")
|
||||
report.append("")
|
||||
report.append(
|
||||
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/messages_en_GB.properties)"
|
||||
)
|
||||
else:
|
||||
report.append("## ✅ Overall Check Status: **_Success_**")
|
||||
report.append("")
|
||||
report.append(
|
||||
f"Thanks @{actor} for your help in keeping the translations up to date."
|
||||
)
|
||||
|
||||
if not only_reference_file:
|
||||
print("\n".join(report))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Find missing keys")
|
||||
parser.add_argument(
|
||||
"--actor",
|
||||
required=False,
|
||||
help="Actor from PR.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reference-file",
|
||||
required=True,
|
||||
help="Path to the reference file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--branch",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Branch name.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check-file",
|
||||
type=str,
|
||||
required=False,
|
||||
help="List of changed files, separated by spaces.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--files",
|
||||
nargs="+",
|
||||
required=False,
|
||||
help="List of changed files, separated by spaces.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Sanitize --actor input to avoid injection attacks
|
||||
if args.actor:
|
||||
args.actor = re.sub(r"[^a-zA-Z0-9_\\-]", "", args.actor)
|
||||
|
||||
# Sanitize --branch input to avoid injection attacks
|
||||
if args.branch:
|
||||
args.branch = re.sub(r"[^a-zA-Z0-9\\-]", "", args.branch)
|
||||
|
||||
file_list = args.files
|
||||
if file_list is None:
|
||||
if args.check_file:
|
||||
file_list = [args.check_file]
|
||||
else:
|
||||
file_list = glob.glob(
|
||||
os.path.join(
|
||||
os.getcwd(),
|
||||
"app",
|
||||
"core",
|
||||
"src",
|
||||
"main",
|
||||
"resources",
|
||||
"messages_*.properties",
|
||||
)
|
||||
)
|
||||
update_missing_keys(args.reference_file, file_list)
|
||||
else:
|
||||
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
Author: Ludy87
|
||||
Description: This script processes JSON translation files for localization checks. It compares translation files in a branch with
|
||||
Description: This script processes TOML 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_json.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
|
||||
python check_language_toml.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
|
||||
"""
|
||||
# Sample for Windows:
|
||||
# python .github/scripts/check_language_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
|
||||
# python .github/scripts/check_language_toml.py --reference-file frontend/public/locales/en-GB/translation.toml --branch "" --files frontend/public/locales/de-DE/translation.toml frontend/public/locales/fr-FR/translation.toml
|
||||
|
||||
import copy
|
||||
import glob
|
||||
@@ -20,12 +20,14 @@ 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 JSON file (including nested keys).
|
||||
:param file_path: Path to the JSON file.
|
||||
Identifies duplicate keys in a TOML file (including nested keys).
|
||||
:param file_path: Path to the TOML 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).
|
||||
@@ -35,8 +37,9 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
|
||||
|
||||
duplicates = []
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
# Load TOML file
|
||||
with open(file_path, 'rb') as file:
|
||||
data = tomllib.load(file)
|
||||
|
||||
def process_dict(obj, current_prefix=""):
|
||||
for key, value in obj.items():
|
||||
@@ -54,18 +57,18 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
|
||||
return duplicates
|
||||
|
||||
|
||||
# Maximum size for JSON files (e.g., 500 KB)
|
||||
# Maximum size for TOML files (e.g., 500 KB)
|
||||
MAX_FILE_SIZE = 500 * 1024
|
||||
|
||||
|
||||
def parse_json_file(file_path):
|
||||
def parse_toml_file(file_path):
|
||||
"""
|
||||
Parses a JSON translation file and returns a flat dictionary of all keys.
|
||||
:param file_path: Path to the JSON file.
|
||||
Parses a TOML translation file and returns a flat dictionary of all keys.
|
||||
:param file_path: Path to the TOML file.
|
||||
:return: Dictionary with flattened keys.
|
||||
"""
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
with open(file_path, 'rb') as file:
|
||||
data = tomllib.load(file)
|
||||
|
||||
def flatten_dict(d, parent_key="", sep="."):
|
||||
items = {}
|
||||
@@ -99,38 +102,37 @@ def unflatten_dict(d, sep="."):
|
||||
return result
|
||||
|
||||
|
||||
def write_json_file(file_path, updated_properties):
|
||||
def write_toml_file(file_path, updated_properties):
|
||||
"""
|
||||
Writes updated properties back to the JSON file.
|
||||
:param file_path: Path to the JSON file.
|
||||
Writes updated properties back to the TOML file.
|
||||
:param file_path: Path to the TOML file.
|
||||
:param updated_properties: Dictionary of updated properties to write.
|
||||
"""
|
||||
nested_data = unflatten_dict(updated_properties)
|
||||
|
||||
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
|
||||
with open(file_path, "wb") as file:
|
||||
tomli_w.dump(nested_data, file)
|
||||
|
||||
|
||||
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 JSON file.
|
||||
:param reference_file: Path to the reference TOML file.
|
||||
:param file_list: List of translation files to update.
|
||||
:param branch: Branch where the files are located.
|
||||
"""
|
||||
reference_properties = parse_json_file(reference_file)
|
||||
reference_properties = parse_toml_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(".json")
|
||||
or not file_path.endswith(".toml")
|
||||
or not os.path.dirname(file_path).endswith("locales")
|
||||
):
|
||||
continue
|
||||
|
||||
current_properties = parse_json_file(os.path.join(branch, file_path))
|
||||
current_properties = parse_toml_file(os.path.join(branch, file_path))
|
||||
updated_properties = {}
|
||||
|
||||
for ref_key, ref_value in reference_properties.items():
|
||||
@@ -141,16 +143,16 @@ def update_missing_keys(reference_file, file_list, branch=""):
|
||||
# Add missing key with reference value
|
||||
updated_properties[ref_key] = ref_value
|
||||
|
||||
write_json_file(os.path.join(branch, file_path), updated_properties)
|
||||
write_toml_file(os.path.join(branch, file_path), updated_properties)
|
||||
|
||||
|
||||
def check_for_missing_keys(reference_file, file_list, branch):
|
||||
update_missing_keys(reference_file, file_list, branch)
|
||||
|
||||
|
||||
def read_json_keys(file_path):
|
||||
def read_toml_keys(file_path):
|
||||
if os.path.isfile(file_path) and os.path.exists(file_path):
|
||||
return parse_json_file(file_path)
|
||||
return parse_toml_file(file_path)
|
||||
return {}
|
||||
|
||||
|
||||
@@ -160,7 +162,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
|
||||
report = []
|
||||
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
|
||||
reference_keys = read_json_keys(reference_file)
|
||||
reference_keys = read_toml_keys(reference_file)
|
||||
has_differences = False
|
||||
|
||||
only_reference_file = True
|
||||
@@ -197,12 +199,12 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
):
|
||||
continue
|
||||
|
||||
if not file_normpath.endswith(".json") or basename_current_file != "translation.json":
|
||||
if not file_normpath.endswith(".toml") or basename_current_file != "translation.toml":
|
||||
continue
|
||||
|
||||
only_reference_file = False
|
||||
report.append(f"#### 📃 **File Check:** `{locale_dir}/{basename_current_file}`")
|
||||
current_keys = read_json_keys(os.path.join(branch, file_path))
|
||||
current_keys = read_toml_keys(os.path.join(branch, file_path))
|
||||
reference_key_count = len(reference_keys)
|
||||
current_key_count = len(current_keys)
|
||||
|
||||
@@ -272,7 +274,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.json](https://github.com/Stirling-Tools/Stirling-PDF/blob/V2/frontend/public/locales/en-GB/translation.json)"
|
||||
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)"
|
||||
)
|
||||
else:
|
||||
report.append("## ✅ Overall Check Status: **_Success_**")
|
||||
@@ -286,7 +288,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Find missing keys")
|
||||
parser = argparse.ArgumentParser(description="Find missing keys in TOML translation files")
|
||||
parser.add_argument(
|
||||
"--actor",
|
||||
required=False,
|
||||
@@ -337,9 +339,9 @@ if __name__ == "__main__":
|
||||
"public",
|
||||
"locales",
|
||||
"*",
|
||||
"translation.json",
|
||||
"translation.toml",
|
||||
)
|
||||
)
|
||||
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)
|
||||
@@ -14,6 +14,7 @@ jobs:
|
||||
permissions:
|
||||
issues: write
|
||||
if: |
|
||||
vars.CI_PROFILE != 'lite' &&
|
||||
github.event.issue.pull_request &&
|
||||
(
|
||||
contains(github.event.comment.body, 'prdeploy') ||
|
||||
@@ -180,7 +181,7 @@ jobs:
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }}
|
||||
build-args: VERSION_TAG=alpha
|
||||
|
||||
@@ -262,7 +262,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "Dockerfile.fat"]
|
||||
docker-rev: ["docker/embedded/Dockerfile", "docker/embedded/Dockerfile.ultra-lite", "docker/embedded/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: ./docker/backend/${{ matrix.docker-rev }}
|
||||
file: ./${{ matrix.docker-rev }}
|
||||
push: false
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
name: Check Properties Files on PR
|
||||
name: Check TOML Translation Files on PR
|
||||
|
||||
# This workflow validates TOML translation files
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- "app/core/src/main/resources/messages_*.properties"
|
||||
- "frontend/public/locales/*/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.event.pull_request.number || github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -73,22 +68,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 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"
|
||||
# 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"
|
||||
|
||||
- name: Determine reference file test
|
||||
- name: Determine reference file
|
||||
id: determine-file
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
@@ -125,11 +120,11 @@ jobs:
|
||||
pull_number: prNumber,
|
||||
});
|
||||
|
||||
// Filter for relevant files based on the PR changes
|
||||
// Filter for relevant TOML files based on the PR changes
|
||||
const changedFiles = files
|
||||
.filter(file =>
|
||||
file.status !== "removed" &&
|
||||
/^app\/core\/src\/main\/resources\/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$/.test(file.filename)
|
||||
/^frontend\/public\/locales\/[a-zA-Z-]+\/translation\.toml$/.test(file.filename)
|
||||
)
|
||||
.map(file => file.filename);
|
||||
|
||||
@@ -169,16 +164,16 @@ jobs:
|
||||
|
||||
// Determine reference file
|
||||
let referenceFilePath;
|
||||
if (changedFiles.includes("app/core/src/main/resources/messages_en_GB.properties")) {
|
||||
if (changedFiles.includes("frontend/public/locales/en-GB/translation.toml")) {
|
||||
console.log("Using PR branch reference file.");
|
||||
const { data: fileContent } = await github.rest.repos.getContent({
|
||||
owner: prRepoOwner,
|
||||
repo: prRepoName,
|
||||
path: "app/core/src/main/resources/messages_en_GB.properties",
|
||||
path: "frontend/public/locales/en-GB/translation.toml",
|
||||
ref: branch,
|
||||
});
|
||||
|
||||
referenceFilePath = "pr-branch-messages_en_GB.properties";
|
||||
referenceFilePath = "pr-branch-translation-en-GB.toml";
|
||||
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
|
||||
fs.writeFileSync(referenceFilePath, content);
|
||||
} else {
|
||||
@@ -186,11 +181,11 @@ jobs:
|
||||
const { data: fileContent } = await github.rest.repos.getContent({
|
||||
owner: repoOwner,
|
||||
repo: repoName,
|
||||
path: "app/core/src/main/resources/messages_en_GB.properties",
|
||||
path: "frontend/public/locales/en-GB/translation.toml",
|
||||
ref: "main",
|
||||
});
|
||||
|
||||
referenceFilePath = "main-branch-messages_en_GB.properties";
|
||||
referenceFilePath = "main-branch-translation-en-GB.toml";
|
||||
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
|
||||
fs.writeFileSync(referenceFilePath, content);
|
||||
}
|
||||
@@ -198,11 +193,20 @@ 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 files..."
|
||||
python .github/scripts/check_language_properties.py \
|
||||
echo "Running Python script to check TOML files..."
|
||||
python .github/scripts/check_language_toml.py \
|
||||
--actor ${{ github.event.pull_request.user.login }} \
|
||||
--reference-file "${REFERENCE_FILE}" \
|
||||
--branch "pr-branch" \
|
||||
@@ -213,7 +217,7 @@ jobs:
|
||||
id: capture-output
|
||||
run: |
|
||||
if [ -f result.txt ] && [ -s result.txt ]; then
|
||||
echo "Test, capturing output..."
|
||||
echo "Capturing output..."
|
||||
SCRIPT_OUTPUT=$(cat result.txt)
|
||||
echo "SCRIPT_OUTPUT<<EOF" >> $GITHUB_ENV
|
||||
echo "$SCRIPT_OUTPUT" >> $GITHUB_ENV
|
||||
@@ -227,7 +231,7 @@ jobs:
|
||||
echo "FAIL_JOB=false" >> $GITHUB_ENV
|
||||
fi
|
||||
else
|
||||
echo "No update found."
|
||||
echo "No output found."
|
||||
echo "SCRIPT_OUTPUT=" >> $GITHUB_ENV
|
||||
echo "FAIL_JOB=false" >> $GITHUB_ENV
|
||||
fi
|
||||
@@ -249,7 +253,7 @@ jobs:
|
||||
issue_number: issueNumber
|
||||
});
|
||||
|
||||
const comment = comments.data.find(c => c.body.includes("## 🚀 Translation Verification Summary"));
|
||||
const comment = comments.data.find(c => c.body.includes("## 🌐 TOML Translation Verification Summary"));
|
||||
|
||||
// Only update or create comments by the action user
|
||||
const expectedActor = "${{ steps.setup-bot.outputs.app-slug }}[bot]";
|
||||
@@ -260,7 +264,7 @@ jobs:
|
||||
owner: repoOwner,
|
||||
repo: repoName,
|
||||
comment_id: comment.id,
|
||||
body: `## 🚀 Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
|
||||
body: `## 🌐 TOML Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
|
||||
});
|
||||
console.log("Updated existing comment.");
|
||||
} else if (!comment) {
|
||||
@@ -269,7 +273,7 @@ jobs:
|
||||
owner: repoOwner,
|
||||
repo: repoName,
|
||||
issue_number: issueNumber,
|
||||
body: `## 🚀 Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
|
||||
body: `## 🌐 TOML Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
|
||||
});
|
||||
console.log("Created new comment.");
|
||||
} else {
|
||||
@@ -287,6 +291,6 @@ jobs:
|
||||
run: |
|
||||
echo "Cleaning up temporary files..."
|
||||
rm -rf pr-branch
|
||||
rm -f pr-branch-messages_en_GB.properties main-branch-messages_en_GB.properties changed_files.txt result.txt
|
||||
rm -f pr-branch-translation-en-GB.toml main-branch-translation-en-GB.toml changed_files.txt result.txt
|
||||
echo "Cleanup complete."
|
||||
continue-on-error: true # Ensure cleanup runs even if previous steps fail
|
||||
@@ -31,6 +31,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
determine-matrix:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
|
||||
@@ -5,6 +5,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- V2-master
|
||||
- alljavadocker
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
@@ -23,6 +24,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
push:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-24.04-8core
|
||||
permissions:
|
||||
packages: write
|
||||
@@ -93,10 +95,10 @@ jobs:
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Generate tags for latest (V2-demo branch - test)
|
||||
- name: Generate tags for latest (alljavadocker branch - test)
|
||||
id: meta-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-demo'
|
||||
if: github.ref == 'refs/heads/alljavadocker'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
@@ -110,7 +112,7 @@ jobs:
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/Dockerfile.unified
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -149,10 +151,10 @@ jobs:
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
|
||||
type=raw,value=latest-fat
|
||||
|
||||
- name: Generate tags for latest-fat (V2-demo branch - test)
|
||||
- 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/V2-demo'
|
||||
if: github.ref == 'refs/heads/alljavadocker'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
@@ -166,7 +168,7 @@ jobs:
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/Dockerfile.unified
|
||||
file: ./docker/embedded/Dockerfile.fat
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -203,10 +205,10 @@ jobs:
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
|
||||
type=raw,value=latest-ultra-lite
|
||||
|
||||
- name: Generate tags for ultra-lite (V2-demo branch - test)
|
||||
- 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/V2-demo'
|
||||
if: github.ref == 'refs/heads/alljavadocker'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
@@ -220,7 +222,7 @@ jobs:
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/Dockerfile.unified-lite
|
||||
file: ./docker/embedded/Dockerfile.ultra-lite
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -24,6 +24,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
push:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
@@ -107,7 +108,7 @@ jobs:
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -152,7 +153,7 @@ jobs:
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.ultra-lite
|
||||
file: ./docker/embedded/Dockerfile.ultra-lite
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -183,7 +184,7 @@ jobs:
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./Dockerfile.fat
|
||||
file: ./docker/embedded/Dockerfile.fat
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -17,6 +17,7 @@ permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
@@ -27,6 +27,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
sonarqube:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
|
||||
@@ -10,6 +10,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
@@ -23,6 +23,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
push:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
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
|
||||
@@ -1,15 +1,15 @@
|
||||
name: Sync Files V2
|
||||
name: Sync Files (TOML)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- V2
|
||||
- main
|
||||
- syncLangTest
|
||||
paths:
|
||||
- "build.gradle"
|
||||
- "README.md"
|
||||
- "frontend/public/locales/*/translation.json"
|
||||
- "frontend/public/locales/*/translation.toml"
|
||||
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
|
||||
- "scripts/ignore_translation.toml"
|
||||
|
||||
@@ -52,21 +52,25 @@ jobs:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
|
||||
- name: Sync translation JSON files
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python .github/scripts/check_language_json.py --reference-file "frontend/public/locales/en-GB/translation.json" --branch V2
|
||||
pip install tomli-w
|
||||
|
||||
- name: Sync translation TOML files
|
||||
run: |
|
||||
python .github/scripts/check_language_toml.py --reference-file "frontend/public/locales/en-GB/translation.toml" --branch main
|
||||
|
||||
- name: Commit translation files
|
||||
run: |
|
||||
git add frontend/public/locales/*/translation.json
|
||||
git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "No changes detected"
|
||||
git add frontend/public/locales/*/translation.toml
|
||||
git diff --staged --quiet || git commit -m ":memo: Sync translation files (TOML)" || echo "No changes detected"
|
||||
|
||||
- name: Install dependencies
|
||||
- name: Install README dependencies
|
||||
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
|
||||
|
||||
- name: Sync README.md
|
||||
run: |
|
||||
python scripts/counter_translation_v2.py
|
||||
python scripts/counter_translation_v3.py
|
||||
|
||||
- name: Run git add
|
||||
run: |
|
||||
@@ -82,21 +86,22 @@ jobs:
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: sync_readme_v2
|
||||
base: V2
|
||||
title: ":globe_with_meridians: [V2] Sync Translations + Update README Progress Table"
|
||||
branch: sync_readme_v3
|
||||
base: main
|
||||
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 for the **V2 branch**. Below are the details of the changes made:
|
||||
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 (`frontend/public/locales/*/translation.json`) to reflect changes in the reference file `en-GB/translation.json`.
|
||||
- Updated translation files (`frontend/public/locales/*/translation.toml`) to reflect changes in the reference file `en-GB/translation.toml`.
|
||||
- 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`.
|
||||
- Generated the translation progress table in `README.md` using `counter_translation_v3.py`.
|
||||
- Added a summary of the current translation status for all supported languages.
|
||||
- Included up-to-date statistics on translation coverage.
|
||||
|
||||
@@ -115,4 +120,5 @@ jobs:
|
||||
sign-commits: true
|
||||
add-paths: |
|
||||
README.md
|
||||
frontend/public/locales/*/translation.json
|
||||
frontend/public/locales/*/translation.toml
|
||||
scripts/ignore_translation.toml
|
||||
@@ -28,6 +28,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
determine-matrix:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
@@ -636,6 +637,8 @@ jobs:
|
||||
if [ "${{ needs.build.result }}" = "success" ]; then
|
||||
echo "✅ All Tauri builds completed successfully!"
|
||||
echo "Artifacts are ready for distribution."
|
||||
elif [ "${{ needs.build.result }}" = "skipped" ]; then
|
||||
echo "⏭️ Tauri builds skipped (CI lite mode enabled)"
|
||||
else
|
||||
echo "❌ Some Tauri builds failed."
|
||||
echo "Please check the logs and fix any issues."
|
||||
|
||||
@@ -21,6 +21,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
@@ -66,7 +67,7 @@ jobs:
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
|
||||
+3
-3
@@ -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.json`
|
||||
**File to update:** `frontend/public/locales/en-GB/translation.toml`
|
||||
|
||||
**Required Translation Keys**:
|
||||
```json
|
||||
```toml
|
||||
{
|
||||
"home": {
|
||||
"[toolName]": {
|
||||
@@ -251,7 +251,7 @@ Update translation files. **Important: Only update `en-GB` files** - other langu
|
||||
```
|
||||
|
||||
**Translation Notes:**
|
||||
- **Only update `en-GB/translation.json`** - other locale files are managed separately
|
||||
- **Only update `en-GB/translation.toml`** - 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
|
||||
|
||||
@@ -1,173 +1,69 @@
|
||||
<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>
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling.png" width="80" alt="Stirling PDF logo">
|
||||
</p>
|
||||
|
||||
[](https://hub.docker.com/r/frooodle/s-pdf)
|
||||
[](https://discord.gg/HYmhKj45pU)
|
||||
[](https://scorecard.dev/viewer/?uri=github.com/Stirling-Tools/Stirling-PDF)
|
||||
[](https://github.com/Stirling-Tools/stirling-pdf)
|
||||
<h1 align="center">Stirling PDF - The Open-Source PDF Platform</h1>
|
||||
|
||||
<a href="https://www.producthunt.com/posts/stirling-pdf?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-stirling-pdf" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=641239&theme=light" alt="Stirling PDF - Open source locally hosted web PDF editor | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
[](https://cloud.digitalocean.com/apps/new?repo=https://github.com/Stirling-Tools/Stirling-PDF/tree/digitalOcean&refcode=c3210994b1af)
|
||||
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.
|
||||
|
||||
[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.
|
||||
<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>
|
||||
|
||||
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.
|
||||

|
||||
|
||||
Homepage: [https://stirlingpdf.com](https://stirlingpdf.com)
|
||||
## Key Capabilities
|
||||
|
||||
All documentation available at [https://docs.stirlingpdf.com/](https://docs.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.
|
||||
- **Enterprise‑grade** - SSO, auditing, and flexible on‑prem deployments.
|
||||
- **Developer platform** - REST APIs available for nearly all tools to integrate into your existing systems.
|
||||
- **Global UI** - Interface available in 40+ languages.
|
||||
|
||||

|
||||
For a full feature list, see the docs: **https://docs.stirlingpdf.com**
|
||||
|
||||
## Features
|
||||
## Quick Start
|
||||
|
||||
- 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)
|
||||
```bash
|
||||
docker run -p 8080:8080 docker.stirlingpdf.com/stirlingtools/stirling-pdf
|
||||
```
|
||||
|
||||
## PDF Features
|
||||
Then open: http://localhost:8080
|
||||
|
||||
### Page Operations
|
||||
For full installation options (including desktop and Kubernetes), see our [Documentation Guide](https://docs.stirlingpdf.com/#documentation-guide).
|
||||
|
||||
- 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
|
||||
## Resources
|
||||
|
||||
### Conversion Operations
|
||||
- [**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)
|
||||
|
||||
- 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
|
||||
## Support
|
||||
|
||||
### Security & Permissions
|
||||
- **Community** [Discord](https://discord.gg/HYmhKj45pU)
|
||||
- **Bug Reports**: [Github issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
|
||||
|
||||
- Add and remove passwords
|
||||
- Change/set PDF permissions
|
||||
- Add watermark(s)
|
||||
- Certify/sign PDFs
|
||||
- Sanitize PDFs
|
||||
- Auto-redact text
|
||||
## Contributing
|
||||
|
||||
### Other Operations
|
||||
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
- 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 development setup, see the [Developer Guide](DeveloperGuide.md).
|
||||
|
||||
For adding translations, see the [Translation Guide](devGuide/HowToAddNewLanguage.md).
|
||||
|
||||
## License
|
||||
|
||||
# 📖 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) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| English (English) (en_GB) |  |
|
||||
| English (US) (en_US) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
|
||||
## 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)
|
||||
Stirling PDF is open-core. See [LICENSE](LICENSE) for details.
|
||||
|
||||
@@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
name = "Analysis",
|
||||
description =
|
||||
"""
|
||||
Document analysis and information extraction services for content intelligence and insights.
|
||||
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.
|
||||
|
||||
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.
|
||||
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
|
||||
""")
|
||||
public @interface AnalysisApi {}
|
||||
|
||||
@@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
name = "Convert",
|
||||
description =
|
||||
"""
|
||||
Document format transformation services for cross-platform compatibility and workflow integration.
|
||||
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.
|
||||
|
||||
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.
|
||||
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
|
||||
""")
|
||||
public @interface ConvertApi {}
|
||||
|
||||
@@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
name = "Filter",
|
||||
description =
|
||||
"""
|
||||
Document content filtering and search operations for information discovery and organization.
|
||||
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.
|
||||
|
||||
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.
|
||||
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
|
||||
""")
|
||||
public @interface FilterApi {}
|
||||
|
||||
@@ -22,21 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
name = "General",
|
||||
description =
|
||||
"""
|
||||
Core PDF processing operations for fundamental document manipulation workflows.
|
||||
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.
|
||||
|
||||
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.
|
||||
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
|
||||
""")
|
||||
public @interface GeneralApi {}
|
||||
|
||||
@@ -22,25 +22,15 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
name = "Misc",
|
||||
description =
|
||||
"""
|
||||
Specialized utilities and supplementary tools for enhanced document processing workflows.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.)
|
||||
""")
|
||||
public @interface MiscApi {}
|
||||
|
||||
@@ -22,25 +22,12 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
name = "Pipeline",
|
||||
description =
|
||||
"""
|
||||
Automated document processing workflows for complex multi-stage business operations.
|
||||
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).
|
||||
|
||||
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.
|
||||
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
|
||||
""")
|
||||
public @interface PipelineApi {}
|
||||
|
||||
@@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
name = "Security",
|
||||
description =
|
||||
"""
|
||||
Document security and protection services for confidential and sensitive content.
|
||||
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.
|
||||
|
||||
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.
|
||||
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
|
||||
""")
|
||||
public @interface SecurityApi {}
|
||||
|
||||
@@ -112,7 +112,6 @@ public class ApplicationProperties {
|
||||
@Data
|
||||
public static class Security {
|
||||
private Boolean enableLogin;
|
||||
private Boolean csrfDisabled;
|
||||
private InitialLogin initialLogin = new InitialLogin();
|
||||
private OAUTH2 oauth2 = new OAUTH2();
|
||||
private SAML2 saml2 = new SAML2();
|
||||
@@ -575,6 +574,16 @@ public class ApplicationProperties {
|
||||
private String username;
|
||||
@ToString.Exclude private String password;
|
||||
private String from;
|
||||
// STARTTLS upgrades a plain SMTP connection to TLS after connecting (RFC 3207)
|
||||
private Boolean startTlsEnable = true;
|
||||
private Boolean startTlsRequired;
|
||||
// SSL/TLS wrapper for implicit TLS (typically port 465)
|
||||
private Boolean sslEnable;
|
||||
// Hostnames or patterns (e.g., "smtp.example.com" or "*") to trust for TLS certificates;
|
||||
// defaults to "*" (trust all) when not set
|
||||
private String sslTrust;
|
||||
// Enables hostname verification for TLS connections
|
||||
private Boolean sslCheckServerIdentity;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
}
|
||||
@@ -254,10 +254,7 @@ public class PostHogService {
|
||||
properties,
|
||||
"security_enableLogin",
|
||||
applicationProperties.getSecurity().getEnableLogin());
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"security_csrfDisabled",
|
||||
applicationProperties.getSecurity().getCsrfDisabled());
|
||||
addIfNotEmpty(properties, "security_csrfDisabled", true);
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"security_loginAttemptCount",
|
||||
|
||||
@@ -39,6 +39,7 @@ public class RequestUriUtils {
|
||||
// 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")) {
|
||||
@@ -161,10 +162,9 @@ public class RequestUriUtils {
|
||||
// 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");
|
||||
|| trimmedUri.startsWith("/v1/api-docs");
|
||||
}
|
||||
|
||||
private static String stripContextPath(String contextPath, String requestURI) {
|
||||
|
||||
@@ -51,7 +51,8 @@ public class RequestUriUtilsTest {
|
||||
|
||||
@Test
|
||||
void testIsFrontendRoute() {
|
||||
assertTrue(RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route");
|
||||
assertTrue(
|
||||
RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route");
|
||||
assertTrue(
|
||||
RequestUriUtils.isFrontendRoute("", "/app/dashboard"),
|
||||
"React routes without extensions should be frontend routes");
|
||||
|
||||
@@ -34,7 +34,6 @@ public class InitialSetup {
|
||||
public void init() throws IOException {
|
||||
initUUIDKey();
|
||||
initSecretKey();
|
||||
initEnableCSRFSecurity();
|
||||
initLegalUrls();
|
||||
initSetAppVersion();
|
||||
GeneralUtils.extractPipeline();
|
||||
@@ -60,18 +59,6 @@ public class InitialSetup {
|
||||
}
|
||||
}
|
||||
|
||||
public void initEnableCSRFSecurity() throws IOException {
|
||||
if (GeneralUtils.isVersionHigher(
|
||||
"0.46.0", applicationProperties.getAutomaticallyGenerated().getAppVersion())) {
|
||||
Boolean csrf = applicationProperties.getSecurity().getCsrfDisabled();
|
||||
if (!csrf) {
|
||||
GeneralUtils.saveKeyToSettings("security.csrfDisabled", false);
|
||||
GeneralUtils.saveKeyToSettings("system.enableAnalytics", true);
|
||||
applicationProperties.getSecurity().setCsrfDisabled(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void initLegalUrls() throws IOException {
|
||||
// Initialize Terms and Conditions
|
||||
String termsUrl = applicationProperties.getLegal().getTermsAndConditions();
|
||||
@@ -95,7 +82,7 @@ public class InitialSetup {
|
||||
isNewServer =
|
||||
existingVersion == null
|
||||
|| existingVersion.isEmpty()
|
||||
|| existingVersion.equals("0.0.0");
|
||||
|| "0.0.0".equals(existingVersion);
|
||||
|
||||
String appVersion = "0.0.0";
|
||||
Resource resource = new ClassPathResource("version.properties");
|
||||
|
||||
@@ -62,10 +62,15 @@ public class OpenApiConfig {
|
||||
|
||||
// Add server configuration from environment variable
|
||||
String swaggerServerUrl = System.getenv("SWAGGER_SERVER_URL");
|
||||
Server server;
|
||||
if (swaggerServerUrl != null && !swaggerServerUrl.trim().isEmpty()) {
|
||||
Server server = new Server().url(swaggerServerUrl).description("API Server");
|
||||
openAPI.addServersItem(server);
|
||||
server = new Server().url(swaggerServerUrl).description("API Server");
|
||||
} else {
|
||||
// Use relative path so Swagger uses the current browser origin to avoid CORS issues
|
||||
// when accessing via different ports
|
||||
server = new Server().url("/").description("Current Server");
|
||||
}
|
||||
openAPI.addServersItem(server);
|
||||
|
||||
// Add ErrorResponse schema to components
|
||||
Schema<?> errorResponseSchema =
|
||||
|
||||
@@ -21,6 +21,9 @@ 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/**",
|
||||
@@ -33,7 +36,7 @@ public class SpringDocConfig {
|
||||
openApi.getInfo()
|
||||
.title("Stirling PDF - Processing API")
|
||||
.description(
|
||||
"API documentation for PDF processing operations including conversion, manipulation, security, and utilities."));
|
||||
"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."));
|
||||
})
|
||||
.build();
|
||||
}
|
||||
@@ -47,14 +50,17 @@ public class SpringDocConfig {
|
||||
"/api/v1/admin/**",
|
||||
"/api/v1/user/**",
|
||||
"/api/v1/settings/**",
|
||||
"/api/v1/team/**")
|
||||
"/api/v1/team/**",
|
||||
"/api/v1/auth/**",
|
||||
"/api/v1/invite/**",
|
||||
"/api/v1/audit/**")
|
||||
.addOpenApiCustomizer(
|
||||
openApi -> {
|
||||
openApi.info(
|
||||
openApi.getInfo()
|
||||
.title("Stirling PDF - Admin API")
|
||||
.title("Stirling PDF - Management API")
|
||||
.description(
|
||||
"API documentation for administrative functions, user management, and system configuration."));
|
||||
"Endpoints for authentication, user management, invitations, audit logging, and system configuration."));
|
||||
})
|
||||
.build();
|
||||
}
|
||||
@@ -76,7 +82,7 @@ public class SpringDocConfig {
|
||||
openApi.getInfo()
|
||||
.title("Stirling PDF - System API")
|
||||
.description(
|
||||
"API documentation for system information, UI data, and utility endpoints."));
|
||||
"System information, UI metadata, job status, and file management endpoints."));
|
||||
})
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -124,7 +124,6 @@ public class SettingsController {
|
||||
ApplicationProperties.Security security = applicationProperties.getSecurity();
|
||||
|
||||
settings.put("enableLogin", security.getEnableLogin());
|
||||
settings.put("csrfDisabled", security.getCsrfDisabled());
|
||||
settings.put("loginMethod", security.getLoginMethod());
|
||||
settings.put("loginAttemptCount", security.getLoginAttemptCount());
|
||||
settings.put("loginResetTimeMinutes", security.getLoginResetTimeMinutes());
|
||||
@@ -159,12 +158,6 @@ public class SettingsController {
|
||||
.getSecurity()
|
||||
.setEnableLogin((Boolean) settings.get("enableLogin"));
|
||||
}
|
||||
if (settings.containsKey("csrfDisabled")) {
|
||||
GeneralUtils.saveKeyToSettings("security.csrfDisabled", settings.get("csrfDisabled"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.setCsrfDisabled((Boolean) settings.get("csrfDisabled"));
|
||||
}
|
||||
if (settings.containsKey("loginMethod")) {
|
||||
GeneralUtils.saveKeyToSettings("security.loginMethod", settings.get("loginMethod"));
|
||||
applicationProperties
|
||||
|
||||
@@ -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.SignatureService;
|
||||
import stirling.software.SPDF.service.SharedSignatureService;
|
||||
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 SignatureService signatureService;
|
||||
private final SharedSignatureService signatureService;
|
||||
private final UserServiceInterface userService;
|
||||
private final ResourceLoader resourceLoader;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
public UIDataController(
|
||||
ApplicationProperties applicationProperties,
|
||||
SignatureService signatureService,
|
||||
SharedSignatureService signatureService,
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
ResourceLoader resourceLoader,
|
||||
RuntimePathConfig runtimePathConfig) {
|
||||
|
||||
-2
@@ -31,12 +31,10 @@ import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.proprietary.security.config.PremiumEndpoint;
|
||||
|
||||
@Slf4j
|
||||
@ConvertApi
|
||||
@RequiredArgsConstructor
|
||||
@PremiumEndpoint
|
||||
public class ConvertPdfJsonController {
|
||||
|
||||
private final PdfJsonConversionService pdfJsonConversionService;
|
||||
+1
@@ -74,6 +74,7 @@ public class ConfigController {
|
||||
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
|
||||
configData.put("languages", applicationProperties.getUi().getLanguages());
|
||||
configData.put("logoStyle", applicationProperties.getUi().getLogoStyle());
|
||||
configData.put("defaultLocale", applicationProperties.getSystem().getDefaultLocale());
|
||||
|
||||
// Security settings
|
||||
// enableLogin requires both the config flag AND proprietary features to be loaded
|
||||
|
||||
+3
-3
@@ -25,7 +25,7 @@ import lombok.Setter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.SignatureFile;
|
||||
import stirling.software.SPDF.service.SignatureService;
|
||||
import stirling.software.SPDF.service.SharedSignatureService;
|
||||
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 SignatureService signatureService;
|
||||
private final SharedSignatureService signatureService;
|
||||
private final UserServiceInterface userService;
|
||||
private final ResourceLoader resourceLoader;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
public GeneralWebController(
|
||||
SignatureService signatureService,
|
||||
SharedSignatureService signatureService,
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
ResourceLoader resourceLoader,
|
||||
RuntimePathConfig runtimePathConfig) {
|
||||
|
||||
+76
-6
@@ -1,18 +1,88 @@
|
||||
package stirling.software.SPDF.controller.web;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
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.annotation.PostConstruct;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
@Controller
|
||||
public class ReactRoutingController {
|
||||
|
||||
@GetMapping("/{path:^(?!api|static|robots\\.txt|favicon\\.ico|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*$}")
|
||||
public String forwardRootPaths() {
|
||||
return "forward:/index.html";
|
||||
@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|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
public String forwardNestedPaths() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +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 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);
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
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
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
}
|
||||
+64
-2
@@ -33,8 +33,12 @@ public class PdfJsonFallbackFontService {
|
||||
public static final String FALLBACK_FONT_CJK_ID = "fallback-noto-cjk";
|
||||
public static final String FALLBACK_FONT_JP_ID = "fallback-noto-jp";
|
||||
public static final String FALLBACK_FONT_KR_ID = "fallback-noto-korean";
|
||||
public static final String FALLBACK_FONT_TC_ID = "fallback-noto-tc";
|
||||
public static final String FALLBACK_FONT_AR_ID = "fallback-noto-arabic";
|
||||
public static final String FALLBACK_FONT_TH_ID = "fallback-noto-thai";
|
||||
public static final String FALLBACK_FONT_DEVANAGARI_ID = "fallback-noto-devanagari";
|
||||
public static final String FALLBACK_FONT_MALAYALAM_ID = "fallback-noto-malayalam";
|
||||
public static final String FALLBACK_FONT_TIBETAN_ID = "fallback-noto-tibetan";
|
||||
|
||||
// Font name aliases map PDF font names to available fallback fonts
|
||||
// This provides better visual consistency when editing PDFs
|
||||
@@ -59,6 +63,22 @@ public class PdfJsonFallbackFontService {
|
||||
Map.entry("dejavuserif", "fallback-dejavu-serif"),
|
||||
Map.entry("dejavumono", "fallback-dejavu-mono"),
|
||||
Map.entry("dejavusansmono", "fallback-dejavu-mono"),
|
||||
// Traditional Chinese fonts (Taiwan, Hong Kong, Macau)
|
||||
Map.entry("mingliu", "fallback-noto-tc"),
|
||||
Map.entry("pmingliu", "fallback-noto-tc"),
|
||||
Map.entry("microsoftjhenghei", "fallback-noto-tc"),
|
||||
Map.entry("jhenghei", "fallback-noto-tc"),
|
||||
Map.entry("kaiti", "fallback-noto-tc"),
|
||||
Map.entry("kaiu", "fallback-noto-tc"),
|
||||
Map.entry("dfkaib5", "fallback-noto-tc"),
|
||||
Map.entry("dfkai", "fallback-noto-tc"),
|
||||
// Simplified Chinese fonts (Mainland China) - more common
|
||||
Map.entry("simsun", "fallback-noto-cjk"),
|
||||
Map.entry("simhei", "fallback-noto-cjk"),
|
||||
Map.entry("microsoftyahei", "fallback-noto-cjk"),
|
||||
Map.entry("yahei", "fallback-noto-cjk"),
|
||||
Map.entry("songti", "fallback-noto-cjk"),
|
||||
Map.entry("heiti", "fallback-noto-cjk"),
|
||||
// Noto Sans - Google's universal font (use as last resort generic fallback)
|
||||
Map.entry("noto", "fallback-noto-sans"),
|
||||
Map.entry("notosans", "fallback-noto-sans"));
|
||||
@@ -83,6 +103,12 @@ public class PdfJsonFallbackFontService {
|
||||
"classpath:/static/fonts/NotoSansKR-Regular.ttf",
|
||||
"NotoSansKR-Regular",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
FALLBACK_FONT_TC_ID,
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/NotoSansTC-Regular.ttf",
|
||||
"NotoSansTC-Regular",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
FALLBACK_FONT_AR_ID,
|
||||
new FallbackFontSpec(
|
||||
@@ -95,6 +121,24 @@ public class PdfJsonFallbackFontService {
|
||||
"classpath:/static/fonts/NotoSansThai-Regular.ttf",
|
||||
"NotoSansThai-Regular",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
FALLBACK_FONT_DEVANAGARI_ID,
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/NotoSansDevanagari-Regular.ttf",
|
||||
"NotoSansDevanagari-Regular",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
FALLBACK_FONT_MALAYALAM_ID,
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/NotoSansMalayalam-Regular.ttf",
|
||||
"NotoSansMalayalam-Regular",
|
||||
"ttf")),
|
||||
Map.entry(
|
||||
FALLBACK_FONT_TIBETAN_ID,
|
||||
new FallbackFontSpec(
|
||||
"classpath:/static/fonts/NotoSerifTibetan-Regular.ttf",
|
||||
"NotoSerifTibetan-Regular",
|
||||
"ttf")),
|
||||
// Liberation Sans family
|
||||
Map.entry(
|
||||
"fallback-liberation-sans",
|
||||
@@ -484,6 +528,20 @@ public class PdfJsonFallbackFontService {
|
||||
*/
|
||||
public String resolveFallbackFontId(int codePoint) {
|
||||
Character.UnicodeBlock block = Character.UnicodeBlock.of(codePoint);
|
||||
|
||||
// Bopomofo is primarily used in Taiwan for Traditional Chinese phonetic annotation
|
||||
if (block == Character.UnicodeBlock.BOPOMOFO
|
||||
|| block == Character.UnicodeBlock.BOPOMOFO_EXTENDED) {
|
||||
return FALLBACK_FONT_TC_ID;
|
||||
}
|
||||
|
||||
// Compatibility ideographs are primarily used by Traditional Chinese encodings (e.g., Big5,
|
||||
// HKSCS) so prefer the Traditional Chinese fallback here.
|
||||
if (block == Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS
|
||||
|| block == Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT) {
|
||||
return FALLBACK_FONT_TC_ID;
|
||||
}
|
||||
|
||||
if (block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS
|
||||
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A
|
||||
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B
|
||||
@@ -492,19 +550,23 @@ public class PdfJsonFallbackFontService {
|
||||
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_E
|
||||
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_F
|
||||
|| block == Character.UnicodeBlock.CJK_SYMBOLS_AND_PUNCTUATION
|
||||
|| block == Character.UnicodeBlock.BOPOMOFO
|
||||
|| block == Character.UnicodeBlock.BOPOMOFO_EXTENDED
|
||||
|| block == Character.UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS) {
|
||||
return FALLBACK_FONT_CJK_ID;
|
||||
}
|
||||
|
||||
Character.UnicodeScript script = Character.UnicodeScript.of(codePoint);
|
||||
return switch (script) {
|
||||
// HAN script is used by both Simplified and Traditional Chinese
|
||||
// Default to Simplified (mainland China, 1.4B speakers) as it's more common
|
||||
// Traditional Chinese PDFs are detected via font name aliases (MingLiU, PMingLiU, etc.)
|
||||
case HAN -> FALLBACK_FONT_CJK_ID;
|
||||
case HIRAGANA, KATAKANA -> FALLBACK_FONT_JP_ID;
|
||||
case HANGUL -> FALLBACK_FONT_KR_ID;
|
||||
case ARABIC -> FALLBACK_FONT_AR_ID;
|
||||
case THAI -> FALLBACK_FONT_TH_ID;
|
||||
case DEVANAGARI -> FALLBACK_FONT_DEVANAGARI_ID;
|
||||
case MALAYALAM -> FALLBACK_FONT_MALAYALAM_ID;
|
||||
case TIBETAN -> FALLBACK_FONT_TIBETAN_ID;
|
||||
default -> FALLBACK_FONT_ID;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,107 +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.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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
|
||||
security:
|
||||
enableLogin: true # 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
|
||||
loginMethod: all # Accepts values like 'all' and 'normal'(only Login with Username/Password), 'oauth2'(only Login with OAuth2) or 'saml2'(only Login with SAML2)
|
||||
@@ -105,6 +104,11 @@ mail:
|
||||
username: '' # SMTP server username
|
||||
password: '' # SMTP server password
|
||||
from: '' # sender email address
|
||||
startTlsEnable: true # enable STARTTLS (explicit TLS upgrade after connecting) when supported by the SMTP server
|
||||
startTlsRequired: false # require STARTTLS; connection fails if the upgrade command is not supported
|
||||
sslEnable: false # enable SSL/TLS wrapper for implicit TLS (typically used with port 465)
|
||||
sslTrust: '' # optional trusted host override, e.g. "smtp.example.com" or "*"; defaults to "*" (trust all) when empty
|
||||
sslCheckServerIdentity: false # enable hostname verification when using SSL/TLS
|
||||
|
||||
legal:
|
||||
termsAndConditions: https://www.stirling.com/legal/terms-of-service # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -23,7 +23,7 @@ import stirling.software.common.configuration.InstallationPathConfig;
|
||||
class SignatureServiceTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
private SignatureService signatureService;
|
||||
private SharedSignatureService 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 SignatureService();
|
||||
signatureService = new SharedSignatureService();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ class SignatureServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSignatureBytes_PersonalFile() throws IOException {
|
||||
void testGetSharedSignatureBytes_SharedFile() throws IOException {
|
||||
// Mock static method for each test
|
||||
try (MockedStatic<InstallationPathConfig> mockedConfig =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
@@ -173,28 +173,8 @@ class SignatureServiceTest {
|
||||
.when(InstallationPathConfig::getSignaturesPath)
|
||||
.thenReturn(tempDir.toString());
|
||||
|
||||
// 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");
|
||||
// Test - core service only reads shared signatures
|
||||
byte[] bytes = signatureService.getSharedSignatureBytes("shared.jpg");
|
||||
|
||||
// Verify
|
||||
assertEquals(
|
||||
@@ -205,7 +185,7 @@ class SignatureServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSignatureBytes_FileNotFound() {
|
||||
void testGetSharedSignatureBytes_FileNotFound() {
|
||||
// Mock static method for each test
|
||||
try (MockedStatic<InstallationPathConfig> mockedConfig =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
@@ -216,13 +196,13 @@ class SignatureServiceTest {
|
||||
// Test and verify
|
||||
assertThrows(
|
||||
FileNotFoundException.class,
|
||||
() -> signatureService.getSignatureBytes(TEST_USER, "nonexistent.png"),
|
||||
() -> signatureService.getSharedSignatureBytes("nonexistent.png"),
|
||||
"Should throw exception for non-existent files");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSignatureBytes_InvalidFileName() {
|
||||
void testGetSharedSignatureBytes_InvalidFileName() {
|
||||
// Mock static method for each test
|
||||
try (MockedStatic<InstallationPathConfig> mockedConfig =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
@@ -233,11 +213,28 @@ class SignatureServiceTest {
|
||||
// Test and verify
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> signatureService.getSignatureBytes(TEST_USER, "../invalid.png"),
|
||||
() -> signatureService.getSharedSignatureBytes("../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
|
||||
|
||||
+9
-1
@@ -205,6 +205,10 @@ public class ProprietaryUIDataController {
|
||||
data.setLoginMethod(securityProps.getLoginMethod());
|
||||
data.setAltLogin(!providerList.isEmpty() && securityProps.isAltLogin());
|
||||
|
||||
// Add language configuration for login page
|
||||
data.setLanguages(applicationProperties.getUi().getLanguages());
|
||||
data.setDefaultLocale(applicationProperties.getSystem().getDefaultLocale());
|
||||
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
|
||||
@@ -328,6 +332,7 @@ public class ProprietaryUIDataController {
|
||||
data.setGrandfatheredUserCount(grandfatheredCount);
|
||||
data.setLicenseMaxUsers(licenseMaxUsers);
|
||||
data.setPremiumEnabled(premiumEnabled);
|
||||
data.setMailEnabled(applicationProperties.getMail().isEnabled());
|
||||
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
@@ -376,7 +381,7 @@ public class ProprietaryUIDataController {
|
||||
data.setUsername(username);
|
||||
data.setRole(user.get().getRolesAsString());
|
||||
data.setSettings(settingsJson);
|
||||
data.setChangeCredsFlag(user.get().isFirstLogin());
|
||||
data.setChangeCredsFlag(user.get().isFirstLogin() || user.get().isForcePasswordChange());
|
||||
data.setOAuth2Login(isOAuth2Login);
|
||||
data.setSaml2Login(isSaml2Login);
|
||||
|
||||
@@ -491,6 +496,8 @@ public class ProprietaryUIDataController {
|
||||
private boolean altLogin;
|
||||
private boolean firstTimeSetup;
|
||||
private boolean showDefaultCredentials;
|
||||
private List<String> languages;
|
||||
private String defaultLocale;
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -510,6 +517,7 @@ public class ProprietaryUIDataController {
|
||||
private int grandfatheredUserCount;
|
||||
private int licenseMaxUsers;
|
||||
private boolean premiumEnabled;
|
||||
private boolean mailEnabled;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -34,7 +34,19 @@ import stirling.software.proprietary.util.FormUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/form")
|
||||
@Tag(name = "Forms", description = "PDF form APIs")
|
||||
@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
|
||||
""")
|
||||
@RequiredArgsConstructor
|
||||
public class FormFillController {
|
||||
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
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
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
}
|
||||
+56
-6
@@ -33,20 +33,70 @@ public class MailConfig {
|
||||
|
||||
// Creates a new instance of JavaMailSenderImpl, which is a Spring implementation
|
||||
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
|
||||
mailSender.setHost(mailProperties.getHost());
|
||||
String host = mailProperties.getHost();
|
||||
mailSender.setHost(host);
|
||||
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();
|
||||
|
||||
// Enables SMTP authentication
|
||||
props.put("mail.smtp.auth", "true");
|
||||
// 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");
|
||||
}
|
||||
|
||||
boolean startTlsEnabled =
|
||||
mailProperties.getStartTlsEnable() == null || mailProperties.getStartTlsEnable();
|
||||
// Enables STARTTLS to encrypt the connection if supported by the SMTP server
|
||||
props.put("mail.smtp.starttls.enable", "true");
|
||||
props.put("mail.smtp.starttls.enable", Boolean.toString(startTlsEnabled));
|
||||
if (mailProperties.getStartTlsRequired() != null) {
|
||||
props.put(
|
||||
"mail.smtp.starttls.required", mailProperties.getStartTlsRequired().toString());
|
||||
}
|
||||
|
||||
if (mailProperties.getSslEnable() != null) {
|
||||
props.put("mail.smtp.ssl.enable", mailProperties.getSslEnable().toString());
|
||||
}
|
||||
|
||||
// Trust the configured host to allow STARTTLS with self-signed certificates
|
||||
String sslTrust = mailProperties.getSslTrust();
|
||||
if (sslTrust == null || sslTrust.trim().isEmpty()) {
|
||||
sslTrust = "*";
|
||||
}
|
||||
if (sslTrust != null && !sslTrust.trim().isEmpty()) {
|
||||
props.put("mail.smtp.ssl.trust", sslTrust);
|
||||
}
|
||||
if (mailProperties.getSslCheckServerIdentity() != null) {
|
||||
props.put(
|
||||
"mail.smtp.ssl.checkserveridentity",
|
||||
mailProperties.getSslCheckServerIdentity().toString());
|
||||
}
|
||||
|
||||
// Returns the configured mail sender, ready to send emails
|
||||
return mailSender;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user