Compare commits

..
Author SHA1 Message Date
Anthony Stirling 5d3739e1d3 urls 2025-09-26 14:48:15 +01:00
Anthony Stirling 8aa52a4fa6 remove tools 2025-09-26 14:43:38 +01:00
909 changed files with 10182 additions and 63763 deletions
-345
View File
@@ -1,345 +0,0 @@
"""
Author: Ludy87
Description: This script processes JSON translation files for localization checks. It compares translation files in a branch with
a reference file to ensure consistency. The script performs two main checks:
1. Verifies that the number of translation keys in the translation files matches the reference file.
2. Ensures that all keys in the translation files are present in the reference file and vice versa.
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_json.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
"""
# Sample for Windows:
# python .github/scripts/check_language_json.py --reference-file frontend/public/locales/en-GB/translation.json --branch "" --files frontend/public/locales/de-DE/translation.json frontend/public/locales/fr-FR/translation.json
import copy
import glob
import os
import argparse
import re
import json
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.
: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).
"""
if keys is None:
keys = {}
duplicates = []
with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
def process_dict(obj, current_prefix=""):
for key, value in obj.items():
full_key = f"{current_prefix}.{key}" if current_prefix else key
if isinstance(value, dict):
process_dict(value, full_key)
else:
if full_key in keys:
duplicates.append((full_key, keys[full_key], full_key))
else:
keys[full_key] = full_key
process_dict(data, prefix)
return duplicates
# Maximum size for JSON files (e.g., 500 KB)
MAX_FILE_SIZE = 500 * 1024
def parse_json_file(file_path):
"""
Parses a JSON translation file and returns a flat dictionary of all keys.
:param file_path: Path to the JSON file.
:return: Dictionary with flattened keys.
"""
with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
def flatten_dict(d, parent_key="", sep="."):
items = {}
for k, v in d.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
if isinstance(v, dict):
items.update(flatten_dict(v, new_key, sep=sep))
else:
items[new_key] = v
return items
return flatten_dict(data)
def unflatten_dict(d, sep="."):
"""
Converts a flat dictionary with dot notation keys back to nested dict.
:param d: Flattened dictionary.
:param sep: Separator used in keys.
:return: Nested dictionary.
"""
result = {}
for key, value in d.items():
parts = key.split(sep)
current = result
for part in parts[:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[parts[-1]] = value
return result
def write_json_file(file_path, updated_properties):
"""
Writes updated properties back to the JSON file.
:param file_path: Path to the JSON file.
:param updated_properties: Dictionary of updated properties to write.
"""
nested_data = unflatten_dict(updated_properties)
with open(file_path, "w", encoding="utf-8", newline="\n") as file:
json.dump(nested_data, file, ensure_ascii=False, indent=2)
file.write("\n") # Add trailing newline
def update_missing_keys(reference_file, file_list, branch=""):
"""
Updates missing keys in the translation files based on the reference file.
:param reference_file: Path to the reference JSON 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)
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 os.path.dirname(file_path).endswith("locales")
):
continue
current_properties = parse_json_file(os.path.join(branch, file_path))
updated_properties = {}
for ref_key, ref_value in reference_properties.items():
if ref_key in current_properties:
# Keep the current translation
updated_properties[ref_key] = current_properties[ref_key]
else:
# Add missing key with reference value
updated_properties[ref_key] = ref_value
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_json_keys(file_path):
if os.path.isfile(file_path) and os.path.exists(file_path):
return parse_json_file(file_path)
return {}
def check_for_differences(reference_file, file_list, branch, actor):
reference_branch = branch
basename_reference_file = os.path.basename(reference_file)
report = []
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
reference_keys = read_json_keys(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(), "frontend", "public", "locales")
)
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))
locale_dir = os.path.basename(os.path.dirname(file_normpath))
if (
basename_current_file == basename_reference_file
and locale_dir == "en-GB"
):
continue
if not file_normpath.endswith(".json") or basename_current_file != "translation.json":
continue
only_reference_file = False
report.append(f"#### 📃 **File Check:** `{locale_dir}/{basename_current_file}`")
current_keys = read_json_keys(os.path.join(branch, file_path))
reference_key_count = len(reference_keys)
current_key_count = len(current_keys)
if reference_key_count != current_key_count:
report.append("")
report.append("1. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
has_differences = True
if reference_key_count > current_key_count:
report.append(
f" - **_Mismatched key count_**: {reference_key_count} (reference) vs {current_key_count} (current). Translation keys are missing."
)
elif reference_key_count < current_key_count:
report.append(
f" - **_Too many keys_**: {reference_key_count} (reference) vs {current_key_count} (current). Please verify if there are additional keys that need to be removed."
)
else:
report.append("1. **Test Status:** ✅ **_Passed_**")
# Check for missing or extra keys
current_keys_set = set(current_keys.keys())
reference_keys_set = set(reference_keys.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:
report.append(
f" - **_Extra keys in `{locale_dir}/{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**."
)
if extra_keys_list:
report.append(
f" - **_Missing keys in `{locale_dir}/{basename_current_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_reference_file}`_**."
)
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 {first}, duplicate at `{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 [en-GB/translation.json](https://github.com/Stirling-Tools/Stirling-PDF/blob/V2/frontend/public/locales/en-GB/translation.json)"
)
else:
report.append("## ✅ Overall Check Status: **_Success_**")
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(),
"frontend",
"public",
"locales",
"*",
"translation.json",
)
)
update_missing_keys(args.reference_file, file_list)
else:
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
+30 -37
View File
@@ -31,15 +31,10 @@ jobs:
project: ${{ steps.changes.outputs.project }}
openapi: ${{ steps.changes.outputs.openapi }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@v4.3.0
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
uses: dorny/paths-filter@v3.0.2
id: changes
with:
filters: .github/config/.files.yaml
@@ -56,19 +51,19 @@ jobs:
spring-security: [true, false]
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4.3.0
- name: Set up JDK ${{ matrix.jdk-version }}
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@v4.7.1
with:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
uses: gradle/actions/setup-gradle@v4.4.2
with:
gradle-version: 8.14
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
@@ -94,7 +89,7 @@ jobs:
done
- name: Upload Test Reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4.6.2
with:
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
path: |
@@ -111,26 +106,26 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4.3.0
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@v4.7.1
with:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- uses: gradle/actions/setup-gradle@v4.4.2
- name: Generate OpenAPI documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
env:
DISABLE_ADDITIONAL_FEATURES: true
- name: Upload OpenAPI Documentation
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4.6.2
with:
name: openapi-docs
path: ./SwaggerDoc.json
@@ -139,21 +134,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@v2.12.2
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4.2.2
- name: Set up Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@v4.1.0
with:
node-version: '22'
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: cd frontend && npm ci
- name: Type-check frontend
run: cd frontend && npm run prebuild && npm run typecheck:all
- name: Lint frontend
run: cd frontend && npm run lint
- name: Build frontend
@@ -161,7 +154,7 @@ jobs:
- name: Run frontend tests
run: cd frontend && npm run test -- --run
- name: Upload frontend build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4.6.2
with:
name: frontend-build
path: frontend/dist/
@@ -173,13 +166,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@v4.3.0
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@v4.7.1
with:
java-version: "17"
distribution: "temurin"
@@ -187,7 +180,7 @@ jobs:
run: ./gradlew clean checkLicense
- name: FAILED - check the licenses for compatibility
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@v4.6.2
with:
name: dependencies-without-allowed-license.json
path: build/reports/dependency-license/dependencies-without-allowed-license.json
@@ -214,15 +207,15 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Set up Java 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
@@ -232,11 +225,11 @@ jobs:
- name: Install Docker Compose
run: |
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.39.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.37.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
cache: 'pip' # caching pip dependencies
@@ -263,21 +256,21 @@ jobs:
docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "Dockerfile.fat"]
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "17"
distribution: "temurin"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
with:
gradle-version: 8.14
@@ -12,7 +12,6 @@ on:
branches:
- V2
paths:
- ".github/workflows/frontend-licenses-update.yml"
- "frontend/package.json"
- "frontend/package-lock.json"
- "frontend/scripts/generate-licenses.js"
@@ -29,12 +28,12 @@ jobs:
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Checkout PR head (default)
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
persist-credentials: false
@@ -49,7 +48,7 @@ jobs:
- name: Checkout BASE branch (safe script)
if: github.event_name == 'pull_request'
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.base.sha }}
path: base
@@ -57,9 +56,9 @@ jobs:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: '22'
node-version: '18'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
@@ -115,7 +114,7 @@ jobs:
# PR Event: Check licenses and comment on PR
- name: Delete previous license check comments
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
@@ -168,7 +167,7 @@ jobs:
- name: Comment on PR - License Check Results
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
-118
View File
@@ -1,118 +0,0 @@
name: Sync Files V2
on:
workflow_dispatch:
push:
branches:
- V2
- syncLangTest
paths:
- "build.gradle"
- "README.md"
- "frontend/public/locales/*/translation.json"
- "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
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.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@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
- name: Sync translation JSON files
run: |
python .github/scripts/check_language_json.py --reference-file "frontend/public/locales/en-GB/translation.json" --branch V2
- name: Commit translation files
run: |
git add frontend/public/locales/*/translation.json
git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "No changes detected"
- name: Install dependencies
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
- name: Sync README.md
run: |
python scripts/counter_translation_v2.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_v2
base: V2
title: ":globe_with_meridians: [V2] Sync Translations + Update README Progress Table"
body: |
### Description of Changes
This Pull Request was automatically generated to synchronize updates to translation files and documentation for the **V2 branch**. 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`.
- 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
frontend/public/locales/*/translation.json
-5
View File
@@ -192,11 +192,6 @@ return useToolOperation({
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
## Translation Rules
- **CRITICAL**: Always update translations in `en-GB` only, never `en-US`
- Translation files are located in `frontend/public/locales/`
## Important Notes
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21
+38 -37
View File
@@ -97,6 +97,7 @@ All documentation available at [https://docs.stirlingpdf.com/](https://docs.stir
# 📖 Get Started
Visit our comprehensive documentation at [docs.stirlingpdf.com](https://docs.stirlingpdf.com) for:
@@ -115,46 +116,46 @@ Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![83%](https://geps.dev/progress/83) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![32%](https://geps.dev/progress/32) |
| Basque (Euskara) (eu_ES) | ![18%](https://geps.dev/progress/18) |
| Bulgarian (Български) (bg_BG) | ![35%](https://geps.dev/progress/35) |
| Catalan (Català) (ca_CA) | ![34%](https://geps.dev/progress/34) |
| Croatian (Hrvatski) (hr_HR) | ![31%](https://geps.dev/progress/31) |
| Czech (Česky) (cs_CZ) | ![34%](https://geps.dev/progress/34) |
| Danish (Dansk) (da_DK) | ![30%](https://geps.dev/progress/30) |
| Dutch (Nederlands) (nl_NL) | ![30%](https://geps.dev/progress/30) |
| Arabic (العربية) (ar_AR) | ![61%](https://geps.dev/progress/61) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![62%](https://geps.dev/progress/62) |
| Basque (Euskara) (eu_ES) | ![36%](https://geps.dev/progress/36) |
| Bulgarian (Български) (bg_BG) | ![68%](https://geps.dev/progress/68) |
| Catalan (Català) (ca_CA) | ![68%](https://geps.dev/progress/68) |
| Croatian (Hrvatski) (hr_HR) | ![60%](https://geps.dev/progress/60) |
| Czech (Česky) (cs_CZ) | ![70%](https://geps.dev/progress/70) |
| Danish (Dansk) (da_DK) | ![61%](https://geps.dev/progress/61) |
| Dutch (Nederlands) (nl_NL) | ![60%](https://geps.dev/progress/60) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![82%](https://geps.dev/progress/82) |
| German (Deutsch) (de_DE) | ![84%](https://geps.dev/progress/84) |
| Greek (Ελληνικά) (el_GR) | ![34%](https://geps.dev/progress/34) |
| Hindi (हिंदी) (hi_IN) | ![34%](https://geps.dev/progress/34) |
| Hungarian (Magyar) (hu_HU) | ![38%](https://geps.dev/progress/38) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![31%](https://geps.dev/progress/31) |
| Irish (Gaeilge) (ga_IE) | ![34%](https://geps.dev/progress/34) |
| Italian (Italiano) (it_IT) | ![84%](https://geps.dev/progress/84) |
| Japanese (日本語) (ja_JP) | ![62%](https://geps.dev/progress/62) |
| Korean (한국어) (ko_KR) | ![34%](https://geps.dev/progress/34) |
| Norwegian (Norsk) (no_NB) | ![32%](https://geps.dev/progress/32) |
| Persian (فارسی) (fa_IR) | ![34%](https://geps.dev/progress/34) |
| Polish (Polski) (pl_PL) | ![36%](https://geps.dev/progress/36) |
| Portuguese (Português) (pt_PT) | ![34%](https://geps.dev/progress/34) |
| Portuguese Brazilian (Português) (pt_BR) | ![83%](https://geps.dev/progress/83) |
| Romanian (Română) (ro_RO) | ![28%](https://geps.dev/progress/28) |
| Russian (Русский) (ru_RU) | ![83%](https://geps.dev/progress/83) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![37%](https://geps.dev/progress/37) |
| Simplified Chinese (简体中文) (zh_CN) | ![85%](https://geps.dev/progress/85) |
| Slovakian (Slovensky) (sk_SK) | ![26%](https://geps.dev/progress/26) |
| Slovenian (Slovenščina) (sl_SI) | ![36%](https://geps.dev/progress/36) |
| Spanish (Español) (es_ES) | ![84%](https://geps.dev/progress/84) |
| Swedish (Svenska) (sv_SE) | ![33%](https://geps.dev/progress/33) |
| Thai (ไทย) (th_TH) | ![31%](https://geps.dev/progress/31) |
| French (Français) (fr_FR) | ![89%](https://geps.dev/progress/89) |
| German (Deutsch) (de_DE) | ![98%](https://geps.dev/progress/98) |
| Greek (Ελληνικά) (el_GR) | ![67%](https://geps.dev/progress/67) |
| Hindi (हिंदी) (hi_IN) | ![67%](https://geps.dev/progress/67) |
| Hungarian (Magyar) (hu_HU) | ![99%](https://geps.dev/progress/99) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![62%](https://geps.dev/progress/62) |
| Irish (Gaeilge) (ga_IE) | ![68%](https://geps.dev/progress/68) |
| Italian (Italiano) (it_IT) | ![98%](https://geps.dev/progress/98) |
| Japanese (日本語) (ja_JP) | ![93%](https://geps.dev/progress/93) |
| Korean (한국어) (ko_KR) | ![67%](https://geps.dev/progress/67) |
| Norwegian (Norsk) (no_NB) | ![66%](https://geps.dev/progress/66) |
| Persian (فارسی) (fa_IR) | ![64%](https://geps.dev/progress/64) |
| Polish (Polski) (pl_PL) | ![72%](https://geps.dev/progress/72) |
| Portuguese (Português) (pt_PT) | ![69%](https://geps.dev/progress/69) |
| Portuguese Brazilian (Português) (pt_BR) | ![76%](https://geps.dev/progress/76) |
| Romanian (Română) (ro_RO) | ![57%](https://geps.dev/progress/57) |
| Russian (Русский) (ru_RU) | ![88%](https://geps.dev/progress/88) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![95%](https://geps.dev/progress/95) |
| Simplified Chinese (简体中文) (zh_CN) | ![93%](https://geps.dev/progress/93) |
| Slovakian (Slovensky) (sk_SK) | ![51%](https://geps.dev/progress/51) |
| Slovenian (Slovenščina) (sl_SI) | ![71%](https://geps.dev/progress/71) |
| Spanish (Español) (es_ES) | ![74%](https://geps.dev/progress/74) |
| Swedish (Svenska) (sv_SE) | ![65%](https://geps.dev/progress/65) |
| Thai (ไทย) (th_TH) | ![59%](https://geps.dev/progress/59) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) |
| Traditional Chinese (繁體中文) (zh_TW) | ![38%](https://geps.dev/progress/38) |
| Turkish (Türkçe) (tr_TR) | ![37%](https://geps.dev/progress/37) |
| Ukrainian (Українська) (uk_UA) | ![36%](https://geps.dev/progress/36) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![28%](https://geps.dev/progress/28) |
| Traditional Chinese (繁體中文) (zh_TW) | ![97%](https://geps.dev/progress/97) |
| Turkish (Türkçe) (tr_TR) | ![80%](https://geps.dev/progress/80) |
| Ukrainian (Українська) (uk_UA) | ![71%](https://geps.dev/progress/71) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![57%](https://geps.dev/progress/57) |
| Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) |
## Stirling PDF Enterprise
@@ -74,7 +74,8 @@ public class AppConfig {
@Bean(name = "appName")
public String appName() {
return "Stirling PDF";
String homeTitle = applicationProperties.getUi().getAppName();
return (homeTitle != null) ? homeTitle : "Stirling PDF";
}
@Bean(name = "appVersion")
@@ -92,7 +93,9 @@ public class AppConfig {
@Bean(name = "homeText")
public String homeText() {
return "null";
return (applicationProperties.getUi().getHomeDescription() != null)
? applicationProperties.getUi().getHomeDescription()
: "null";
}
@Bean(name = "languages")
@@ -107,8 +110,11 @@ public class AppConfig {
@Bean(name = "navBarText")
public String navBarText() {
String navBar = applicationProperties.getUi().getAppNameNavbar();
return (navBar != null) ? navBar : "Stirling PDF";
String defaultNavBar =
applicationProperties.getUi().getAppNameNavbar() != null
? applicationProperties.getUi().getAppNameNavbar()
: applicationProperties.getUi().getAppName();
return (defaultNavBar != null) ? defaultNavBar : "Stirling PDF";
}
@Bean(name = "enableAlphaFunctionality")
@@ -252,6 +258,12 @@ public class AppConfig {
return false;
}
@Bean(name = "GoogleDriveEnabled")
@Profile("default")
public boolean googleDriveEnabled() {
return false;
}
@Bean(name = "license")
@Profile("default")
public String licenseType() {
@@ -120,7 +120,6 @@ public class ApplicationProperties {
private String loginMethod = "all";
private String customGlobalAPIKey;
private Jwt jwt = new Jwt();
private Validation validation = new Validation();
public Boolean isAltLogin() {
return saml2.getEnabled() || oauth2.getEnabled();
@@ -307,41 +306,7 @@ public class ApplicationProperties {
private boolean enableKeyRotation = false;
private boolean enableKeyCleanup = true;
private int keyRetentionDays = 7;
}
@Data
public static class Validation {
private Trust trust = new Trust();
private boolean allowAIA = false;
private Aatl aatl = new Aatl();
private Eutl eutl = new Eutl();
private Revocation revocation = new Revocation();
@Data
public static class Trust {
private boolean serverAsAnchor = true;
private boolean useSystemTrust = false;
private boolean useMozillaBundle = false;
private boolean useAATL = false;
private boolean useEUTL = false;
}
@Data
public static class Aatl {
private String url = "https://trustlist.adobe.com/tl.pdf";
}
@Data
public static class Eutl {
private String lotlUrl = "https://ec.europa.eu/tools/lotl/eu-lotl.xml";
private boolean acceptTransitional = false;
}
@Data
public static class Revocation {
private String mode = "none";
private boolean hardFail = false;
}
private boolean secureCookie;
}
}
@@ -355,8 +320,6 @@ public class ApplicationProperties {
private String tessdataDir;
private Boolean enableAlphaFunctionality;
private Boolean enableAnalytics;
private Boolean enablePosthog;
private Boolean enableScarf;
private Datasource datasource;
private Boolean disableSanitize;
private int maxDPI;
@@ -365,27 +328,10 @@ public class ApplicationProperties {
private CustomPaths customPaths = new CustomPaths();
private String fileUploadLimit;
private TempFileManagement tempFileManagement = new TempFileManagement();
private List<String> corsAllowedOrigins = new ArrayList<>();
private String
frontendUrl; // Base URL for frontend (used for invite links, etc.). If not set,
// falls back to backend URL.
public boolean isAnalyticsEnabled() {
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
}
public boolean isPosthogEnabled() {
// Treat null as enabled when analytics is enabled
return this.isAnalyticsEnabled()
&& (this.getEnablePosthog() == null || this.getEnablePosthog());
}
public boolean isScarfEnabled() {
// Treat null as enabled when analytics is enabled
return this.isAnalyticsEnabled()
&& (this.getEnableScarf() == null || this.getEnableScarf());
}
}
@Data
@@ -494,9 +440,21 @@ public class ApplicationProperties {
@Data
public static class Ui {
private String appName;
private String homeDescription;
private String appNameNavbar;
private List<String> languages;
public String getAppName() {
return appName != null && appName.trim().length() > 0 ? appName : null;
}
public String getHomeDescription() {
return homeDescription != null && homeDescription.trim().length() > 0
? homeDescription
: null;
}
public String getAppNameNavbar() {
return appNameNavbar != null && appNameNavbar.trim().length() > 0
? appNameNavbar
@@ -552,8 +510,6 @@ public class ApplicationProperties {
@Data
public static class Mail {
private boolean enabled;
private boolean enableInvites = false;
private int inviteLinkExpiryHours = 72; // Default: 72 hours (3 days)
private String host;
private int port;
private String username;
@@ -574,6 +530,7 @@ public class ApplicationProperties {
private boolean ssoAutoLogin;
private boolean database;
private CustomMetadata customMetadata = new CustomMetadata();
private GoogleDrive googleDrive = new GoogleDrive();
@Data
public static class CustomMetadata {
@@ -592,6 +549,26 @@ public class ApplicationProperties {
: producer;
}
}
@Data
public static class GoogleDrive {
private boolean enabled;
private String clientId;
private String apiKey;
private String appId;
public String getClientId() {
return clientId == null || clientId.trim().isEmpty() ? "" : clientId;
}
public String getApiKey() {
return apiKey == null || apiKey.trim().isEmpty() ? "" : apiKey;
}
public String getAppId() {
return appId == null || appId.trim().isEmpty() ? "" : appId;
}
}
}
@Data
@@ -56,7 +56,7 @@ public class PostHogService {
}
private void captureSystemInfo() {
if (!applicationProperties.getSystem().isPosthogEnabled()) {
if (!applicationProperties.getSystem().isAnalyticsEnabled()) {
return;
}
try {
@@ -67,7 +67,7 @@ public class PostHogService {
}
public void captureEvent(String eventName, Map<String, Object> properties) {
if (!applicationProperties.getSystem().isPosthogEnabled()) {
if (!applicationProperties.getSystem().isAnalyticsEnabled()) {
return;
}
@@ -325,16 +325,13 @@ public class PostHogService {
properties,
"system_enableAnalytics",
applicationProperties.getSystem().isAnalyticsEnabled());
addIfNotEmpty(
properties,
"system_enablePosthog",
applicationProperties.getSystem().isPosthogEnabled());
addIfNotEmpty(
properties,
"system_enableScarf",
applicationProperties.getSystem().isScarfEnabled());
// Capture UI properties
addIfNotEmpty(properties, "ui_appName", applicationProperties.getUi().getAppName());
addIfNotEmpty(
properties,
"ui_homeDescription",
applicationProperties.getUi().getHomeDescription());
addIfNotEmpty(
properties, "ui_appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
@@ -6,6 +6,4 @@ public interface UserServiceInterface {
String getCurrentUsername();
long getTotalUsersCount();
boolean isCurrentUserAdmin();
}
@@ -1,29 +0,0 @@
package stirling.software.common.util;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
/**
* Captures application command-line arguments at startup so they can be reused for restart
* operations. This allows the application to restart with the same configuration.
*/
@Slf4j
@Component
public class AppArgsCapture implements ApplicationRunner {
public static final AtomicReference<List<String>> APP_ARGS = new AtomicReference<>(List.of());
@Override
public void run(ApplicationArguments args) {
APP_ARGS.set(List.of(args.getSourceArgs()));
log.debug(
"Captured {} application arguments for restart capability",
args.getSourceArgs().length);
}
}
@@ -1,84 +0,0 @@
package stirling.software.common.util;
import java.io.File;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import lombok.extern.slf4j.Slf4j;
/** Utility class to locate JAR files at runtime for restart operations */
@Slf4j
public class JarPathUtil {
/**
* Gets the path to the currently running JAR file
*
* @return Path to the current JAR, or null if not running from a JAR
*/
public static Path currentJar() {
try {
Path jar =
Paths.get(
JarPathUtil.class
.getProtectionDomain()
.getCodeSource()
.getLocation()
.toURI())
.toAbsolutePath();
// Check if we're actually running from a JAR (not from IDE/classes directory)
if (jar.toString().endsWith(".jar")) {
log.debug("Current JAR located at: {}", jar);
return jar;
} else {
log.warn("Not running from JAR, current location: {}", jar);
return null;
}
} catch (URISyntaxException e) {
log.error("Failed to determine current JAR location", e);
return null;
}
}
/**
* Gets the path to the restart-helper.jar file Expected to be in the same directory as the main
* JAR
*
* @return Path to restart-helper.jar, or null if not found
*/
public static Path restartHelperJar() {
Path appJar = currentJar();
if (appJar == null) {
return null;
}
Path helperJar = appJar.getParent().resolve("restart-helper.jar");
if (Files.isRegularFile(helperJar)) {
log.debug("Restart helper JAR located at: {}", helperJar);
return helperJar;
} else {
log.warn("Restart helper JAR not found at: {}", helperJar);
return null;
}
}
/**
* Gets the java binary path for the current JVM
*
* @return Path to java executable
*/
public static String javaExecutable() {
String javaHome = System.getProperty("java.home");
String javaBin = javaHome + File.separator + "bin" + File.separator + "java";
// On Windows, add .exe extension
if (System.getProperty("os.name").toLowerCase().contains("win")) {
javaBin += ".exe";
}
return javaBin;
}
}
@@ -109,14 +109,38 @@ class ApplicationPropertiesLogicTest {
assertTrue(ex.getMessage().toLowerCase().contains("not supported"));
}
@Test
void premium_google_drive_getters_return_empty_string_on_null_or_blank() {
Premium.ProFeatures.GoogleDrive gd = new Premium.ProFeatures.GoogleDrive();
assertEquals("", gd.getClientId());
assertEquals("", gd.getApiKey());
assertEquals("", gd.getAppId());
gd.setClientId(" id ");
gd.setApiKey(" key ");
gd.setAppId(" app ");
assertEquals(" id ", gd.getClientId());
assertEquals(" key ", gd.getApiKey());
assertEquals(" app ", gd.getAppId());
}
@Test
void ui_getters_return_null_for_blank() {
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
ui.setAppName(" ");
ui.setHomeDescription("");
ui.setAppNameNavbar(null);
assertNull(ui.getAppName());
assertNull(ui.getHomeDescription());
assertNull(ui.getAppNameNavbar());
ui.setAppName("Stirling-PDF");
ui.setHomeDescription("Home");
ui.setAppNameNavbar("Nav");
assertEquals("Stirling-PDF", ui.getAppName());
assertEquals("Home", ui.getHomeDescription());
assertEquals("Nav", ui.getAppNameNavbar());
}
@@ -1,49 +1,22 @@
package stirling.software.SPDF.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final EndpointInterceptor endpointInterceptor;
private final ApplicationProperties applicationProperties;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(endpointInterceptor);
}
@Override
public void addCorsMappings(CorsRegistry registry) {
// Only configure CORS if allowed origins are specified
if (applicationProperties.getSystem() != null
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
String[] allowedOrigins =
applicationProperties
.getSystem()
.getCorsAllowedOrigins()
.toArray(new String[0]);
registry.addMapping("/**")
.allowedOrigins(allowedOrigins)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
// If no origins are configured, CORS is not enabled (secure by default)
}
// @Override
// public void addResourceHandlers(ResourceHandlerRegistry registry) {
// // Handler for external static resources - DISABLED in backend-only mode
@@ -1,15 +1,12 @@
package stirling.software.SPDF.controller.api;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
@@ -32,7 +29,7 @@ public class SettingsController {
@AutoJobPostMapping("/update-enable-analytics")
@Hidden
public ResponseEntity<String> updateApiKey(@RequestParam Boolean enabled) throws IOException {
public ResponseEntity<String> updateApiKey(@RequestBody Boolean enabled) throws IOException {
if (applicationProperties.getSystem().getEnableAnalytics() != null) {
return ResponseEntity.status(HttpStatus.ALREADY_REPORTED)
.body(
@@ -49,392 +46,4 @@ public class SettingsController {
public ResponseEntity<Map<String, Boolean>> getDisabledEndpoints() {
return ResponseEntity.ok(endpointConfiguration.getEndpointStatuses());
}
// ========== GENERAL SETTINGS ==========
@GetMapping("/admin/settings/general")
@Hidden
public ResponseEntity<Map<String, Object>> getGeneralSettings() {
Map<String, Object> settings = new HashMap<>();
settings.put("ui", applicationProperties.getUi());
settings.put(
"system",
Map.of(
"defaultLocale", applicationProperties.getSystem().getDefaultLocale(),
"showUpdate", applicationProperties.getSystem().isShowUpdate(),
"showUpdateOnlyAdmin",
applicationProperties.getSystem().getShowUpdateOnlyAdmin(),
"customHTMLFiles", applicationProperties.getSystem().isCustomHTMLFiles(),
"fileUploadLimit", applicationProperties.getSystem().getFileUploadLimit()));
return ResponseEntity.ok(settings);
}
@PostMapping("/admin/settings/general")
@Hidden
public ResponseEntity<String> updateGeneralSettings(@RequestBody Map<String, Object> settings)
throws IOException {
// Update UI settings
if (settings.containsKey("ui")) {
Map<String, String> ui = (Map<String, String>) settings.get("ui");
if (ui.containsKey("appNameNavbar")) {
GeneralUtils.saveKeyToSettings("ui.appNameNavbar", ui.get("appNameNavbar"));
applicationProperties.getUi().setAppNameNavbar(ui.get("appNameNavbar"));
}
}
// Update System settings
if (settings.containsKey("system")) {
Map<String, Object> system = (Map<String, Object>) settings.get("system");
if (system.containsKey("defaultLocale")) {
GeneralUtils.saveKeyToSettings("system.defaultLocale", system.get("defaultLocale"));
applicationProperties
.getSystem()
.setDefaultLocale((String) system.get("defaultLocale"));
}
if (system.containsKey("showUpdate")) {
GeneralUtils.saveKeyToSettings("system.showUpdate", system.get("showUpdate"));
applicationProperties.getSystem().setShowUpdate((Boolean) system.get("showUpdate"));
}
if (system.containsKey("showUpdateOnlyAdmin")) {
GeneralUtils.saveKeyToSettings(
"system.showUpdateOnlyAdmin", system.get("showUpdateOnlyAdmin"));
applicationProperties
.getSystem()
.setShowUpdateOnlyAdmin((Boolean) system.get("showUpdateOnlyAdmin"));
}
if (system.containsKey("fileUploadLimit")) {
GeneralUtils.saveKeyToSettings(
"system.fileUploadLimit", system.get("fileUploadLimit"));
applicationProperties
.getSystem()
.setFileUploadLimit((String) system.get("fileUploadLimit"));
}
}
return ResponseEntity.ok(
"General settings updated. Restart required for changes to take effect.");
}
// ========== SECURITY SETTINGS ==========
@GetMapping("/admin/settings/security")
@Hidden
public ResponseEntity<Map<String, Object>> getSecuritySettings() {
Map<String, Object> settings = new HashMap<>();
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());
settings.put(
"initialLogin",
Map.of(
"username",
security.getInitialLogin().getUsername() != null
? security.getInitialLogin().getUsername()
: ""));
// JWT settings
ApplicationProperties.Security.Jwt jwt = security.getJwt();
settings.put(
"jwt",
Map.of(
"enableKeystore", jwt.isEnableKeystore(),
"enableKeyRotation", jwt.isEnableKeyRotation(),
"enableKeyCleanup", jwt.isEnableKeyCleanup(),
"keyRetentionDays", jwt.getKeyRetentionDays()));
return ResponseEntity.ok(settings);
}
@PostMapping("/admin/settings/security")
@Hidden
public ResponseEntity<String> updateSecuritySettings(@RequestBody Map<String, Object> settings)
throws IOException {
if (settings.containsKey("enableLogin")) {
GeneralUtils.saveKeyToSettings("security.enableLogin", settings.get("enableLogin"));
applicationProperties
.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
.getSecurity()
.setLoginMethod((String) settings.get("loginMethod"));
}
if (settings.containsKey("loginAttemptCount")) {
GeneralUtils.saveKeyToSettings(
"security.loginAttemptCount", settings.get("loginAttemptCount"));
applicationProperties
.getSecurity()
.setLoginAttemptCount((Integer) settings.get("loginAttemptCount"));
}
if (settings.containsKey("loginResetTimeMinutes")) {
GeneralUtils.saveKeyToSettings(
"security.loginResetTimeMinutes", settings.get("loginResetTimeMinutes"));
applicationProperties
.getSecurity()
.setLoginResetTimeMinutes(
((Number) settings.get("loginResetTimeMinutes")).longValue());
}
// JWT settings
if (settings.containsKey("jwt")) {
Map<String, Object> jwt = (Map<String, Object>) settings.get("jwt");
if (jwt.containsKey("keyRetentionDays")) {
GeneralUtils.saveKeyToSettings(
"security.jwt.keyRetentionDays", jwt.get("keyRetentionDays"));
applicationProperties
.getSecurity()
.getJwt()
.setKeyRetentionDays((Integer) jwt.get("keyRetentionDays"));
}
}
return ResponseEntity.ok(
"Security settings updated. Restart required for changes to take effect.");
}
// ========== CONNECTIONS SETTINGS (OAuth/SAML) ==========
@GetMapping("/admin/settings/connections")
@Hidden
public ResponseEntity<Map<String, Object>> getConnectionsSettings() {
Map<String, Object> settings = new HashMap<>();
ApplicationProperties.Security security = applicationProperties.getSecurity();
// OAuth2 settings
ApplicationProperties.Security.OAUTH2 oauth2 = security.getOauth2();
settings.put(
"oauth2",
Map.of(
"enabled", oauth2.getEnabled(),
"issuer", oauth2.getIssuer() != null ? oauth2.getIssuer() : "",
"clientId", oauth2.getClientId() != null ? oauth2.getClientId() : "",
"provider", oauth2.getProvider() != null ? oauth2.getProvider() : "",
"autoCreateUser", oauth2.getAutoCreateUser(),
"blockRegistration", oauth2.getBlockRegistration(),
"useAsUsername",
oauth2.getUseAsUsername() != null
? oauth2.getUseAsUsername()
: ""));
// SAML2 settings
ApplicationProperties.Security.SAML2 saml2 = security.getSaml2();
settings.put(
"saml2",
Map.of(
"enabled", saml2.getEnabled(),
"provider", saml2.getProvider() != null ? saml2.getProvider() : "",
"autoCreateUser", saml2.getAutoCreateUser(),
"blockRegistration", saml2.getBlockRegistration(),
"registrationId", saml2.getRegistrationId()));
return ResponseEntity.ok(settings);
}
@PostMapping("/admin/settings/connections")
@Hidden
public ResponseEntity<String> updateConnectionsSettings(
@RequestBody Map<String, Object> settings) throws IOException {
// OAuth2 settings
if (settings.containsKey("oauth2")) {
Map<String, Object> oauth2 = (Map<String, Object>) settings.get("oauth2");
if (oauth2.containsKey("enabled")) {
GeneralUtils.saveKeyToSettings("security.oauth2.enabled", oauth2.get("enabled"));
applicationProperties
.getSecurity()
.getOauth2()
.setEnabled((Boolean) oauth2.get("enabled"));
}
if (oauth2.containsKey("issuer")) {
GeneralUtils.saveKeyToSettings("security.oauth2.issuer", oauth2.get("issuer"));
applicationProperties
.getSecurity()
.getOauth2()
.setIssuer((String) oauth2.get("issuer"));
}
if (oauth2.containsKey("clientId")) {
GeneralUtils.saveKeyToSettings("security.oauth2.clientId", oauth2.get("clientId"));
applicationProperties
.getSecurity()
.getOauth2()
.setClientId((String) oauth2.get("clientId"));
}
if (oauth2.containsKey("clientSecret")) {
GeneralUtils.saveKeyToSettings(
"security.oauth2.clientSecret", oauth2.get("clientSecret"));
applicationProperties
.getSecurity()
.getOauth2()
.setClientSecret((String) oauth2.get("clientSecret"));
}
if (oauth2.containsKey("provider")) {
GeneralUtils.saveKeyToSettings("security.oauth2.provider", oauth2.get("provider"));
applicationProperties
.getSecurity()
.getOauth2()
.setProvider((String) oauth2.get("provider"));
}
if (oauth2.containsKey("autoCreateUser")) {
GeneralUtils.saveKeyToSettings(
"security.oauth2.autoCreateUser", oauth2.get("autoCreateUser"));
applicationProperties
.getSecurity()
.getOauth2()
.setAutoCreateUser((Boolean) oauth2.get("autoCreateUser"));
}
if (oauth2.containsKey("blockRegistration")) {
GeneralUtils.saveKeyToSettings(
"security.oauth2.blockRegistration", oauth2.get("blockRegistration"));
applicationProperties
.getSecurity()
.getOauth2()
.setBlockRegistration((Boolean) oauth2.get("blockRegistration"));
}
if (oauth2.containsKey("useAsUsername")) {
GeneralUtils.saveKeyToSettings(
"security.oauth2.useAsUsername", oauth2.get("useAsUsername"));
applicationProperties
.getSecurity()
.getOauth2()
.setUseAsUsername((String) oauth2.get("useAsUsername"));
}
}
// SAML2 settings
if (settings.containsKey("saml2")) {
Map<String, Object> saml2 = (Map<String, Object>) settings.get("saml2");
if (saml2.containsKey("enabled")) {
GeneralUtils.saveKeyToSettings("security.saml2.enabled", saml2.get("enabled"));
applicationProperties
.getSecurity()
.getSaml2()
.setEnabled((Boolean) saml2.get("enabled"));
}
if (saml2.containsKey("provider")) {
GeneralUtils.saveKeyToSettings("security.saml2.provider", saml2.get("provider"));
applicationProperties
.getSecurity()
.getSaml2()
.setProvider((String) saml2.get("provider"));
}
if (saml2.containsKey("autoCreateUser")) {
GeneralUtils.saveKeyToSettings(
"security.saml2.autoCreateUser", saml2.get("autoCreateUser"));
applicationProperties
.getSecurity()
.getSaml2()
.setAutoCreateUser((Boolean) saml2.get("autoCreateUser"));
}
if (saml2.containsKey("blockRegistration")) {
GeneralUtils.saveKeyToSettings(
"security.saml2.blockRegistration", saml2.get("blockRegistration"));
applicationProperties
.getSecurity()
.getSaml2()
.setBlockRegistration((Boolean) saml2.get("blockRegistration"));
}
}
return ResponseEntity.ok(
"Connection settings updated. Restart required for changes to take effect.");
}
// ========== PRIVACY SETTINGS ==========
@GetMapping("/admin/settings/privacy")
@Hidden
public ResponseEntity<Map<String, Object>> getPrivacySettings() {
Map<String, Object> settings = new HashMap<>();
settings.put("enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
settings.put("googleVisibility", applicationProperties.getSystem().getGooglevisibility());
settings.put("metricsEnabled", applicationProperties.getMetrics().getEnabled());
return ResponseEntity.ok(settings);
}
@PostMapping("/admin/settings/privacy")
@Hidden
public ResponseEntity<String> updatePrivacySettings(@RequestBody Map<String, Object> settings)
throws IOException {
if (settings.containsKey("enableAnalytics")) {
GeneralUtils.saveKeyToSettings(
"system.enableAnalytics", settings.get("enableAnalytics"));
applicationProperties
.getSystem()
.setEnableAnalytics((Boolean) settings.get("enableAnalytics"));
}
if (settings.containsKey("googleVisibility")) {
GeneralUtils.saveKeyToSettings(
"system.googlevisibility", settings.get("googleVisibility"));
applicationProperties
.getSystem()
.setGooglevisibility((Boolean) settings.get("googleVisibility"));
}
if (settings.containsKey("metricsEnabled")) {
GeneralUtils.saveKeyToSettings("metrics.enabled", settings.get("metricsEnabled"));
applicationProperties.getMetrics().setEnabled((Boolean) settings.get("metricsEnabled"));
}
return ResponseEntity.ok(
"Privacy settings updated. Restart required for changes to take effect.");
}
// ========== ADVANCED SETTINGS ==========
@GetMapping("/admin/settings/advanced")
@Hidden
public ResponseEntity<Map<String, Object>> getAdvancedSettings() {
Map<String, Object> settings = new HashMap<>();
settings.put("endpoints", applicationProperties.getEndpoints());
settings.put(
"enableAlphaFunctionality",
applicationProperties.getSystem().getEnableAlphaFunctionality());
settings.put("maxDPI", applicationProperties.getSystem().getMaxDPI());
settings.put("enableUrlToPDF", applicationProperties.getSystem().getEnableUrlToPDF());
settings.put("customPaths", applicationProperties.getSystem().getCustomPaths());
settings.put(
"tempFileManagement", applicationProperties.getSystem().getTempFileManagement());
return ResponseEntity.ok(settings);
}
@PostMapping("/admin/settings/advanced")
@Hidden
public ResponseEntity<String> updateAdvancedSettings(@RequestBody Map<String, Object> settings)
throws IOException {
if (settings.containsKey("enableAlphaFunctionality")) {
GeneralUtils.saveKeyToSettings(
"system.enableAlphaFunctionality", settings.get("enableAlphaFunctionality"));
applicationProperties
.getSystem()
.setEnableAlphaFunctionality(
(Boolean) settings.get("enableAlphaFunctionality"));
}
if (settings.containsKey("maxDPI")) {
GeneralUtils.saveKeyToSettings("system.maxDPI", settings.get("maxDPI"));
applicationProperties.getSystem().setMaxDPI((Integer) settings.get("maxDPI"));
}
if (settings.containsKey("enableUrlToPDF")) {
GeneralUtils.saveKeyToSettings("system.enableUrlToPDF", settings.get("enableUrlToPDF"));
applicationProperties
.getSystem()
.setEnableUrlToPDF((Boolean) settings.get("enableUrlToPDF"));
}
return ResponseEntity.ok(
"Advanced settings updated. Restart required for changes to take effect.");
}
}
@@ -15,7 +15,6 @@ import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.common.service.UserServiceInterface;
@ConfigApi
@Hidden
@@ -25,21 +24,17 @@ public class ConfigController {
private final ApplicationContext applicationContext;
private final EndpointConfiguration endpointConfiguration;
private final ServerCertificateServiceInterface serverCertificateService;
private final UserServiceInterface userService;
public ConfigController(
ApplicationProperties applicationProperties,
ApplicationContext applicationContext,
EndpointConfiguration endpointConfiguration,
@org.springframework.beans.factory.annotation.Autowired(required = false)
ServerCertificateServiceInterface serverCertificateService,
@org.springframework.beans.factory.annotation.Autowired(required = false)
UserServiceInterface userService) {
ServerCertificateServiceInterface serverCertificateService) {
this.applicationProperties = applicationProperties;
this.applicationContext = applicationContext;
this.endpointConfiguration = endpointConfiguration;
this.serverCertificateService = serverCertificateService;
this.userService = userService;
}
@GetMapping("/app-config")
@@ -56,36 +51,20 @@ public class ConfigController {
configData.put("serverPort", appConfig.getServerPort());
// Extract values from ApplicationProperties
configData.put("appName", applicationProperties.getUi().getAppName());
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
configData.put("homeDescription", applicationProperties.getUi().getHomeDescription());
configData.put("languages", applicationProperties.getUi().getLanguages());
// Security settings
configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin());
// Mail settings - check both SMTP enabled AND invites enabled
boolean smtpEnabled = applicationProperties.getMail().isEnabled();
boolean invitesEnabled = applicationProperties.getMail().isEnableInvites();
configData.put("enableEmailInvites", smtpEnabled && invitesEnabled);
// Check if user is admin using UserServiceInterface
boolean isAdmin = false;
if (userService != null) {
try {
isAdmin = userService.isCurrentUserAdmin();
} catch (Exception e) {
// If there's an error, isAdmin remains false
}
}
configData.put("isAdmin", isAdmin);
// System settings
configData.put(
"enableAlphaFunctionality",
applicationProperties.getSystem().getEnableAlphaFunctionality());
configData.put(
"enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
configData.put("enablePosthog", applicationProperties.getSystem().getEnablePosthog());
configData.put("enableScarf", applicationProperties.getSystem().getEnableScarf());
// Premium/Enterprise settings
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
@@ -119,6 +98,11 @@ public class ConfigController {
if (applicationContext.containsBean("license")) {
configData.put("license", applicationContext.getBean("license", String.class));
}
if (applicationContext.containsBean("GoogleDriveEnabled")) {
configData.put(
"GoogleDriveEnabled",
applicationContext.getBean("GoogleDriveEnabled", Boolean.class));
}
if (applicationContext.containsBean("SSOAutoLogin")) {
configData.put(
"SSOAutoLogin",
@@ -5,11 +5,9 @@ import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.PKIXCertPathBuilderResult;
import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPublicKey;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.List;
@@ -34,7 +32,6 @@ import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.JsonDataResponse;
import stirling.software.SPDF.model.api.security.SignatureValidationRequest;
@@ -45,7 +42,6 @@ import stirling.software.common.annotations.api.SecurityApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
@Slf4j
@SecurityApi
@RequiredArgsConstructor
public class ValidateSignatureController {
@@ -69,9 +65,8 @@ public class ValidateSignatureController {
@Operation(
summary = "Validate PDF Digital Signature",
description =
"Validates the digital signatures in a PDF file using PKIX path building"
+ " and time-of-signing semantics. Supports custom trust anchors."
+ " Input:PDF Output:JSON Type:SISO")
"Validates the digital signatures in a PDF file against default or custom"
+ " certificates. Input:PDF Output:JSON Type:SISO")
@AutoJobPostMapping(
value = "/validate-signature",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -79,12 +74,12 @@ public class ValidateSignatureController {
@ModelAttribute SignatureValidationRequest request) throws IOException {
List<SignatureValidationResult> results = new ArrayList<>();
MultipartFile file = request.getFileInput();
MultipartFile certFile = request.getCertFile();
// Load custom certificate if provided
X509Certificate customCert = null;
if (request.getCertFile() != null && !request.getCertFile().isEmpty()) {
try (ByteArrayInputStream certStream =
new ByteArrayInputStream(request.getCertFile().getBytes())) {
if (certFile != null && !certFile.isEmpty()) {
try (ByteArrayInputStream certStream = new ByteArrayInputStream(certFile.getBytes())) {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
customCert = (X509Certificate) cf.generateCertificate(certStream);
} catch (CertificateException e) {
@@ -113,150 +108,67 @@ public class ValidateSignatureController {
Store<X509CertificateHolder> certStore = signedData.getCertificates();
SignerInformationStore signerStore = signedData.getSignerInfos();
for (SignerInformation signerInfo : signerStore.getSigners()) {
for (SignerInformation signer : signerStore.getSigners()) {
X509CertificateHolder certHolder =
(X509CertificateHolder)
certStore.getMatches(signerInfo.getSID()).iterator().next();
X509Certificate signerCert =
certStore.getMatches(signer.getSID()).iterator().next();
X509Certificate cert =
new JcaX509CertificateConverter().getCertificate(certHolder);
// Extract intermediate certificates from CMS
Collection<X509Certificate> intermediates =
certValidationService.extractIntermediateCertificates(
certStore, signerCert);
boolean isValid =
signer.verify(new JcaSimpleSignerInfoVerifierBuilder().build(cert));
result.setValid(isValid);
// Log what we found
log.debug(
"Found {} intermediate certificates in CMS signature",
intermediates.size());
for (X509Certificate inter : intermediates) {
log.debug(
" → Intermediate: {}",
inter.getSubjectX500Principal().getName());
log.debug(
" Issuer DN: {}", inter.getIssuerX500Principal().getName());
}
// Additional validations
result.setChainValid(
customCert != null
? certValidationService
.validateCertificateChainWithCustomCert(
cert, customCert)
: certValidationService.validateCertificateChain(cert));
// Determine validation time (TSA timestamp or signingTime, or current)
CertificateValidationService.ValidationTime validationTimeResult =
certValidationService.extractValidationTime(signerInfo);
Date validationTime;
if (validationTimeResult == null) {
validationTime = new Date();
result.setValidationTimeSource("current");
} else {
validationTime = validationTimeResult.date;
result.setValidationTimeSource(validationTimeResult.source);
}
result.setTrustValid(
customCert != null
? certValidationService.validateTrustWithCustomCert(
cert, customCert)
: certValidationService.validateTrustStore(cert));
// Verify cryptographic signature
boolean cmsValid =
signerInfo.verify(
new JcaSimpleSignerInfoVerifierBuilder().build(signerCert));
result.setValid(cmsValid);
// Build and validate certificate path
boolean chainValid = false;
boolean trustValid = false;
try {
PKIXCertPathBuilderResult pathResult =
certValidationService.buildAndValidatePath(
signerCert, intermediates, customCert, validationTime);
chainValid = true;
trustValid = true; // Path ends at trust anchor
result.setCertPathLength(
pathResult.getCertPath().getCertificates().size());
} catch (Exception e) {
String errorMsg = e.getMessage();
result.setChainValidationError(errorMsg);
chainValid = false;
trustValid = false;
// Log the full error for debugging
log.warn(
"Certificate path validation failed for {}: {}",
signerCert.getSubjectX500Principal().getName(),
errorMsg);
log.debug("Full stack trace:", e);
}
result.setChainValid(chainValid);
result.setTrustValid(trustValid);
// Check validity at validation time
boolean outside =
certValidationService.isOutsideValidityPeriod(
signerCert, validationTime);
result.setNotExpired(!outside);
// Revocation status determination
boolean revocationEnabled = certValidationService.isRevocationEnabled();
result.setRevocationChecked(revocationEnabled);
if (!revocationEnabled) {
result.setRevocationStatus("not-checked");
} else if (chainValid && trustValid) {
// Path building succeeded with revocation enabled = no revocation found
result.setRevocationStatus("good");
} else if (result.getChainValidationError() != null
&& result.getChainValidationError()
.toLowerCase()
.contains("revocation")) {
// Check if failure was revocation-related
if (result.getChainValidationError()
.toLowerCase()
.contains("unable to check")) {
result.setRevocationStatus("soft-fail");
} else {
result.setRevocationStatus("revoked");
}
} else {
result.setRevocationStatus("unknown");
}
result.setNotRevoked(!certValidationService.isRevoked(cert));
result.setNotExpired(!cert.getNotAfter().before(new Date()));
// Set basic signature info
result.setSignerName(sig.getName());
result.setSignatureDate(
sig.getSignDate() != null
? sig.getSignDate().getTime().toString()
: null);
result.setSignatureDate(sig.getSignDate().getTime().toString());
result.setReason(sig.getReason());
result.setLocation(sig.getLocation());
// Set certificate details (from signer cert)
result.setIssuerDN(signerCert.getIssuerX500Principal().getName());
result.setSubjectDN(signerCert.getSubjectX500Principal().getName());
result.setSerialNumber(
signerCert.getSerialNumber().toString(16)); // Hex format
result.setValidFrom(signerCert.getNotBefore().toString());
result.setValidUntil(signerCert.getNotAfter().toString());
result.setSignatureAlgorithm(signerCert.getSigAlgName());
// Set new certificate details
result.setIssuerDN(cert.getIssuerX500Principal().getName());
result.setSubjectDN(cert.getSubjectX500Principal().getName());
result.setSerialNumber(cert.getSerialNumber().toString(16)); // Hex format
result.setValidFrom(cert.getNotBefore().toString());
result.setValidUntil(cert.getNotAfter().toString());
result.setSignatureAlgorithm(cert.getSigAlgName());
// Get key size (if possible)
try {
result.setKeySize(
((RSAPublicKey) signerCert.getPublicKey())
.getModulus()
.bitLength());
((RSAPublicKey) cert.getPublicKey()).getModulus().bitLength());
} catch (Exception e) {
// If not RSA or error, set to 0
result.setKeySize(0);
}
result.setVersion(String.valueOf(signerCert.getVersion()));
result.setVersion(String.valueOf(cert.getVersion()));
// Set key usage
List<String> keyUsages = new ArrayList<>();
boolean[] keyUsageFlags = signerCert.getKeyUsage();
boolean[] keyUsageFlags = cert.getKeyUsage();
if (keyUsageFlags != null) {
String[] keyUsageLabels = {
"Digital Signature",
"Non-Repudiation",
"Key Encipherment",
"Data Encipherment",
"Key Agreement",
"Certificate Signing",
"CRL Signing",
"Encipher Only",
"Decipher Only"
"Digital Signature", "Non-Repudiation", "Key Encipherment",
"Data Encipherment", "Key Agreement", "Certificate Signing",
"CRL Signing", "Encipher Only", "Decipher Only"
};
for (int i = 0; i < keyUsageFlags.length; i++) {
if (keyUsageFlags[i]) {
@@ -266,8 +178,10 @@ public class ValidateSignatureController {
}
result.setKeyUsages(keyUsages);
// Check if self-signed (properly)
result.setSelfSigned(certValidationService.isSelfSigned(signerCert));
// Check if self-signed
result.setSelfSigned(
cert.getSubjectX500Principal()
.equals(cert.getIssuerX500Principal()));
}
} catch (Exception e) {
result.setValid(false);
@@ -6,32 +6,17 @@ import lombok.Data;
@Data
public class SignatureValidationResult {
// Cryptographic signature validation
private boolean valid;
// Certificate chain validation
private boolean chainValid;
private boolean trustValid;
private String chainValidationError;
private int certPathLength;
// Time validation
private boolean notExpired;
// Revocation validation
private boolean revocationChecked; // true if PKIX revocation was enabled
private String revocationStatus; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown"
private String validationTimeSource; // "current", "signing-time", or "timestamp"
// Signature metadata
private String signerName;
private String signatureDate;
private String reason;
private String location;
private String errorMessage;
private boolean chainValid;
private boolean trustValid;
private boolean notExpired;
private boolean notRevoked;
// Certificate details
private String issuerDN; // Certificate issuer's Distinguished Name
private String subjectDN; // Certificate subject's Distinguished Name
private String serialNumber; // Certificate serial number
@@ -1,863 +1,143 @@
package stirling.software.SPDF.service;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.MessageDigest;
import java.security.KeyStoreException;
import java.security.cert.*;
import java.util.*;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary;
import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode;
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
import org.bouncycastle.asn1.ASN1Encodable;
import org.bouncycastle.asn1.ASN1GeneralizedTime;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.ASN1UTCTime;
import org.bouncycastle.asn1.cms.CMSAttributes;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cms.CMSSignedData;
import org.bouncycastle.cms.SignerInformation;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.tsp.TimeStampToken;
import org.bouncycastle.util.Store;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
import io.github.pixee.security.BoundedLineReader;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.ServerCertificateServiceInterface;
@Service
@Slf4j
public class CertificateValidationService {
/**
* Result container for validation time extraction Contains both the date and the source of the
* time
*/
public static class ValidationTime {
public final Date date;
public final String source; // "timestamp" | "signing-time" | "current"
public ValidationTime(Date date, String source) {
this.date = date;
this.source = source;
}
}
// Separate trust stores: signing vs TLS
private KeyStore signingTrustAnchors; // AATL/EUTL + server cert for PDF signing
private final ServerCertificateServiceInterface serverCertificateService;
private final ApplicationProperties applicationProperties;
// EUTL (EU Trusted List) constants
private static final String NS_TSL = "http://uri.etsi.org/02231/v2#";
// Qualified CA service types to import as trust anchors (per ETSI TS 119 612)
private static final Set<String> EUTL_SERVICE_TYPES =
new HashSet<>(
Arrays.asList(
"http://uri.etsi.org/TrstSvc/Svctype/CA/QC",
"http://uri.etsi.org/TrstSvc/Svctype/NationalRootCA-QC"));
// Active statuses to accept (per ETSI TS 119 612)
private static final String STATUS_UNDER_SUPERVISION =
"http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/undersupervision";
private static final String STATUS_ACCREDITED =
"http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/accredited";
private static final String STATUS_SUPERVISION_IN_CESSATION =
"http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/supervisionincessation";
static {
if (java.security.Security.getProvider("BC") == null) {
java.security.Security.addProvider(new BouncyCastleProvider());
}
}
public CertificateValidationService(
@Autowired(required = false) ServerCertificateServiceInterface serverCertificateService,
ApplicationProperties applicationProperties) {
this.serverCertificateService = serverCertificateService;
this.applicationProperties = applicationProperties;
}
private KeyStore trustStore;
@PostConstruct
private void initializeTrustStore() throws Exception {
signingTrustAnchors = KeyStore.getInstance(KeyStore.getDefaultType());
signingTrustAnchors.load(null, null);
ApplicationProperties.Security.Validation validation =
applicationProperties.getSecurity().getValidation();
// Enable JDK fetching of OCSP/CRLDP if allowed
if (validation.isAllowAIA()) {
java.security.Security.setProperty("ocsp.enable", "true");
System.setProperty("com.sun.security.enableCRLDP", "true");
System.setProperty("com.sun.security.enableAIAcaIssuers", "true");
log.info("Enabled AIA certificate fetching and revocation checking");
}
// Trust only what we explicitly opt into:
if (validation.getTrust().isServerAsAnchor()) loadServerCertAsAnchor();
if (validation.getTrust().isUseSystemTrust()) loadJavaSystemTrustStore();
if (validation.getTrust().isUseMozillaBundle()) loadBundledMozillaCACerts();
if (validation.getTrust().isUseAATL()) loadAATLCertificates();
if (validation.getTrust().isUseEUTL()) loadEUTLCertificates();
trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
trustStore.load(null, null);
loadMozillaCertificates();
}
/**
* Core entry-point: build a valid PKIX path from signerCert using provided intermediates
*
* @param signerCert The signer certificate
* @param intermediates Collection of intermediate certificates from CMS
* @param customTrustAnchor Optional custom root/intermediate certificate
* @param validationTime Time to validate at (signing time or current)
* @return PKIXCertPathBuilderResult containing validated path
* @throws GeneralSecurityException if path building/validation fails
*/
public PKIXCertPathBuilderResult buildAndValidatePath(
X509Certificate signerCert,
Collection<X509Certificate> intermediates,
X509Certificate customTrustAnchor,
Date validationTime)
throws GeneralSecurityException {
private void loadMozillaCertificates() throws Exception {
try (InputStream is = getClass().getResourceAsStream("/certdata.txt")) {
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
String line;
StringBuilder certData = new StringBuilder();
boolean inCert = false;
int certCount = 0;
// Build trust anchors
Set<TrustAnchor> anchors = new HashSet<>();
if (customTrustAnchor != null) {
anchors.add(new TrustAnchor(customTrustAnchor, null));
} else {
Enumeration<String> aliases = signingTrustAnchors.aliases();
while (aliases.hasMoreElements()) {
Certificate c = signingTrustAnchors.getCertificate(aliases.nextElement());
if (c instanceof X509Certificate x) {
anchors.add(new TrustAnchor(x, null));
while ((line = BoundedLineReader.readLine(reader, 5_000_000)) != null) {
if (line.startsWith("CKA_VALUE MULTILINE_OCTAL")) {
inCert = true;
certData = new StringBuilder();
continue;
}
if (inCert) {
if ("END".equals(line)) {
inCert = false;
byte[] certBytes = parseOctalData(certData.toString());
if (certBytes != null) {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
X509Certificate cert =
(X509Certificate)
cf.generateCertificate(
new ByteArrayInputStream(certBytes));
trustStore.setCertificateEntry("mozilla-cert-" + certCount++, cert);
}
} else {
certData.append(line).append("\n");
}
}
}
}
if (anchors.isEmpty()) {
throw new CertPathBuilderException("No trust anchors available");
}
// Target certificate selector
X509CertSelector target = new X509CertSelector();
target.setCertificate(signerCert);
// Intermediate certificate store
List<Certificate> allCerts = new ArrayList<>(intermediates);
CertStore intermediateStore =
CertStore.getInstance("Collection", new CollectionCertStoreParameters(allCerts));
// PKIX parameters
PKIXBuilderParameters params = new PKIXBuilderParameters(anchors, target);
params.addCertStore(intermediateStore);
String revocationMode =
applicationProperties.getSecurity().getValidation().getRevocation().getMode();
params.setRevocationEnabled(!"none".equalsIgnoreCase(revocationMode));
if (validationTime != null) {
params.setDate(validationTime);
}
// Revocation checking
if (!"none".equalsIgnoreCase(revocationMode)) {
try {
PKIXRevocationChecker rc =
(PKIXRevocationChecker)
CertPathValidator.getInstance("PKIX").getRevocationChecker();
Set<PKIXRevocationChecker.Option> options =
EnumSet.noneOf(PKIXRevocationChecker.Option.class);
// Soft-fail: allow validation to succeed if revocation status unavailable
boolean revocationHardFail =
applicationProperties
.getSecurity()
.getValidation()
.getRevocation()
.isHardFail();
if (!revocationHardFail) {
options.add(PKIXRevocationChecker.Option.SOFT_FAIL);
}
// Revocation mode configuration
if ("ocsp".equalsIgnoreCase(revocationMode)) {
// OCSP-only: prefer OCSP (default), disable fallback to CRL
options.add(PKIXRevocationChecker.Option.NO_FALLBACK);
} else if ("crl".equalsIgnoreCase(revocationMode)) {
// CRL-only: prefer CRLs, disable fallback to OCSP
options.add(PKIXRevocationChecker.Option.PREFER_CRLS);
options.add(PKIXRevocationChecker.Option.NO_FALLBACK);
}
// "ocsp+crl" or other: use defaults (try OCSP first, fallback to CRL)
rc.setOptions(options);
params.addCertPathChecker(rc);
} catch (Exception e) {
log.warn("Failed to configure revocation checker: {}", e.getMessage());
}
}
// Build path
CertPathBuilder builder = CertPathBuilder.getInstance("PKIX");
return (PKIXCertPathBuilderResult) builder.build(params);
}
/**
* Extract validation time from signature (TSA timestamp or signingTime)
*
* @param signerInfo The CMS signer information
* @return ValidationTime containing date and source, or null if not found
*/
public ValidationTime extractValidationTime(SignerInformation signerInfo) {
private byte[] parseOctalData(String data) {
try {
// 1) Check for timestamp token (RFC 3161) - highest priority
var unsignedAttrs = signerInfo.getUnsignedAttributes();
if (unsignedAttrs != null) {
var attr =
unsignedAttrs.get(new ASN1ObjectIdentifier("1.2.840.113549.1.9.16.2.14"));
if (attr != null) {
try {
TimeStampToken tst =
new TimeStampToken(
new CMSSignedData(
attr.getAttributeValues()[0]
.toASN1Primitive()
.getEncoded()));
Date tstTime = tst.getTimeStampInfo().getGenTime();
log.debug("Using timestamp token time: {}", tstTime);
return new ValidationTime(tstTime, "timestamp");
} catch (Exception e) {
log.debug("Failed to parse timestamp token: {}", e.getMessage());
}
}
}
// 2) Check for signingTime attribute - fallback
var signedAttrs = signerInfo.getSignedAttributes();
if (signedAttrs != null) {
var st = signedAttrs.get(CMSAttributes.signingTime);
if (st != null) {
ASN1Encodable val = st.getAttributeValues()[0];
Date signingTime = null;
if (val instanceof ASN1UTCTime ut) {
signingTime = ut.getDate();
} else if (val instanceof ASN1GeneralizedTime gt) {
signingTime = gt.getDate();
}
if (signingTime != null) {
log.debug("Using signingTime attribute: {}", signingTime);
return new ValidationTime(signingTime, "signing-time");
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
String[] tokens = data.split("\\\\");
for (String token : tokens) {
token = token.trim();
if (!token.isEmpty()) {
baos.write(Integer.parseInt(token, 8));
}
}
return baos.toByteArray();
} catch (Exception e) {
log.debug("Error extracting validation time: {}", e.getMessage());
return null;
}
return null;
}
/**
* Check if certificate is outside validity period at given time
*
* @param cert Certificate to check
* @param at Time to check validity
* @return true if certificate is expired or not yet valid
*/
public boolean isOutsideValidityPeriod(X509Certificate cert, Date at) {
public boolean validateCertificateChain(X509Certificate cert) {
try {
cert.checkValidity(at);
CertPathValidator validator = CertPathValidator.getInstance("PKIX");
CertificateFactory cf = CertificateFactory.getInstance("X.509");
List<X509Certificate> certList = Arrays.asList(cert);
CertPath certPath = cf.generateCertPath(certList);
Set<TrustAnchor> anchors = new HashSet<>();
Enumeration<String> aliases = trustStore.aliases();
while (aliases.hasMoreElements()) {
Object trustCert = trustStore.getCertificate(aliases.nextElement());
if (trustCert instanceof X509Certificate x509Cert) {
anchors.add(new TrustAnchor(x509Cert, null));
}
}
PKIXParameters params = new PKIXParameters(anchors);
params.setRevocationEnabled(false);
validator.validate(certPath, params);
return true;
} catch (Exception e) {
return false;
}
}
public boolean validateTrustStore(X509Certificate cert) {
try {
Enumeration<String> aliases = trustStore.aliases();
while (aliases.hasMoreElements()) {
Object trustCert = trustStore.getCertificate(aliases.nextElement());
if (trustCert instanceof X509Certificate && cert.equals(trustCert)) {
return true;
}
}
return false;
} catch (KeyStoreException e) {
return false;
}
}
public boolean isRevoked(X509Certificate cert) {
try {
cert.checkValidity();
return false;
} catch (CertificateExpiredException | CertificateNotYetValidException e) {
return true;
}
}
/**
* Check if revocation checking is enabled
*
* @return true if revocation mode is not "none"
*/
public boolean isRevocationEnabled() {
String revocationMode =
applicationProperties.getSecurity().getValidation().getRevocation().getMode();
return !"none".equalsIgnoreCase(revocationMode);
}
/**
* Check if certificate is a CA certificate
*
* @param cert Certificate to check
* @return true if certificate has basicConstraints with CA=true
*/
public boolean isCA(X509Certificate cert) {
return cert.getBasicConstraints() >= 0;
}
/**
* Verify if certificate is self-signed by checking signature
*
* @param cert Certificate to check
* @return true if certificate is self-signed and signature is valid
*/
public boolean isSelfSigned(X509Certificate cert) {
public boolean validateCertificateChainWithCustomCert(
X509Certificate cert, X509Certificate customCert) {
try {
if (!cert.getSubjectX500Principal().equals(cert.getIssuerX500Principal())) {
return false;
}
cert.verify(cert.getPublicKey());
cert.verify(customCert.getPublicKey());
return true;
} catch (Exception e) {
return false;
}
}
/**
* Calculate SHA-256 fingerprint of certificate
*
* @param cert Certificate
* @return Hex string of SHA-256 hash
*/
public String sha256Fingerprint(X509Certificate cert) {
public boolean validateTrustWithCustomCert(X509Certificate cert, X509Certificate customCert) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(cert.getEncoded());
return bytesToHex(hash);
// Compare the issuer of the signature certificate with the custom certificate
return cert.getIssuerX500Principal().equals(customCert.getSubjectX500Principal());
} catch (Exception e) {
return "";
return false;
}
}
private String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02X", b));
}
return sb.toString();
}
/**
* Extract all certificates from CMS signature store
*
* @param certStore BouncyCastle certificate store
* @param signerCert The signer certificate
* @return Collection of all certificates except signer
*/
public Collection<X509Certificate> extractIntermediateCertificates(
Store<X509CertificateHolder> certStore, X509Certificate signerCert) {
List<X509Certificate> intermediates = new ArrayList<>();
try {
JcaX509CertificateConverter converter = new JcaX509CertificateConverter();
Collection<X509CertificateHolder> holders = certStore.getMatches(null);
for (X509CertificateHolder holder : holders) {
X509Certificate cert = converter.getCertificate(holder);
if (!cert.equals(signerCert)) {
intermediates.add(cert);
}
}
} catch (Exception e) {
log.debug("Error extracting intermediate certificates: {}", e.getMessage());
}
return intermediates;
}
// ==================== Trust Store Loading ====================
/**
* Load certificates from Java's system trust store (cacerts). On Windows, this includes
* certificates from the Windows trust store. This provides maximum compatibility with what
* browsers and OS trust.
*/
private void loadJavaSystemTrustStore() {
try {
log.info("Loading certificates from Java system trust store");
// Get default trust manager factory
TrustManagerFactory tmf =
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init((KeyStore) null); // null = use system default
// Extract certificates from trust managers
int loadedCount = 0;
for (TrustManager tm : tmf.getTrustManagers()) {
if (tm instanceof X509TrustManager x509tm) {
for (X509Certificate cert : x509tm.getAcceptedIssuers()) {
if (isCA(cert)) {
String fingerprint = sha256Fingerprint(cert);
String alias = "system-" + fingerprint;
signingTrustAnchors.setCertificateEntry(alias, cert);
loadedCount++;
}
}
}
}
log.info("Loaded {} CA certificates from Java system trust store", loadedCount);
} catch (Exception e) {
log.error("Failed to load Java system trust store: {}", e.getMessage(), e);
}
}
/**
* Load bundled Mozilla CA certificate bundle from resources. This bundle contains ~140 trusted
* root CAs from Mozilla's CA Certificate Program, suitable for validating most commercial PDF
* signatures.
*/
private void loadBundledMozillaCACerts() {
try {
log.info("Loading bundled Mozilla CA certificates from resources");
InputStream certStream =
getClass().getClassLoader().getResourceAsStream("certs/cacert.pem");
if (certStream == null) {
log.warn("Bundled Mozilla CA certificate file not found in resources");
return;
}
CertificateFactory cf = CertificateFactory.getInstance("X.509");
Collection<? extends Certificate> certs = cf.generateCertificates(certStream);
certStream.close();
int loadedCount = 0;
int skippedCount = 0;
for (Certificate cert : certs) {
if (cert instanceof X509Certificate x509) {
// Only add CA certificates to trust anchors
if (isCA(x509)) {
String fingerprint = sha256Fingerprint(x509);
String alias = "mozilla-" + fingerprint;
signingTrustAnchors.setCertificateEntry(alias, x509);
loadedCount++;
} else {
skippedCount++;
}
}
}
log.info(
"Loaded {} Mozilla CA certificates as trust anchors (skipped {} non-CA certs)",
loadedCount,
skippedCount);
} catch (Exception e) {
log.error("Failed to load bundled Mozilla CA certificates: {}", e.getMessage(), e);
}
}
private void loadServerCertAsAnchor() {
try {
if (serverCertificateService != null
&& serverCertificateService.isEnabled()
&& serverCertificateService.hasServerCertificate()) {
X509Certificate serverCert = serverCertificateService.getServerCertificate();
// Self-signed certificates can be trust anchors regardless of CA flag
// Non-self-signed certificates should only be trust anchors if they're CAs
boolean selfSigned = isSelfSigned(serverCert);
boolean ca = isCA(serverCert);
if (selfSigned || ca) {
signingTrustAnchors.setCertificateEntry("server-anchor", serverCert);
log.info(
"Loaded server certificate as trust anchor (self-signed: {}, CA: {})",
selfSigned,
ca);
} else {
log.warn(
"Server certificate is neither self-signed nor a CA; not adding as trust anchor");
}
}
} catch (Exception e) {
log.warn("Failed loading server certificate as anchor: {}", e.getMessage());
}
}
/** Download and parse Adobe Approved Trust List (AATL) and add CA certs as trust anchors. */
private void loadAATLCertificates() {
try {
String aatlUrl = applicationProperties.getSecurity().getValidation().getAatl().getUrl();
log.info("Loading Adobe Approved Trust List (AATL) from: {}", aatlUrl);
byte[] pdfBytes = downloadTrustList(aatlUrl);
if (pdfBytes == null) {
log.warn("AATL download returned no data");
return;
}
int added = parseAATLPdf(pdfBytes);
log.info("Loaded {} AATL CA certificates into signing trust", added);
} catch (Exception e) {
log.warn("Failed to load AATL: {}", e.getMessage());
log.debug("AATL loading error", e);
}
}
/** Simple HTTP(S) fetch with sane timeouts. */
private byte[] downloadTrustList(String urlStr) {
HttpURLConnection conn = null;
try {
URL url = new URL(urlStr);
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(10_000);
conn.setReadTimeout(30_000);
conn.setInstanceFollowRedirects(true);
int code = conn.getResponseCode();
if (code == HttpURLConnection.HTTP_OK) {
try (InputStream in = conn.getInputStream();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buf = new byte[8192];
int r;
while ((r = in.read(buf)) != -1) out.write(buf, 0, r);
return out.toByteArray();
}
} else {
log.warn("AATL download failed: HTTP {}", code);
return null;
}
} catch (Exception e) {
log.warn("AATL download error: {}", e.getMessage());
return null;
} finally {
if (conn != null) conn.disconnect();
}
}
/**
* Parse AATL PDF, extract the embedded "SecuritySettings.xml", and import CA certs. Returns the
* number of newly-added CA certificates.
*/
private int parseAATLPdf(byte[] pdfBytes) throws Exception {
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
PDDocumentNameDictionary names = doc.getDocumentCatalog().getNames();
if (names == null) {
log.warn("AATL PDF has no name dictionary");
return 0;
}
PDEmbeddedFilesNameTreeNode efRoot = names.getEmbeddedFiles();
if (efRoot == null) {
log.warn("AATL PDF has no embedded files");
return 0;
}
// 1) Try names at root level
Map<String, PDComplexFileSpecification> top = efRoot.getNames();
if (top != null) {
Integer count = tryParseSecuritySettingsXML(top);
if (count != null) return count;
}
// 2) Traverse kids (name-tree)
@SuppressWarnings("unchecked")
List<?> kids = efRoot.getKids();
if (kids != null) {
for (Object kidObj : kids) {
if (kidObj instanceof PDEmbeddedFilesNameTreeNode) {
PDEmbeddedFilesNameTreeNode kid = (PDEmbeddedFilesNameTreeNode) kidObj;
Map<String, PDComplexFileSpecification> map = kid.getNames();
if (map != null) {
Integer count = tryParseSecuritySettingsXML(map);
if (count != null) return count;
}
}
}
}
log.warn("AATL PDF did not contain SecuritySettings.xml");
return 0;
}
}
/**
* Try to locate "SecuritySettings.xml" in the given name map. If found and parsed, returns the
* number of certs added; otherwise returns null.
*/
private Integer tryParseSecuritySettingsXML(Map<String, PDComplexFileSpecification> nameMap) {
PDComplexFileSpecification fileSpec = nameMap.get("SecuritySettings.xml");
if (fileSpec == null) return null;
PDEmbeddedFile ef = fileSpec.getEmbeddedFile();
if (ef == null) return null;
try (InputStream xmlStream = ef.createInputStream()) {
return parseSecuritySettingsXML(xmlStream);
} catch (Exception e) {
log.warn("Failed parsing SecuritySettings.xml: {}", e.getMessage());
log.debug("SecuritySettings.xml parse error", e);
return null;
}
}
/**
* Parse the SecuritySettings.xml and load only CA certificates (basicConstraints >= 0). Returns
* the number of newly-added CA certificates.
*/
private int parseSecuritySettingsXML(InputStream xmlStream) throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(xmlStream);
NodeList certNodes = doc.getElementsByTagName("Certificate");
CertificateFactory cf = CertificateFactory.getInstance("X.509");
int added = 0;
for (int i = 0; i < certNodes.getLength(); i++) {
String base64 = certNodes.item(i).getTextContent().trim();
if (base64.isEmpty()) continue;
try {
byte[] certBytes = java.util.Base64.getMimeDecoder().decode(base64);
X509Certificate cert =
(X509Certificate)
cf.generateCertificate(new ByteArrayInputStream(certBytes));
// Only add CA certs as anchors
if (isCA(cert)) {
String fingerprint = sha256Fingerprint(cert);
String alias = "aatl-" + fingerprint;
// avoid duplicates
if (signingTrustAnchors.getCertificate(alias) == null) {
signingTrustAnchors.setCertificateEntry(alias, cert);
added++;
}
} else {
log.debug(
"Skipping non-CA certificate from AATL: {}",
cert.getSubjectX500Principal().getName());
}
} catch (Exception e) {
log.debug("Failed to parse an AATL certificate node: {}", e.getMessage());
}
}
return added;
}
/**
* Download LOTL (List Of Trusted Lists), resolve national TSLs, and import qualified CA
* certificates.
*/
private void loadEUTLCertificates() {
try {
String lotlUrl =
applicationProperties.getSecurity().getValidation().getEutl().getLotlUrl();
log.info("Loading EU Trusted List (LOTL) from: {}", lotlUrl);
byte[] lotlBytes = downloadXml(lotlUrl);
if (lotlBytes == null) {
log.warn("LOTL download returned no data");
return;
}
List<String> tslUrls = parseLotlForTslLocations(lotlBytes);
log.info("Found {} national TSL locations in LOTL", tslUrls.size());
int totalAdded = 0;
for (String tslUrl : tslUrls) {
try {
byte[] tslBytes = downloadXml(tslUrl);
if (tslBytes == null) {
log.warn("TSL download failed: {}", tslUrl);
continue;
}
int added = parseTslAndAddCas(tslBytes, tslUrl);
totalAdded += added;
} catch (Exception e) {
log.warn("Failed to parse TSL {}: {}", tslUrl, e.getMessage());
log.debug("TSL parse error", e);
}
}
log.info("Imported {} qualified CA certificates from EUTL", totalAdded);
} catch (Exception e) {
log.warn("EUTL load failed: {}", e.getMessage());
log.debug("EUTL load error", e);
}
}
/** HTTP(S) GET for XML with sane timeouts. */
private byte[] downloadXml(String urlStr) {
HttpURLConnection conn = null;
try {
URL url = new URL(urlStr);
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(10_000);
conn.setReadTimeout(30_000);
conn.setInstanceFollowRedirects(true);
int code = conn.getResponseCode();
if (code == HttpURLConnection.HTTP_OK) {
try (InputStream in = conn.getInputStream();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buf = new byte[8192];
int r;
while ((r = in.read(buf)) != -1) out.write(buf, 0, r);
return out.toByteArray();
}
} else {
log.warn("XML download failed: HTTP {} for {}", code, urlStr);
return null;
}
} catch (Exception e) {
log.warn("XML download error for {}: {}", urlStr, e.getMessage());
return null;
} finally {
if (conn != null) conn.disconnect();
}
}
/** Parse LOTL and return all TSL URLs from PointersToOtherTSL. */
private List<String> parseLotlForTslLocations(byte[] lotlBytes) throws Exception {
DocumentBuilderFactory dbf = secureDbfWithNamespaces();
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse(new ByteArrayInputStream(lotlBytes));
List<String> out = new ArrayList<>();
NodeList ptrs = doc.getElementsByTagNameNS(NS_TSL, "PointersToOtherTSL");
if (ptrs.getLength() == 0) return out;
org.w3c.dom.Element ptrRoot = (org.w3c.dom.Element) ptrs.item(0);
NodeList locations = ptrRoot.getElementsByTagNameNS(NS_TSL, "TSLLocation");
for (int i = 0; i < locations.getLength(); i++) {
String url = locations.item(i).getTextContent().trim();
if (!url.isEmpty()) out.add(url);
}
return out;
}
/**
* Parse a single national TSL, import CA certificates for qualified services in an active
* status. Returns count of newly added CA certs.
*/
private int parseTslAndAddCas(byte[] tslBytes, String sourceUrl) throws Exception {
DocumentBuilderFactory dbf = secureDbfWithNamespaces();
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse(new ByteArrayInputStream(tslBytes));
int added = 0;
NodeList services = doc.getElementsByTagNameNS(NS_TSL, "TSPService");
for (int i = 0; i < services.getLength(); i++) {
org.w3c.dom.Element svc = (org.w3c.dom.Element) services.item(i);
org.w3c.dom.Element info = firstChildNS(svc, "ServiceInformation");
if (info == null) continue;
String type = textOf(info, "ServiceTypeIdentifier");
if (!EUTL_SERVICE_TYPES.contains(type)) continue;
String status = textOf(info, "ServiceStatus");
if (!isActiveStatus(status)) continue;
org.w3c.dom.Element sdi = firstChildNS(info, "ServiceDigitalIdentity");
if (sdi == null) continue;
NodeList digitalIds = sdi.getElementsByTagNameNS(NS_TSL, "DigitalId");
for (int d = 0; d < digitalIds.getLength(); d++) {
org.w3c.dom.Element did = (org.w3c.dom.Element) digitalIds.item(d);
NodeList certNodes = did.getElementsByTagNameNS(NS_TSL, "X509Certificate");
for (int c = 0; c < certNodes.getLength(); c++) {
String base64 = certNodes.item(c).getTextContent().trim();
if (base64.isEmpty()) continue;
try {
byte[] certBytes = java.util.Base64.getMimeDecoder().decode(base64);
CertificateFactory cf = CertificateFactory.getInstance("X.509");
X509Certificate cert =
(X509Certificate)
cf.generateCertificate(new ByteArrayInputStream(certBytes));
if (!isCA(cert)) {
log.debug(
"Skipping non-CA in TSL {}: {}",
sourceUrl,
cert.getSubjectX500Principal().getName());
continue;
}
String fp = sha256Fingerprint(cert);
String alias = "eutl-" + fp;
if (signingTrustAnchors.getCertificate(alias) == null) {
signingTrustAnchors.setCertificateEntry(alias, cert);
added++;
}
} catch (Exception e) {
log.debug(
"Failed to import a certificate from {}: {}",
sourceUrl,
e.getMessage());
}
}
}
}
log.debug("TSL {} → imported {} CA certificates", sourceUrl, added);
return added;
}
/** Check if service status is active (per ETSI TS 119 612). */
private boolean isActiveStatus(String statusUri) {
if (STATUS_UNDER_SUPERVISION.equals(statusUri)) return true;
if (STATUS_ACCREDITED.equals(statusUri)) return true;
boolean acceptTransitional =
applicationProperties
.getSecurity()
.getValidation()
.getEutl()
.isAcceptTransitional();
if (acceptTransitional && STATUS_SUPERVISION_IN_CESSATION.equals(statusUri)) return true;
return false;
}
/** Create secure DocumentBuilderFactory with namespace awareness. */
private DocumentBuilderFactory secureDbfWithNamespaces() throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
// Secure processing hardening
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
return factory;
}
/** Get first child element with given local name in TSL namespace. */
private org.w3c.dom.Element firstChildNS(org.w3c.dom.Element parent, String localName) {
NodeList nl = parent.getElementsByTagNameNS(NS_TSL, localName);
return (nl.getLength() == 0) ? null : (org.w3c.dom.Element) nl.item(0);
}
/** Get text content of first child with given local name. */
private String textOf(org.w3c.dom.Element parent, String localName) {
org.w3c.dom.Element e = firstChildNS(parent, localName);
return (e == null) ? "" : e.getTextContent().trim();
}
/** Get signing trust store */
public KeyStore getSigningTrustStore() {
return signingTrustAnchors;
}
}
@@ -2,7 +2,7 @@ multipart.enabled=true
logging.level.org.springframework=WARN
logging.level.org.hibernate=WARN
logging.level.org.eclipse.jetty=WARN
#logging.level.org.springframework.security.oauth2=DEBUG
#logging.level.org.springframework.security.saml2=TRACE
#logging.level.org.springframework.security=DEBUG
#logging.level.org.opensaml=DEBUG
#logging.level.stirling.software.proprietary.security=DEBUG
@@ -35,12 +35,12 @@ spring.datasource.username=sa
spring.datasource.password=
spring.h2.console.enabled=false
spring.jpa.hibernate.ddl-auto=update
# Defer datasource initialization to ensure that the database is fully set up
# before Hibernate attempts to access it. This is particularly useful when
# Defer datasource initialization to ensure that the database is fully set up
# before Hibernate attempts to access it. This is particularly useful when
# using database initialization scripts or tools.
spring.jpa.defer-datasource-initialization=true
# Disable SQL logging to avoid cluttering the logs in production. Enable this
# Disable SQL logging to avoid cluttering the logs in production. Enable this
# property during development if you need to debug SQL queries.
spring.jpa.show-sql=false
server.servlet.session.timeout:30m
@@ -60,4 +60,4 @@ spring.main.allow-bean-definition-overriding=true
java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
# V2 features
v2=true
v2=false
@@ -1916,7 +1916,6 @@ fileManager.storageError=Storage error occurred
fileManager.storageLow=Storage is running low. Consider removing old files.
fileManager.uploadError=Failed to upload some files.
fileManager.supportMessage=Powered by browser database storage for unlimited capacity
fileManager.loadingFiles=Loading files...
# Page Editor
pageEditor.title=Page Editor
@@ -1946,25 +1945,6 @@ viewer.zoomIn=Zoom in
# Tool Picker
toolPicker.searchPlaceholder=Search tools...
toolPicker.noToolsFound=No tools found
toolPanel.toggle.legacy=Switch to legacy mode
toolPanel.toggle.sidebar=Switch to sidebar mode
toolPanel.placeholder=Choose a tool to get started
toolPanel.legacy.heading=All tools (legacy view)
toolPanel.legacy.tagline=Browse and launch tools while keeping the classic full-width gallery.
toolPanel.legacy.descriptionsOn=Showing descriptions
toolPanel.legacy.descriptionsOff=Descriptions hidden
toolPanel.legacy.noResults=Try adjusting your search or toggle descriptions to find what you need.
toolPanel.legacy.matchedSynonym=Matches "{{text}}"
toolPanel.modePrompt.title=Choose how you browse tools
toolPanel.modePrompt.description=Preview both layouts and decide how you want to explore Stirling PDF tools.
toolPanel.modePrompt.sidebarTitle=Advanced sidebar
toolPanel.modePrompt.sidebarDescription=Keep tools alongside your workspace for quick switching.
toolPanel.modePrompt.recommended=Recommended
toolPanel.modePrompt.chooseSidebar=Use advanced sidebar
toolPanel.modePrompt.legacyTitle=Legacy fullscreen
toolPanel.modePrompt.legacyDescription=Browse every tool in a catalogue that covers the workspace until you pick one.
toolPanel.modePrompt.chooseLegacy=Use legacy fullscreen
toolPanel.modePrompt.dismiss=Maybe later
pageEditor.reset=Reset Changes
pageEditor.zoomIn=Zoom In
pageEditor.zoomOut=Zoom Out
@@ -1977,39 +1957,3 @@ viewer.nextPage=Next Page
viewer.pageNavigation=Page Navigation
viewer.currentPage=Current Page
viewer.totalPages=Total Pages
toolPanel.legacy.favorites=Favourites
toolPanel.legacy.recent=Recently used
toolPanel.legacy.favorite=Add to favourites
toolPanel.legacy.unfavorite=Remove from favourites
toolPanel.legacy.settings.title=Customise appearance
toolPanel.legacy.settings.iconBackground.label=Tool icon background
toolPanel.legacy.settings.iconBackground.description=When to show coloured backgrounds behind tool icons
toolPanel.legacy.settings.iconBackground.none=None
toolPanel.legacy.settings.iconBackground.hover=On hover
toolPanel.legacy.settings.iconBackground.always=Always
toolPanel.legacy.settings.iconColor.label=Tool icon colour
toolPanel.legacy.settings.iconColor.description=Colour scheme for tool icons
toolPanel.legacy.settings.iconColor.colored=Coloured
toolPanel.legacy.settings.iconColor.vibrant=Vibrant
toolPanel.legacy.settings.iconColor.monochrome=Monochrome
toolPanel.legacy.settings.sectionTitle.label=Section titles
toolPanel.legacy.settings.sectionTitle.description=Colour for category section titles
toolPanel.legacy.settings.sectionTitle.colored=Coloured
toolPanel.legacy.settings.sectionTitle.neutral=Neutral
toolPanel.legacy.settings.headerIcon.label=Section header icons
toolPanel.legacy.settings.headerIcon.description=Colour for Favourites/Recent icons
toolPanel.legacy.settings.headerIcon.colored=Coloured
toolPanel.legacy.settings.headerIcon.monochrome=Monochrome
toolPanel.legacy.settings.headerBadge.label=Section header badges
toolPanel.legacy.settings.headerBadge.description=Colour for count badges in section headers
toolPanel.legacy.settings.headerBadge.colored=Coloured
toolPanel.legacy.settings.headerBadge.neutral=Neutral
toolPanel.legacy.settings.border.label=Tool item borders
toolPanel.legacy.settings.border.description=Show borders around tool items
toolPanel.legacy.settings.border.visible=Visible
toolPanel.legacy.settings.border.hidden=Hidden
toolPanel.legacy.settings.hover.label=Hover effect intensity
toolPanel.legacy.settings.hover.description=How prominent the hover effect should be
toolPanel.legacy.settings.hover.subtle=Subtle
toolPanel.legacy.settings.hover.moderate=Moderate
toolPanel.legacy.settings.hover.prominent=Prominent
@@ -64,22 +64,7 @@ security:
enableKeyRotation: true # Set to 'true' to enable key pair rotation
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days.
validation: # PDF signature validation settings
trust:
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
useSystemTrust: true # Trust Java/OS system trust store for PDF signature validation
useMozillaBundle: true # Trust bundled Mozilla CA bundle (~140 CAs) for PDF signature validation
useAATL: false # Trust Adobe Approved Trust List (AATL) for PDF signature validation - downloads from Adobe on startup if enabled
useEUTL: false # Trust EU Trusted List (EUTL) for eIDAS qualified certificates - downloads LOTL and national TSLs on startup if enabled
allowAIA: false # Allow JDK to fetch issuer certificates and revocation information from network (OCSP/CRL/AIA)
aatl:
url: https://trustlist.adobe.com/tl.pdf # Adobe Approved Trust List download URL
eutl:
lotlUrl: https://ec.europa.eu/tools/lotl/eu-lotl.xml # EU List Of Trusted Lists (LOTL) URL
acceptTransitional: false # Accept certificates with 'supervisionincessation' status (transitional state)
revocation:
mode: none # Revocation checking mode: 'none' (disabled), 'ocsp' (OCSP only), 'crl' (CRL only), 'ocsp+crl' (OCSP with CRL fallback)
hardFail: false # Fail validation if revocation status cannot be determined (true=strict, false=soft-fail)
secureCookie: false # Set to 'true' to use secure cookies for JWTs
premium:
key: 00000000-0000-0000-0000-000000000000
@@ -91,6 +76,11 @@ premium:
author: username
creator: Stirling-PDF
producer: Stirling-PDF
googleDrive:
enabled: false
clientId: ''
apiKey: ''
appId: ''
enterpriseFeatures:
audit:
enabled: true # Enable audit logging
@@ -99,7 +89,6 @@ premium:
mail:
enabled: false # set to 'true' to enable sending emails
enableInvites: false # set to 'true' to enable email invites for user management (requires mail.enabled and security.enableLogin)
host: smtp.example.com # SMTP server hostname
port: 587 # SMTP server port
username: '' # SMTP server username
@@ -107,8 +96,8 @@ mail:
from: '' # sender email address
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
privacyPolicy: https://www.stirling.com/legal/privacy-policy # URL to the privacy policy of your application (e.g. https://example.com/privacy). Empty string to disable or filename to load from local file in static folder
termsAndConditions: https://www.stirlingpdf.com/terms # 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
privacyPolicy: https://www.stirlingpdf.com/privacy-policy # URL to the privacy policy of your application (e.g. https://example.com/privacy). Empty string to disable or filename to load from local file in static folder
accessibilityStatement: '' # URL to the accessibility statement of your application (e.g. https://example.com/accessibility). Empty string to disable or filename to load from local file in static folder
cookiePolicy: '' # URL to the cookie policy of your application (e.g. https://example.com/cookie). Empty string to disable or filename to load from local file in static folder
impressum: '' # URL to the impressum of your application (e.g. https://example.com/impressum). Empty string to disable or filename to load from local file in static folder
@@ -121,13 +110,10 @@ system:
showUpdateOnlyAdmin: false # only admins can see when a new update is available, depending on showUpdate it must be set to 'true'
customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files
tessdataDir: /usr/share/tessdata # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored.
enableAnalytics: null # Master toggle for analytics: set to 'true' to enable all analytics, 'false' to disable all analytics, or leave as 'null' to prompt admin on first launch
enablePosthog: null # Enable PostHog analytics (open-source product analytics): set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled
enableScarf: null # Enable Scarf tracking pixel: set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled
enableAnalytics: null # set to 'true' to enable analytics, set to 'false' to disable analytics; for enterprise users, this is set to true
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS.
serverCertificate:
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
organizationName: Stirling-PDF # Organization name for generated certificates
@@ -172,6 +158,8 @@ system:
cleanupSystemTemp: false # Whether to clean broader system temp directory
ui:
appName: '' # application's visible name
homeDescription: '' # short description or tagline shown on the homepage
appNameNavbar: '' # name displayed on the navigation bar
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
@@ -422,6 +422,10 @@
<span th:text="#{fileChooser.or}" style="margin: 0 5px;"></span>
<span th:text="#{fileChooser.dragAndDrop}" id="dragAndDrop"></span>
</div>
<hr th:if="${@GoogleDriveEnabled == true}" class="horizontal-divider" >
</div>
<div th:if="${@GoogleDriveEnabled == true}" th:id="${name}+'-google-drive-button'" class="google-drive-button" th:attr="data-name=${name}, data-multiple=${!disableMultipleFiles}, data-accept=${accept}" >
<img th:src="@{'/images/google-drive.svg'}" alt="google drive">
</div>
</div>
<div class="selected-files flex-wrap"></div>
@@ -439,4 +443,16 @@
</div>
</div>
<script th:src="@{'/js/fileInput.js'}" type="module"></script>
<div th:if="${@GoogleDriveEnabled == true}" >
<script type="text/javascript" th:src="@{'/js/googleFilePicker.js'}"></script>
<script async defer src="https://apis.google.com/js/api.js" onload="gapiLoaded()"></script>
<script async defer src="https://accounts.google.com/gsi/client" onload="gisLoaded()"></script>
<script th:inline="javascript">
window.stirlingPDF.GoogleDriveClientId = /*[[${@GoogleDriveConfig.getClientId()}]]*/ null;
window.stirlingPDF.GoogleDriveApiKey = /*[[${@GoogleDriveConfig.getApiKey()}]]*/ null;
window.stirlingPDF.GoogleDriveAppId = /*[[${@GoogleDriveConfig.getAppId()}]]*/ null;
</script>
</div>
</th:block>
@@ -2,20 +2,20 @@ package stirling.software.SPDF.service;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.security.PublicKey;
import java.security.cert.CertificateExpiredException;
import java.security.cert.X509Certificate;
import java.util.Date;
import javax.security.auth.x500.X500Principal;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import org.mockito.Mockito;
/** Tests for the CertificateValidationService using mocked certificates. */
class CertificateValidationServiceTest {
@@ -26,67 +26,121 @@ class CertificateValidationServiceTest {
@BeforeEach
void setUp() throws Exception {
// Create mock ApplicationProperties with default validation settings
ApplicationProperties applicationProperties = mock(ApplicationProperties.class);
ApplicationProperties.Security security = mock(ApplicationProperties.Security.class);
ApplicationProperties.Security.Validation validation =
mock(ApplicationProperties.Security.Validation.class);
ApplicationProperties.Security.Validation.Trust trust =
mock(ApplicationProperties.Security.Validation.Trust.class);
ApplicationProperties.Security.Validation.Revocation revocation =
mock(ApplicationProperties.Security.Validation.Revocation.class);
when(applicationProperties.getSecurity()).thenReturn(security);
when(security.getValidation()).thenReturn(validation);
when(validation.getTrust()).thenReturn(trust);
when(validation.getRevocation()).thenReturn(revocation);
when(validation.isAllowAIA()).thenReturn(false);
when(trust.isServerAsAnchor()).thenReturn(false);
when(trust.isUseSystemTrust()).thenReturn(false);
when(trust.isUseMozillaBundle()).thenReturn(false);
when(trust.isUseAATL()).thenReturn(false);
when(trust.isUseEUTL()).thenReturn(false);
when(revocation.getMode()).thenReturn("none");
when(revocation.isHardFail()).thenReturn(false);
validationService = new CertificateValidationService(null, applicationProperties);
validationService = new CertificateValidationService();
// Create mock certificates
validCertificate = mock(X509Certificate.class);
expiredCertificate = mock(X509Certificate.class);
// Set up behaviors for valid certificate (both overloads)
doNothing().when(validCertificate).checkValidity();
doNothing().when(validCertificate).checkValidity(any(Date.class));
// Set up behaviors for valid certificate
doNothing().when(validCertificate).checkValidity(); // No exception means valid
// Set up behaviors for expired certificate (both overloads)
// Set up behaviors for expired certificate
doThrow(new CertificateExpiredException("Certificate expired"))
.when(expiredCertificate)
.checkValidity();
doThrow(new CertificateExpiredException("Certificate expired"))
.when(expiredCertificate)
.checkValidity(any(Date.class));
}
@Test
void testIsOutsideValidityPeriod_ValidCertificate() {
void testIsRevoked_ValidCertificate() {
// When certificate is valid (not expired)
boolean result = validationService.isOutsideValidityPeriod(validCertificate, new Date());
boolean result = validationService.isRevoked(validCertificate);
// Then it should not be outside validity period
assertFalse(result, "Valid certificate should not be outside validity period");
// Then it should not be considered revoked
assertFalse(result, "Valid certificate should not be considered revoked");
}
@Test
void testIsOutsideValidityPeriod_ExpiredCertificate() {
void testIsRevoked_ExpiredCertificate() {
// When certificate is expired
boolean result = validationService.isOutsideValidityPeriod(expiredCertificate, new Date());
boolean result = validationService.isRevoked(expiredCertificate);
// Then it should be outside validity period
assertTrue(result, "Expired certificate should be outside validity period");
// Then it should be considered revoked
assertTrue(result, "Expired certificate should be considered revoked");
}
// Note: Full integration tests for buildAndValidatePath() would require
// real certificate chains and trust anchors. These would be better as
// integration tests using actual signed PDFs from the test-signed-pdfs directory.
@Test
void testValidateTrustWithCustomCert_Match() {
// Create certificates with matching issuer and subject
X509Certificate issuingCert = mock(X509Certificate.class);
X509Certificate signedCert = mock(X509Certificate.class);
// Create X500Principal objects for issuer and subject
X500Principal issuerPrincipal = new X500Principal("CN=Test Issuer");
// Mock the issuer of the signed certificate to match the subject of the issuing certificate
when(signedCert.getIssuerX500Principal()).thenReturn(issuerPrincipal);
when(issuingCert.getSubjectX500Principal()).thenReturn(issuerPrincipal);
// When validating trust with custom cert
boolean result = validationService.validateTrustWithCustomCert(signedCert, issuingCert);
// Then validation should succeed
assertTrue(result, "Certificate with matching issuer and subject should validate");
}
@Test
void testValidateTrustWithCustomCert_NoMatch() {
// Create certificates with non-matching issuer and subject
X509Certificate issuingCert = mock(X509Certificate.class);
X509Certificate signedCert = mock(X509Certificate.class);
// Create X500Principal objects for issuer and subject
X500Principal issuerPrincipal = new X500Principal("CN=Test Issuer");
X500Principal differentPrincipal = new X500Principal("CN=Different Name");
// Mock the issuer of the signed certificate to NOT match the subject of the issuing
// certificate
when(signedCert.getIssuerX500Principal()).thenReturn(issuerPrincipal);
when(issuingCert.getSubjectX500Principal()).thenReturn(differentPrincipal);
// When validating trust with custom cert
boolean result = validationService.validateTrustWithCustomCert(signedCert, issuingCert);
// Then validation should fail
assertFalse(result, "Certificate with non-matching issuer and subject should not validate");
}
@Test
void testValidateCertificateChainWithCustomCert_Success() throws Exception {
// Setup mock certificates
X509Certificate signedCert = mock(X509Certificate.class);
X509Certificate signingCert = mock(X509Certificate.class);
PublicKey publicKey = mock(PublicKey.class);
when(signingCert.getPublicKey()).thenReturn(publicKey);
// When verifying the certificate with the signing cert's public key, don't throw exception
doNothing().when(signedCert).verify(Mockito.any());
// When validating certificate chain with custom cert
boolean result =
validationService.validateCertificateChainWithCustomCert(signedCert, signingCert);
// Then validation should succeed
assertTrue(result, "Certificate chain with proper signing should validate");
}
@Test
void testValidateCertificateChainWithCustomCert_Failure() throws Exception {
// Setup mock certificates
X509Certificate signedCert = mock(X509Certificate.class);
X509Certificate signingCert = mock(X509Certificate.class);
PublicKey publicKey = mock(PublicKey.class);
when(signingCert.getPublicKey()).thenReturn(publicKey);
// When verifying the certificate with the signing cert's public key, throw exception
// Need to use a specific exception that verify() can throw
doThrow(new java.security.SignatureException("Verification failed"))
.when(signedCert)
.verify(Mockito.any());
// When validating certificate chain with custom cert
boolean result =
validationService.validateCertificateChainWithCustomCert(signedCert, signingCert);
// Then validation should fail
assertFalse(result, "Certificate chain with failed signing should not validate");
}
}
@@ -1,15 +1,17 @@
package stirling.software.proprietary.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.transaction.annotation.EnableTransactionManagement;
/** Configuration to enable scheduling for the audit system. */
/** Configuration to explicitly enable JPA repositories and scheduling for the audit system. */
@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(basePackages = "stirling.software.proprietary.repository")
@EnableScheduling
public class AuditJpaConfig {
// This configuration enables scheduling for audit cleanup tasks
// JPA repositories are now managed by DatabaseConfig to avoid conflicts
// This configuration enables JPA repositories in the specified package
// and enables scheduling for audit cleanup tasks
// No additional beans or methods needed
}
@@ -1,434 +0,0 @@
package stirling.software.proprietary.controller.api;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.model.security.PersistentAuditEvent;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
/** REST API controller for audit data used by React frontend. */
@Slf4j
@ProprietaryUiDataApi
@PreAuthorize("hasRole('ADMIN')")
@RequiredArgsConstructor
@EnterpriseEndpoint
public class AuditRestController {
private final PersistentAuditEventRepository auditRepository;
private final ObjectMapper objectMapper;
/**
* Get audit events with pagination and filters. Maps to frontend's getEvents() call.
*
* @param page Page number (0-indexed)
* @param pageSize Number of items per page
* @param eventType Filter by event type
* @param username Filter by username (principal)
* @param startDate Filter start date
* @param endDate Filter end date
* @return Paginated audit events response
*/
@GetMapping("/audit-events")
public ResponseEntity<AuditEventsResponse> getAuditEvents(
@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "pageSize", defaultValue = "30") int pageSize,
@RequestParam(value = "eventType", required = false) String eventType,
@RequestParam(value = "username", required = false) String username,
@RequestParam(value = "startDate", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate startDate,
@RequestParam(value = "endDate", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate endDate) {
Pageable pageable = PageRequest.of(page, pageSize, Sort.by("timestamp").descending());
Page<PersistentAuditEvent> events;
// Apply filters based on provided parameters
if (eventType != null && username != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events =
auditRepository.findByPrincipalAndTypeAndTimestampBetween(
username, eventType, start, end, pageable);
} else if (eventType != null && username != null) {
events = auditRepository.findByPrincipalAndType(username, eventType, pageable);
} else if (eventType != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findByTypeAndTimestampBetween(eventType, start, end, pageable);
} else if (username != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events =
auditRepository.findByPrincipalAndTimestampBetween(
username, start, end, pageable);
} else if (startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findByTimestampBetween(start, end, pageable);
} else if (eventType != null) {
events = auditRepository.findByType(eventType, pageable);
} else if (username != null) {
events = auditRepository.findByPrincipal(username, pageable);
} else {
events = auditRepository.findAll(pageable);
}
// Convert to response format expected by frontend
List<AuditEventDto> eventDtos =
events.getContent().stream().map(this::convertToDto).collect(Collectors.toList());
AuditEventsResponse response =
AuditEventsResponse.builder()
.events(eventDtos)
.totalEvents((int) events.getTotalElements())
.page(events.getNumber())
.pageSize(events.getSize())
.totalPages(events.getTotalPages())
.build();
return ResponseEntity.ok(response);
}
/**
* Get chart data for dashboard. Maps to frontend's getChartsData() call.
*
* @param period Time period for charts (day/week/month)
* @return Chart data for events by type, user, and over time
*/
@GetMapping("/audit-charts")
public ResponseEntity<AuditChartsData> getAuditCharts(
@RequestParam(value = "period", defaultValue = "week") String period) {
// Calculate days based on period
int days;
switch (period.toLowerCase()) {
case "day":
days = 1;
break;
case "month":
days = 30;
break;
case "week":
default:
days = 7;
break;
}
// Get events from the specified period
Instant startDate = Instant.now().minus(java.time.Duration.ofDays(days));
List<PersistentAuditEvent> events = auditRepository.findByTimestampAfter(startDate);
// Count events by type
Map<String, Long> eventsByType =
events.stream()
.collect(
Collectors.groupingBy(
PersistentAuditEvent::getType, Collectors.counting()));
// Count events by principal (user)
Map<String, Long> eventsByUser =
events.stream()
.collect(
Collectors.groupingBy(
PersistentAuditEvent::getPrincipal, Collectors.counting()));
// Count events by day
Map<String, Long> eventsByDay =
events.stream()
.collect(
Collectors.groupingBy(
e ->
LocalDateTime.ofInstant(
e.getTimestamp(),
ZoneId.systemDefault())
.format(DateTimeFormatter.ISO_LOCAL_DATE),
Collectors.counting()));
// Convert to ChartData format
ChartData eventsByTypeChart =
ChartData.builder()
.labels(new ArrayList<>(eventsByType.keySet()))
.values(
eventsByType.values().stream()
.map(Long::intValue)
.collect(Collectors.toList()))
.build();
ChartData eventsByUserChart =
ChartData.builder()
.labels(new ArrayList<>(eventsByUser.keySet()))
.values(
eventsByUser.values().stream()
.map(Long::intValue)
.collect(Collectors.toList()))
.build();
// Sort events by day for time series
TreeMap<String, Long> sortedEventsByDay = new TreeMap<>(eventsByDay);
ChartData eventsOverTimeChart =
ChartData.builder()
.labels(new ArrayList<>(sortedEventsByDay.keySet()))
.values(
sortedEventsByDay.values().stream()
.map(Long::intValue)
.collect(Collectors.toList()))
.build();
AuditChartsData chartsData =
AuditChartsData.builder()
.eventsByType(eventsByTypeChart)
.eventsByUser(eventsByUserChart)
.eventsOverTime(eventsOverTimeChart)
.build();
return ResponseEntity.ok(chartsData);
}
/**
* Get available event types for filtering. Maps to frontend's getEventTypes() call.
*
* @return List of unique event types
*/
@GetMapping("/audit-event-types")
public ResponseEntity<List<String>> getEventTypes() {
// Get distinct event types from the database
List<String> dbTypes = auditRepository.findDistinctEventTypes();
// Include standard enum types in case they're not in the database yet
List<String> enumTypes =
Arrays.stream(AuditEventType.values())
.map(AuditEventType::name)
.collect(Collectors.toList());
// Combine both sources, remove duplicates, and sort
Set<String> combinedTypes = new HashSet<>();
combinedTypes.addAll(dbTypes);
combinedTypes.addAll(enumTypes);
List<String> result = combinedTypes.stream().sorted().collect(Collectors.toList());
return ResponseEntity.ok(result);
}
/**
* Get list of users for filtering. Maps to frontend's getUsers() call.
*
* @return List of unique usernames
*/
@GetMapping("/audit-users")
public ResponseEntity<List<String>> getUsers() {
// Use the countByPrincipal query to get unique principals
List<Object[]> principalCounts = auditRepository.countByPrincipal();
List<String> users =
principalCounts.stream()
.map(arr -> (String) arr[0])
.sorted()
.collect(Collectors.toList());
return ResponseEntity.ok(users);
}
/**
* Export audit data in CSV or JSON format. Maps to frontend's exportData() call.
*
* @param format Export format (csv or json)
* @param eventType Filter by event type
* @param username Filter by username
* @param startDate Filter start date
* @param endDate Filter end date
* @return File download response
*/
@GetMapping("/audit-export")
public ResponseEntity<byte[]> exportAuditData(
@RequestParam(value = "format", defaultValue = "csv") String format,
@RequestParam(value = "eventType", required = false) String eventType,
@RequestParam(value = "username", required = false) String username,
@RequestParam(value = "startDate", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate startDate,
@RequestParam(value = "endDate", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate endDate) {
// Get data with same filtering as getAuditEvents
List<PersistentAuditEvent> events;
if (eventType != null && username != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events =
auditRepository.findAllByPrincipalAndTypeAndTimestampBetweenForExport(
username, eventType, start, end);
} else if (eventType != null && username != null) {
events = auditRepository.findAllByPrincipalAndTypeForExport(username, eventType);
} else if (eventType != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events =
auditRepository.findAllByTypeAndTimestampBetweenForExport(
eventType, start, end);
} else if (username != null && startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events =
auditRepository.findAllByPrincipalAndTimestampBetweenForExport(
username, start, end);
} else if (startDate != null && endDate != null) {
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
events = auditRepository.findAllByTimestampBetweenForExport(start, end);
} else if (eventType != null) {
events = auditRepository.findByTypeForExport(eventType);
} else if (username != null) {
events = auditRepository.findAllByPrincipalForExport(username);
} else {
events = auditRepository.findAll();
}
// Export based on format
if ("json".equalsIgnoreCase(format)) {
return exportAsJson(events);
} else {
return exportAsCsv(events);
}
}
// Helper methods
private AuditEventDto convertToDto(PersistentAuditEvent event) {
// Parse the JSON data field if present
Map<String, Object> details = new HashMap<>();
if (event.getData() != null && !event.getData().isEmpty()) {
try {
@SuppressWarnings("unchecked")
Map<String, Object> parsed = objectMapper.readValue(event.getData(), Map.class);
details = parsed;
} catch (JsonProcessingException e) {
log.warn("Failed to parse audit event data as JSON: {}", event.getData());
details.put("rawData", event.getData());
}
}
return AuditEventDto.builder()
.id(String.valueOf(event.getId()))
.timestamp(event.getTimestamp().toString())
.eventType(event.getType())
.username(event.getPrincipal())
.ipAddress((String) details.getOrDefault("ipAddress", "")) // Extract if available
.details(details)
.build();
}
private ResponseEntity<byte[]> exportAsCsv(List<PersistentAuditEvent> events) {
StringBuilder csv = new StringBuilder();
csv.append("ID,Principal,Type,Timestamp,Data\n");
DateTimeFormatter formatter = DateTimeFormatter.ISO_INSTANT;
for (PersistentAuditEvent event : events) {
csv.append(event.getId()).append(",");
csv.append(escapeCSV(event.getPrincipal())).append(",");
csv.append(escapeCSV(event.getType())).append(",");
csv.append(formatter.format(event.getTimestamp())).append(",");
csv.append(escapeCSV(event.getData())).append("\n");
}
byte[] csvBytes = csv.toString().getBytes();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
headers.setContentDispositionFormData("attachment", "audit_export.csv");
return ResponseEntity.ok().headers(headers).body(csvBytes);
}
private ResponseEntity<byte[]> exportAsJson(List<PersistentAuditEvent> events) {
try {
byte[] jsonBytes = objectMapper.writeValueAsBytes(events);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setContentDispositionFormData("attachment", "audit_export.json");
return ResponseEntity.ok().headers(headers).body(jsonBytes);
} catch (JsonProcessingException e) {
log.error("Error serializing audit events to JSON", e);
return ResponseEntity.internalServerError().build();
}
}
private String escapeCSV(String field) {
if (field == null) {
return "";
}
// Replace double quotes with two double quotes and wrap in quotes
return "\"" + field.replace("\"", "\"\"") + "\"";
}
// DTOs for response formatting
@lombok.Data
@lombok.Builder
public static class AuditEventsResponse {
private List<AuditEventDto> events;
private int totalEvents;
private int page;
private int pageSize;
private int totalPages;
}
@lombok.Data
@lombok.Builder
public static class AuditEventDto {
private String id;
private String timestamp;
private String eventType;
private String username;
private String ipAddress;
private Map<String, Object> details;
}
@lombok.Data
@lombok.Builder
public static class AuditChartsData {
private ChartData eventsByType;
private ChartData eventsByUser;
private ChartData eventsOverTime;
}
@lombok.Data
@lombok.Builder
public static class ChartData {
private List<String> labels;
private List<Integer> values;
}
}
@@ -39,7 +39,6 @@ import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.model.dto.TeamWithUserCountDTO;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
import stirling.software.proprietary.security.database.repository.SessionRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
@@ -51,10 +50,10 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
import stirling.software.proprietary.security.service.DatabaseService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@Slf4j
@ProprietaryUiDataApi
@EnterpriseEndpoint
public class ProprietaryUIDataController {
private final ApplicationProperties applicationProperties;
@@ -66,8 +65,6 @@ public class ProprietaryUIDataController {
private final DatabaseService databaseService;
private final boolean runningEE;
private final ObjectMapper objectMapper;
private final UserLicenseSettingsService licenseSettingsService;
private final PersistentAuditEventRepository auditRepository;
public ProprietaryUIDataController(
ApplicationProperties applicationProperties,
@@ -78,9 +75,7 @@ public class ProprietaryUIDataController {
SessionRepository sessionRepository,
DatabaseService databaseService,
ObjectMapper objectMapper,
@Qualifier("runningEE") boolean runningEE,
UserLicenseSettingsService licenseSettingsService,
PersistentAuditEventRepository auditRepository) {
@Qualifier("runningEE") boolean runningEE) {
this.applicationProperties = applicationProperties;
this.auditConfig = auditConfig;
this.sessionPersistentRegistry = sessionPersistentRegistry;
@@ -90,13 +85,10 @@ public class ProprietaryUIDataController {
this.databaseService = databaseService;
this.objectMapper = objectMapper;
this.runningEE = runningEE;
this.licenseSettingsService = licenseSettingsService;
this.auditRepository = auditRepository;
}
@GetMapping("/audit-dashboard")
@PreAuthorize("hasRole('ADMIN')")
@EnterpriseEndpoint
@Operation(summary = "Get audit dashboard data")
public ResponseEntity<AuditDashboardData> getAuditDashboardData() {
AuditDashboardData data = new AuditDashboardData();
@@ -270,13 +262,6 @@ public class ProprietaryUIDataController {
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME))
.toList();
// Calculate license limits
int maxAllowedUsers = licenseSettingsService.calculateMaxAllowedUsers();
long availableSlots = licenseSettingsService.getAvailableUserSlots();
int grandfatheredCount = licenseSettingsService.getDisplayGrandfatheredCount();
int licenseMaxUsers = licenseSettingsService.getSettings().getLicenseMaxUsers();
boolean premiumEnabled = applicationProperties.getPremium().isEnabled();
AdminSettingsData data = new AdminSettingsData();
data.setUsers(sortedUsers);
data.setCurrentUsername(authentication.getName());
@@ -288,11 +273,6 @@ public class ProprietaryUIDataController {
data.setDisabledUsers(disabledUsers);
data.setTeams(allTeams);
data.setMaxPaidUsers(applicationProperties.getPremium().getMaxUsers());
data.setMaxAllowedUsers(maxAllowedUsers);
data.setAvailableSlots(availableSlots);
data.setGrandfatheredUserCount(grandfatheredCount);
data.setLicenseMaxUsers(licenseMaxUsers);
data.setPremiumEnabled(premiumEnabled);
return ResponseEntity.ok(data);
}
@@ -465,11 +445,6 @@ public class ProprietaryUIDataController {
private int disabledUsers;
private List<Team> teams;
private int maxPaidUsers;
private int maxAllowedUsers;
private long availableSlots;
private int grandfatheredUserCount;
private int licenseMaxUsers;
private boolean premiumEnabled;
}
@Data
@@ -1,236 +0,0 @@
package stirling.software.proprietary.controller.api;
import java.util.*;
import java.util.stream.Collectors;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.model.security.PersistentAuditEvent;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
/** REST API controller for usage analytics data used by React frontend. */
@Slf4j
@ProprietaryUiDataApi
@PreAuthorize("hasRole('ADMIN')")
@RequiredArgsConstructor
@EnterpriseEndpoint
public class UsageRestController {
private final PersistentAuditEventRepository auditRepository;
private final ObjectMapper objectMapper;
/**
* Get endpoint statistics derived from audit events. This endpoint analyzes HTTP_REQUEST audit
* events to generate usage statistics.
*
* @param limit Optional limit on number of endpoints to return
* @param dataType Type of data to include: "all" (default), "api" (API endpoints excluding
* auth), or "ui" (non-API endpoints)
* @return Endpoint statistics response
*/
@GetMapping("/usage-endpoint-statistics")
public ResponseEntity<EndpointStatisticsResponse> getEndpointStatistics(
@RequestParam(value = "limit", required = false) Integer limit,
@RequestParam(value = "dataType", defaultValue = "all") String dataType) {
// Get all HTTP_REQUEST audit events
List<PersistentAuditEvent> httpEvents =
auditRepository.findByTypeForExport(AuditEventType.HTTP_REQUEST.name());
// Count visits per endpoint
Map<String, Long> endpointCounts = new HashMap<>();
for (PersistentAuditEvent event : httpEvents) {
String endpoint = extractEndpointFromAuditData(event.getData());
if (endpoint != null) {
// Apply data type filter
if (!shouldIncludeEndpoint(endpoint, dataType)) {
continue;
}
endpointCounts.merge(endpoint, 1L, Long::sum);
}
}
// Calculate totals
long totalVisits = endpointCounts.values().stream().mapToLong(Long::longValue).sum();
int totalEndpoints = endpointCounts.size();
// Convert to list and sort by visit count (descending)
List<EndpointStatistic> statistics =
endpointCounts.entrySet().stream()
.map(
entry -> {
String endpoint = entry.getKey();
long visits = entry.getValue();
double percentage =
totalVisits > 0 ? (visits * 100.0 / totalVisits) : 0.0;
return EndpointStatistic.builder()
.endpoint(endpoint)
.visits((int) visits)
.percentage(Math.round(percentage * 10.0) / 10.0)
.build();
})
.sorted(Comparator.comparingInt(EndpointStatistic::getVisits).reversed())
.collect(Collectors.toList());
// Apply limit if specified
if (limit != null && limit > 0 && statistics.size() > limit) {
statistics = statistics.subList(0, limit);
}
EndpointStatisticsResponse response =
EndpointStatisticsResponse.builder()
.endpoints(statistics)
.totalEndpoints(totalEndpoints)
.totalVisits((int) totalVisits)
.build();
return ResponseEntity.ok(response);
}
/**
* Extract the endpoint path from the audit event's data field. The data field contains JSON
* with an "endpoint" or "path" key.
*
* @param dataJson JSON string from audit event
* @return Endpoint path or null if not found
*/
private String extractEndpointFromAuditData(String dataJson) {
if (dataJson == null || dataJson.isEmpty()) {
return null;
}
try {
@SuppressWarnings("unchecked")
Map<String, Object> data = objectMapper.readValue(dataJson, Map.class);
// Try common keys for endpoint path
Object endpoint = data.get("endpoint");
if (endpoint != null) {
return normalizeEndpoint(endpoint.toString());
}
Object path = data.get("path");
if (path != null) {
return normalizeEndpoint(path.toString());
}
// Fallback: check if there's a request-related key
Object requestUri = data.get("requestUri");
if (requestUri != null) {
return normalizeEndpoint(requestUri.toString());
}
} catch (JsonProcessingException e) {
log.debug("Failed to parse audit data JSON: {}", dataJson, e);
}
return null;
}
/**
* Normalize endpoint paths by removing query strings and standardizing format.
*
* @param endpoint Raw endpoint path
* @return Normalized endpoint path
*/
private String normalizeEndpoint(String endpoint) {
if (endpoint == null) {
return null;
}
// Remove query string
int queryIndex = endpoint.indexOf('?');
if (queryIndex != -1) {
endpoint = endpoint.substring(0, queryIndex);
}
// Ensure it starts with /
if (!endpoint.startsWith("/")) {
endpoint = "/" + endpoint;
}
return endpoint;
}
/**
* Determine if an endpoint should be included based on the data type filter.
*
* @param endpoint The endpoint path to check
* @param dataType The filter type: "all", "api", or "ui"
* @return true if the endpoint should be included, false otherwise
*/
private boolean shouldIncludeEndpoint(String endpoint, String dataType) {
if ("all".equalsIgnoreCase(dataType)) {
return true;
}
boolean isApiEndpoint = isApiEndpoint(endpoint);
if ("api".equalsIgnoreCase(dataType)) {
return isApiEndpoint;
} else if ("ui".equalsIgnoreCase(dataType)) {
return !isApiEndpoint;
}
// Default to including all if unrecognized type
return true;
}
/**
* Check if an endpoint is an API endpoint. API endpoints match /api/v1/* pattern but exclude
* /api/v1/auth/* paths.
*
* @param endpoint The endpoint path to check
* @return true if this is an API endpoint (excluding auth endpoints), false otherwise
*/
private boolean isApiEndpoint(String endpoint) {
if (endpoint == null) {
return false;
}
// Check if it starts with /api/v1/
if (!endpoint.startsWith("/api/v1/")) {
return false;
}
// Exclude auth endpoints
if (endpoint.startsWith("/api/v1/auth/")) {
return false;
}
return true;
}
// DTOs for response formatting
@lombok.Data
@lombok.Builder
public static class EndpointStatisticsResponse {
private List<EndpointStatistic> endpoints;
private int totalEndpoints;
private int totalVisits;
}
@lombok.Data
@lombok.Builder
public static class EndpointStatistic {
private String endpoint;
private int visits;
private double percentage;
}
}
@@ -1,65 +0,0 @@
package stirling.software.proprietary.model;
import java.io.Serializable;
import jakarta.persistence.*;
import lombok.*;
/**
* Entity to store user license settings in the database. This is a singleton entity (only one row
* should exist). Tracks grandfathered user counts and license limits.
*/
@Entity
@Table(name = "user_license_settings")
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@ToString
public class UserLicenseSettings implements Serializable {
private static final long serialVersionUID = 1L;
public static final Long SINGLETON_ID = 1L;
@Id
@Column(name = "id")
private Long id = SINGLETON_ID;
/**
* The number of users that existed in the database when grandfathering was initialized. This
* value is set once during initial setup and should NEVER be modified afterwards.
*/
@Column(name = "grandfathered_user_count", nullable = false)
private int grandfatheredUserCount = 0;
/**
* Flag to indicate that grandfathering has been initialized and locked. Once true, the
* grandfatheredUserCount should never change. This prevents manipulation by deleting/recreating
* the table.
*/
@Column(name = "grandfathering_locked", nullable = false)
private boolean grandfatheringLocked = false;
/**
* Maximum number of users allowed by the current license. This is updated when the license key
* is validated.
*/
@Column(name = "license_max_users", nullable = false)
private int licenseMaxUsers = 0;
/**
* Random salt used when generating signatures. Makes it harder to recompute the signature when
* manually editing the table.
*/
@Column(name = "integrity_salt", nullable = false, length = 64)
private String integritySalt = "";
/**
* Signed representation of {@code grandfatheredUserCount}. Stores the original value alongside
* a secret-backed HMAC so we can detect tampering and restore the correct count.
*/
@Column(name = "grandfathered_user_signature", nullable = false, length = 256)
private String grandfatheredUserSignature = "";
}
@@ -57,6 +57,7 @@ public class CustomAuthenticationSuccessHandler
String jwt =
jwtService.generateToken(
authentication, Map.of("authType", AuthenticationType.WEB));
jwtService.addToken(response, jwt);
log.debug("JWT generated for user: {}", userName);
getRedirectStrategy().sendRedirect(request, response, "/");
@@ -72,6 +72,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
} else if (!jwtService.extractToken(request).isBlank()) {
jwtService.clearToken(response);
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
} else {
// Redirect to login page after logout
@@ -114,12 +115,8 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
// Set service provider keys for the SamlClient
samlClient.setSPKeys(certificate, privateKey);
// Build relay state to return user to login page after IdP logout
String relayState =
UrlUtils.getOrigin(request) + request.getContextPath() + LOGOUT_PATH;
// Redirect to identity provider for logout with relay state
samlClient.redirectToIdentityProvider(response, relayState, nameIdValue);
// Redirect to identity provider for logout. todo: add relay state
samlClient.redirectToIdentityProvider(response, null, nameIdValue);
} catch (Exception e) {
log.error(
"Error retrieving logout URL from Provider {} for user {}",
@@ -20,7 +20,6 @@ import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@Slf4j
@Component
@@ -31,7 +30,6 @@ public class InitialSecuritySetup {
private final TeamService teamService;
private final ApplicationProperties applicationProperties;
private final DatabaseServiceInterface databaseService;
private final UserLicenseSettingsService licenseSettingsService;
@PostConstruct
public void init() {
@@ -47,18 +45,12 @@ public class InitialSecuritySetup {
assignUsersToDefaultTeamIfMissing();
initializeInternalApiUser();
initializeUserLicenseSettings();
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
log.error("Failed to initialize security setup.", e);
System.exit(1);
}
}
private void initializeUserLicenseSettings() {
licenseSettingsService.initializeGrandfatheredCount();
licenseSettingsService.updateLicenseMaxUsers();
}
private void assignUsersToDefaultTeamIfMissing() {
Team defaultTeam = teamService.getOrCreateDefaultTeam();
Team internalTeam = teamService.getOrCreateInternalTeam();
@@ -13,7 +13,7 @@ public class RateLimitResetScheduler {
private final IPRateLimitingFilter rateLimitingFilter;
@Scheduled(cron = "${security.rate-limit.reset-schedule:0 0 0 * * MON}")
@Scheduled(cron = "0 0 0 * * MON") // At 00:00 every Monday TODO: configurable
public void resetRateLimit() {
rateLimitingFilter.resetRequestCounts();
}
@@ -25,8 +25,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
@EnableJpaRepositories(
basePackages = {
"stirling.software.proprietary.security.database.repository",
"stirling.software.proprietary.security.repository",
"stirling.software.proprietary.repository"
"stirling.software.proprietary.security.repository"
})
@EntityScan({"stirling.software.proprietary.security.model", "stirling.software.proprietary.model"})
public class DatabaseConfig {
@@ -39,6 +39,7 @@ import stirling.software.proprietary.security.CustomLogoutSuccessHandler;
import stirling.software.proprietary.security.JwtAuthenticationEntryPoint;
import stirling.software.proprietary.security.database.repository.JPATokenRepositoryImpl;
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
import stirling.software.proprietary.security.filter.FirstLoginFilter;
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
import stirling.software.proprietary.security.filter.JwtAuthenticationFilter;
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
@@ -73,6 +74,7 @@ public class SecurityConfiguration {
private final JwtServiceInterface jwtService;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final LoginAttemptService loginAttemptService;
private final FirstLoginFilter firstLoginFilter;
private final SessionPersistentRegistry sessionRegistry;
private final PersistentLoginRepository persistentLoginRepository;
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
@@ -91,6 +93,7 @@ public class SecurityConfiguration {
JwtServiceInterface jwtService,
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
LoginAttemptService loginAttemptService,
FirstLoginFilter firstLoginFilter,
SessionPersistentRegistry sessionRegistry,
@Autowired(required = false) GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper,
@Autowired(required = false)
@@ -107,6 +110,7 @@ public class SecurityConfiguration {
this.jwtService = jwtService;
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
this.loginAttemptService = loginAttemptService;
this.firstLoginFilter = firstLoginFilter;
this.sessionRegistry = sessionRegistry;
this.persistentLoginRepository = persistentLoginRepository;
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
@@ -128,14 +132,19 @@ public class SecurityConfiguration {
if (loginEnabledValue) {
boolean v2Enabled = appConfig.v2Enabled();
if (v2Enabled) {
http.addFilterBefore(
jwtAuthenticationFilter(),
UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(
exceptionHandling ->
exceptionHandling.authenticationEntryPoint(
jwtAuthenticationEntryPoint));
}
http.addFilterBefore(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(
rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class);
if (v2Enabled) {
http.addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class);
}
.addFilterAfter(rateLimitingFilter(), UserAuthenticationFilter.class)
.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class);
if (!securityProperties.getCsrfDisabled()) {
CookieCsrfTokenRepository cookieRepo =
@@ -147,13 +156,6 @@ public class SecurityConfiguration {
csrf ->
csrf.ignoringRequestMatchers(
request -> {
String uri = request.getRequestURI();
// Ignore CSRF for auth endpoints
if (uri.startsWith("/api/v1/auth/")) {
return true;
}
String apiKey = request.getHeader("X-API-KEY");
// If there's no API key, don't ignore CSRF
// (return false)
@@ -236,13 +238,9 @@ public class SecurityConfiguration {
: uri;
return trimmedUri.startsWith("/login")
|| trimmedUri.startsWith("/oauth")
|| trimmedUri.startsWith("/oauth2")
|| trimmedUri.startsWith("/saml2")
|| trimmedUri.endsWith(".svg")
|| trimmedUri.startsWith("/register")
|| trimmedUri.startsWith("/signup")
|| trimmedUri.startsWith("/invite")
|| trimmedUri.startsWith("/auth/callback")
|| trimmedUri.startsWith("/error")
|| trimmedUri.startsWith("/images/")
|| trimmedUri.startsWith("/public/")
@@ -254,20 +252,6 @@ public class SecurityConfiguration {
|| trimmedUri.startsWith("/favicon")
|| trimmedUri.startsWith(
"/api/v1/info/status")
|| trimmedUri.startsWith("/api/v1/config")
|| trimmedUri.startsWith(
"/api/v1/auth/register")
|| trimmedUri.startsWith(
"/api/v1/user/register")
|| trimmedUri.startsWith(
"/api/v1/auth/login")
|| trimmedUri.startsWith(
"/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/me")
|| trimmedUri.startsWith(
"/api/v1/invite/validate")
|| trimmedUri.startsWith(
"/api/v1/invite/accept")
|| trimmedUri.startsWith("/v1/api-docs")
|| uri.contains("/v1/api-docs");
})
@@ -293,40 +277,33 @@ public class SecurityConfiguration {
// Handle OAUTH2 Logins
if (securityProperties.isOauth2Active()) {
http.oauth2Login(
oauth2 -> {
// v1: Use /oauth2 as login page for Thymeleaf templates
if (!v2Enabled) {
oauth2.loginPage("/oauth2");
}
// v2: Don't set loginPage, let default OAuth2 flow handle it
oauth2
/*
This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database.
If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser'
is set as true, else login fails with an error message advising the same.
*/
.successHandler(
new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
securityProperties.getOauth2(),
userService,
jwtService))
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
// Add existing Authorities from the database
.userInfoEndpoint(
userInfoEndpoint ->
userInfoEndpoint
.oidcUserService(
new CustomOAuth2UserService(
securityProperties
.getOauth2(),
userService,
loginAttemptService))
.userAuthoritiesMapper(
oAuth2userAuthoritiesMapper))
.permitAll();
});
oauth2 ->
oauth2.loginPage("/oauth2")
/*
This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database.
If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser'
is set as true, else login fails with an error message advising the same.
*/
.successHandler(
new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
securityProperties.getOauth2(),
userService,
jwtService))
.failureHandler(
new CustomOAuth2AuthenticationFailureHandler())
// Add existing Authorities from the database
.userInfoEndpoint(
userInfoEndpoint ->
userInfoEndpoint
.oidcUserService(
new CustomOAuth2UserService(
securityProperties,
userService,
loginAttemptService))
.userAuthoritiesMapper(
oAuth2userAuthoritiesMapper))
.permitAll());
}
// Handle SAML
if (securityProperties.isSaml2Active() && runningProOrHigher) {
@@ -12,6 +12,7 @@ import org.springframework.core.annotation.Order;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.EnterpriseEdition;
import stirling.software.common.model.ApplicationProperties.Premium;
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.GoogleDrive;
@Configuration
@Order(Ordered.HIGHEST_PRECEDENCE)
@@ -54,6 +55,19 @@ public class EEAppConfig {
return applicationProperties.getPremium().getProFeatures().isSsoAutoLogin();
}
@Profile("security")
@Bean(name = "GoogleDriveEnabled")
@Primary
public boolean googleDriveEnabled() {
return runningProOrHigher()
&& applicationProperties.getPremium().getProFeatures().getGoogleDrive().isEnabled();
}
@Bean(name = "GoogleDriveConfig")
public GoogleDrive googleDriveConfig() {
return applicationProperties.getPremium().getProFeatures().getGoogleDrive();
}
// TODO: Remove post migration
@SuppressWarnings("deprecation")
public void migrateEnterpriseSettingsToPremium(ApplicationProperties applicationProperties) {
@@ -5,20 +5,14 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.GeneralUtils;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@Slf4j
@Component
@@ -30,36 +24,21 @@ public class LicenseKeyChecker {
private final ApplicationProperties applicationProperties;
private final UserLicenseSettingsService licenseSettingsService;
private License premiumEnabledResult = License.NORMAL;
public LicenseKeyChecker(
KeygenLicenseVerifier licenseService,
ApplicationProperties applicationProperties,
@Lazy UserLicenseSettingsService licenseSettingsService) {
KeygenLicenseVerifier licenseService, ApplicationProperties applicationProperties) {
this.licenseService = licenseService;
this.applicationProperties = applicationProperties;
this.licenseSettingsService = licenseSettingsService;
}
@PostConstruct
public void init() {
evaluateLicense();
}
@EventListener(ApplicationReadyEvent.class)
public void onApplicationReady() {
synchronizeLicenseSettings();
this.checkLicense();
}
@Scheduled(initialDelay = 604800000, fixedRate = 604800000) // 7 days in milliseconds
public void checkLicensePeriodically() {
evaluateLicense();
synchronizeLicenseSettings();
checkLicense();
}
private void evaluateLicense() {
private void checkLicense() {
if (!applicationProperties.getPremium().isEnabled()) {
premiumEnabledResult = License.NORMAL;
} else {
@@ -80,10 +59,6 @@ public class LicenseKeyChecker {
}
}
private void synchronizeLicenseSettings() {
licenseSettingsService.updateLicenseMaxUsers();
}
private String getLicenseKeyContent(String keyOrFilePath) {
if (keyOrFilePath == null || keyOrFilePath.trim().isEmpty()) {
log.error("License key is not specified");
@@ -114,8 +89,7 @@ public class LicenseKeyChecker {
public void updateLicenseKey(String newKey) throws IOException {
applicationProperties.getPremium().setKey(newKey);
GeneralUtils.saveKeyToSettings("EnterpriseEdition.key", newKey);
evaluateLicense();
synchronizeLicenseSettings();
checkLicense();
}
public License getPremiumLicenseEnabledResult() {
@@ -1,27 +1,19 @@
package stirling.software.proprietary.security.controller.api;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ApplicationContext;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
@@ -40,9 +32,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.AdminApi;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.AppArgsCapture;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.JarPathUtil;
import stirling.software.proprietary.security.model.api.admin.SettingValueResponse;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest;
@@ -55,7 +45,6 @@ public class AdminSettingsController {
private final ApplicationProperties applicationProperties;
private final ObjectMapper objectMapper;
private final ApplicationContext applicationContext;
// Track settings that have been modified but not yet applied (require restart)
private static final ConcurrentHashMap<String, Object> pendingChanges =
@@ -206,8 +195,7 @@ public class AdminSettingsController {
@Operation(
summary = "Get specific settings section",
description =
"Retrieve settings for a specific section (e.g., security, system, ui). "
+ "By default includes pending changes with awaitingRestart flags. Admin access required.")
"Retrieve settings for a specific section (e.g., security, system, ui). Admin access required.")
@ApiResponses(
value = {
@ApiResponse(
@@ -218,9 +206,7 @@ public class AdminSettingsController {
responseCode = "403",
description = "Access denied - Admin role required")
})
public ResponseEntity<?> getSettingsSection(
@PathVariable String sectionName,
@RequestParam(defaultValue = "true") boolean includePending) {
public ResponseEntity<?> getSettingsSection(@PathVariable String sectionName) {
try {
Object sectionData = getSectionData(sectionName);
if (sectionData == null) {
@@ -231,24 +217,8 @@ public class AdminSettingsController {
+ ". Valid sections: "
+ String.join(", ", VALID_SECTION_NAMES));
}
// Convert to Map for manipulation
@SuppressWarnings("unchecked")
Map<String, Object> sectionMap = objectMapper.convertValue(sectionData, Map.class);
if (includePending && !pendingChanges.isEmpty()) {
// Add pending changes block for this section
Map<String, Object> sectionPending = extractPendingForSection(sectionName);
if (!sectionPending.isEmpty()) {
sectionMap.put("_pending", sectionPending);
}
}
log.debug(
"Admin requested settings section: {} (includePending={})",
sectionName,
includePending);
return ResponseEntity.ok(sectionMap);
log.debug("Admin requested settings section: {}", sectionName);
return ResponseEntity.ok(sectionData);
} catch (IllegalArgumentException e) {
log.error("Invalid section name {}: {}", sectionName, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
@@ -418,101 +388,6 @@ public class AdminSettingsController {
}
}
@PostMapping("/restart")
@Operation(
summary = "Restart the application",
description =
"Triggers a graceful restart of the Spring Boot application to apply pending settings changes. Uses a restart helper to ensure proper restart. Admin access required.")
@ApiResponses(
value = {
@ApiResponse(responseCode = "200", description = "Restart initiated successfully"),
@ApiResponse(
responseCode = "403",
description = "Access denied - Admin role required"),
@ApiResponse(responseCode = "500", description = "Failed to initiate restart")
})
public ResponseEntity<String> restartApplication() {
try {
log.warn("Admin initiated application restart");
// Get paths to current JAR and restart helper
Path appJar = JarPathUtil.currentJar();
Path helperJar = JarPathUtil.restartHelperJar();
if (appJar == null) {
log.error("Cannot restart: not running from JAR (likely development mode)");
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(
"Restart not available in development mode. Please restart the application manually.");
}
if (helperJar == null || !Files.isRegularFile(helperJar)) {
log.error("Cannot restart: restart-helper.jar not found at expected location");
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body("Restart helper not found. Please restart the application manually.");
}
// Get current application arguments
List<String> appArgs = AppArgsCapture.APP_ARGS.get();
// Write args to temp file to avoid command-line quoting issues
Path argsFile = Files.createTempFile("stirling-app-args-", ".txt");
Files.write(argsFile, appArgs, StandardCharsets.UTF_8);
// Get current process PID and java executable
long pid = ProcessHandle.current().pid();
String javaBin = JarPathUtil.javaExecutable();
// Build command to launch restart helper
List<String> cmd = new ArrayList<>();
cmd.add(javaBin);
cmd.add("-jar");
cmd.add(helperJar.toString());
cmd.add("--pid");
cmd.add(Long.toString(pid));
cmd.add("--app");
cmd.add(appJar.toString());
cmd.add("--argsFile");
cmd.add(argsFile.toString());
cmd.add("--backoffMs");
cmd.add("1000");
log.info("Launching restart helper: {}", String.join(" ", cmd));
// Launch restart helper process
new ProcessBuilder(cmd)
.directory(appJar.getParent().toFile())
.inheritIO() // Forward logs
.start();
// Clear pending changes since we're restarting
pendingChanges.clear();
// Give the HTTP response time to complete, then exit
new Thread(
() -> {
try {
Thread.sleep(1000);
log.info("Shutting down for restart...");
SpringApplication.exit(applicationContext, () -> 0);
System.exit(0);
} catch (InterruptedException e) {
log.error("Restart interrupted: {}", e.getMessage(), e);
Thread.currentThread().interrupt();
}
})
.start();
return ResponseEntity.ok(
"Application restart initiated. The server will be back online shortly.");
} catch (Exception e) {
log.error("Failed to initiate restart: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to initiate application restart: " + e.getMessage());
}
}
private Object getSectionData(String sectionName) {
if (sectionName == null || sectionName.trim().isEmpty()) {
return null;
@@ -751,62 +626,4 @@ public class AdminSettingsController {
return mergedSettings;
}
/**
* Extract pending changes for a specific section
*
* @param sectionName The section name (e.g., "security", "system")
* @return Map of pending changes with nested structure for this section
*/
@SuppressWarnings("unchecked")
private Map<String, Object> extractPendingForSection(String sectionName) {
Map<String, Object> result = new HashMap<>();
String sectionPrefix = sectionName.toLowerCase() + ".";
// Find all pending changes for this section
for (Map.Entry<String, Object> entry : pendingChanges.entrySet()) {
String pendingKey = entry.getKey();
if (pendingKey.toLowerCase().startsWith(sectionPrefix)) {
// Extract the path within the section (e.g., "security.enableLogin" ->
// "enableLogin")
String pathInSection = pendingKey.substring(sectionPrefix.length());
Object pendingValue = entry.getValue();
// Build nested structure from dot notation
setNestedValue(result, pathInSection, pendingValue);
}
}
return result;
}
/**
* Set a value in a nested map using dot notation
*
* @param map The root map
* @param dotPath The dot notation path (e.g., "oauth2.clientSecret")
* @param value The value to set
*/
@SuppressWarnings("unchecked")
private void setNestedValue(Map<String, Object> map, String dotPath, Object value) {
String[] parts = dotPath.split("\\.");
Map<String, Object> current = map;
// Navigate/create nested maps for all parts except the last
for (int i = 0; i < parts.length - 1; i++) {
String part = parts[i];
Object nested = current.get(part);
if (!(nested instanceof Map)) {
nested = new HashMap<String, Object>();
current.put(part, nested);
}
current = (Map<String, Object>) nested;
}
// Set the final value
current.put(parts[parts.length - 1], value);
}
}
@@ -1,238 +0,0 @@
package stirling.software.proprietary.security.controller.api;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.UserService;
/** REST API Controller for authentication operations. */
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Authentication", description = "Endpoints for user authentication and registration")
public class AuthController {
private final UserService userService;
private final JwtServiceInterface jwtService;
private final CustomUserDetailsService userDetailsService;
/**
* Login endpoint - replaces Supabase signInWithPassword
*
* @param request Login credentials (email/username and password)
* @param response HTTP response to set JWT cookie
* @return User and session information
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/login")
public ResponseEntity<?> login(
@RequestBody UsernameAndPass request, HttpServletResponse response) {
try {
// Validate input parameters
if (request.getUsername() == null || request.getUsername().trim().isEmpty()) {
log.warn("Login attempt with null or empty username");
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Username is required"));
}
if (request.getPassword() == null || request.getPassword().isEmpty()) {
log.warn(
"Login attempt with null or empty password for user: {}",
request.getUsername());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Password is required"));
}
log.debug("Login attempt for user: {}", request.getUsername());
UserDetails userDetails =
userDetailsService.loadUserByUsername(request.getUsername().trim());
User user = (User) userDetails;
if (!userService.isPasswordCorrect(user, request.getPassword())) {
log.warn("Invalid password for user: {}", request.getUsername());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid credentials"));
}
if (!user.isEnabled()) {
log.warn("Disabled user attempted login: {}", request.getUsername());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "User account is disabled"));
}
Map<String, Object> claims = new HashMap<>();
claims.put("authType", AuthenticationType.WEB.toString());
claims.put("role", user.getRolesAsString());
String token = jwtService.generateToken(user.getUsername(), claims);
log.info("Login successful for user: {}", request.getUsername());
return ResponseEntity.ok(
Map.of(
"user", buildUserResponse(user),
"session", Map.of("access_token", token, "expires_in", 3600)));
} catch (UsernameNotFoundException e) {
log.warn("User not found: {}", request.getUsername());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid username or password"));
} catch (AuthenticationException e) {
log.error("Authentication failed for user: {}", request.getUsername(), e);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid credentials"));
} catch (Exception e) {
log.error("Login error for user: {}", request.getUsername(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Internal server error"));
}
}
/**
* Get current user
*
* @return Current authenticated user information
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@GetMapping("/me")
public ResponseEntity<?> getCurrentUser() {
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null
|| !auth.isAuthenticated()
|| auth.getPrincipal().equals("anonymousUser")) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Not authenticated"));
}
UserDetails userDetails = (UserDetails) auth.getPrincipal();
User user = (User) userDetails;
return ResponseEntity.ok(Map.of("user", buildUserResponse(user)));
} catch (Exception e) {
log.error("Get current user error", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Internal server error"));
}
}
/**
* Logout endpoint
*
* @param response HTTP response
* @return Success message
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletResponse response) {
try {
SecurityContextHolder.clearContext();
log.debug("User logged out successfully");
return ResponseEntity.ok(Map.of("message", "Logged out successfully"));
} catch (Exception e) {
log.error("Logout error", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Internal server error"));
}
}
/**
* Refresh token
*
* @param request HTTP request containing current JWT cookie
* @param response HTTP response to set new JWT cookie
* @return New token information
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/refresh")
public ResponseEntity<?> refresh(HttpServletRequest request, HttpServletResponse response) {
try {
String token = jwtService.extractToken(request);
if (token == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "No token found"));
}
jwtService.validateToken(token);
String username = jwtService.extractUsername(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
User user = (User) userDetails;
Map<String, Object> claims = new HashMap<>();
claims.put("authType", user.getAuthenticationType());
claims.put("role", user.getRolesAsString());
String newToken = jwtService.generateToken(username, claims);
log.debug("Token refreshed for user: {}", username);
return ResponseEntity.ok(Map.of("access_token", newToken, "expires_in", 3600));
} catch (Exception e) {
log.error("Token refresh error", e);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Token refresh failed"));
}
}
/**
* Helper method to build user response object
*
* @param user User entity
* @return Map containing user information
*/
private Map<String, Object> buildUserResponse(User user) {
Map<String, Object> userMap = new HashMap<>();
userMap.put("id", user.getId());
userMap.put("email", user.getUsername()); // Use username as email
userMap.put("username", user.getUsername());
userMap.put("role", user.getRolesAsString());
userMap.put("enabled", user.isEnabled());
// Add metadata for OAuth compatibility
Map<String, Object> appMetadata = new HashMap<>();
appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider
userMap.put("app_metadata", appMetadata);
return userMap;
}
// ===========================
// Request/Response DTOs
// ===========================
/** Login request DTO */
public record LoginRequest(String email, String password) {}
}
@@ -2,19 +2,21 @@ package stirling.software.proprietary.security.controller.api;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import org.eclipse.jetty.http.HttpStatus;
import org.springframework.context.annotation.Conditional;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
@@ -40,19 +42,15 @@ public class DatabaseController {
summary = "Import a database backup file",
description = "Uploads and imports a database backup SQL file.")
@PostMapping(consumes = "multipart/form-data", value = "import-database")
public ResponseEntity<?> importDatabase(
public String importDatabase(
@Parameter(description = "SQL file to import", required = true)
@RequestParam("fileInput")
MultipartFile file)
MultipartFile file,
RedirectAttributes redirectAttributes)
throws IOException {
if (file == null || file.isEmpty()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
java.util.Map.of(
"error",
"fileNullOrEmpty",
"message",
"File is null or empty"));
redirectAttributes.addAttribute("error", "fileNullOrEmpty");
return "redirect:/database";
}
log.info("Received file: {}", file.getOriginalFilename());
Path tempTemplatePath = Files.createTempFile("backup_", ".sql");
@@ -60,31 +58,15 @@ public class DatabaseController {
Files.copy(in, tempTemplatePath, StandardCopyOption.REPLACE_EXISTING);
boolean importSuccess = databaseService.importDatabaseFromUI(tempTemplatePath);
if (importSuccess) {
return ResponseEntity.ok(
java.util.Map.of(
"message",
"importIntoDatabaseSuccessed",
"description",
"Database imported successfully"));
redirectAttributes.addAttribute("infoMessage", "importIntoDatabaseSuccessed");
} else {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
java.util.Map.of(
"error",
"failedImportFile",
"message",
"Failed to import database file"));
redirectAttributes.addAttribute("error", "failedImportFile");
}
} catch (Exception e) {
log.error("Error importing database: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
java.util.Map.of(
"error",
"failedImportFile",
"message",
"Failed to import database: " + e.getMessage()));
redirectAttributes.addAttribute("error", "failedImportFile");
}
return "redirect:/database";
}
@Hidden
@@ -92,17 +74,11 @@ public class DatabaseController {
summary = "Import database backup by filename",
description = "Imports a database backup file from the server using its file name.")
@GetMapping("/import-database-file/{fileName}")
public ResponseEntity<?> importDatabaseFromBackupUI(
public String importDatabaseFromBackupUI(
@Parameter(description = "Name of the file to import", required = true) @PathVariable
String fileName) {
if (fileName == null || fileName.isEmpty()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
java.util.Map.of(
"error",
"fileNullOrEmpty",
"message",
"File name is null or empty"));
return "redirect:/database?error=fileNullOrEmpty";
}
// Check if the file exists in the backup list
boolean fileExists =
@@ -110,31 +86,14 @@ public class DatabaseController {
.anyMatch(backup -> backup.getFileName().equals(fileName));
if (!fileExists) {
log.error("File {} not found in backup list", fileName);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(
java.util.Map.of(
"error",
"fileNotFound",
"message",
"File not found in backup list"));
return "redirect:/database?error=fileNotFound";
}
log.info("Received file: {}", fileName);
if (databaseService.importDatabaseFromUI(fileName)) {
log.info("File {} imported to database", fileName);
return ResponseEntity.ok(
java.util.Map.of(
"message",
"importIntoDatabaseSuccessed",
"description",
"Database backup imported successfully"));
return "redirect:/database?infoMessage=importIntoDatabaseSuccessed";
}
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
java.util.Map.of(
"error",
"failedImportFile",
"message",
"Failed to import database file"));
return "redirect:/database?error=failedImportFile";
}
@Hidden
@@ -142,42 +101,24 @@ public class DatabaseController {
summary = "Delete a database backup file",
description = "Deletes a specified database backup file from the server.")
@GetMapping("/delete/{fileName}")
public ResponseEntity<?> deleteFile(
public String deleteFile(
@Parameter(description = "Name of the file to delete", required = true) @PathVariable
String fileName) {
if (fileName == null || fileName.isEmpty()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
java.util.Map.of(
"error",
"invalidFileName",
"message",
"File must not be null or empty"));
throw new IllegalArgumentException("File must not be null or empty");
}
try {
if (databaseService.deleteBackupFile(fileName)) {
log.info("Deleted file: {}", fileName);
return ResponseEntity.ok(java.util.Map.of("message", "File deleted successfully"));
} else {
log.error("Failed to delete file: {}", fileName);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
java.util.Map.of(
"error",
"failedToDeleteFile",
"message",
"Failed to delete backup file"));
return "redirect:/database?error=failedToDeleteFile";
}
} catch (IOException e) {
log.error("Error deleting file: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
java.util.Map.of(
"error",
"deleteError",
"message",
"Error deleting file: " + e.getMessage()));
return "redirect:/database?error=" + e.getMessage();
}
return "redirect:/database";
}
@Hidden
@@ -201,29 +142,22 @@ public class DatabaseController {
.body(resource);
} catch (IOException e) {
log.error("Error downloading file: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(
java.util.Map.of(
"error",
"downloadFailed",
"message",
"Failed to download file: " + e.getMessage()));
return ResponseEntity.status(HttpStatus.SEE_OTHER_303)
.location(URI.create("/database?error=downloadFailed"))
.build();
}
}
@Operation(
summary = "Create a database backup",
description = "This endpoint triggers the creation of a database backup.")
description =
"This endpoint triggers the creation of a database backup and redirects to the"
+ " database management page.")
@GetMapping("/createDatabaseBackup")
public ResponseEntity<?> createDatabaseBackup() {
public String createDatabaseBackup() {
log.info("Starting database backup creation...");
databaseService.exportDatabase();
log.info("Database backup successfully created.");
return ResponseEntity.ok(
java.util.Map.of(
"message",
"backupCreated",
"description",
"Database backup created successfully"));
return "redirect:/database?infoMessage=backupCreated";
}
}
@@ -1,484 +0,0 @@
package stirling.software.proprietary.security.controller.api;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.UserApi;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.InviteToken;
import stirling.software.proprietary.security.repository.InviteTokenRepository;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
@UserApi
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/invite")
public class InviteLinkController {
private final InviteTokenRepository inviteTokenRepository;
private final TeamRepository teamRepository;
private final UserService userService;
private final ApplicationProperties applicationProperties;
private final Optional<EmailService> emailService;
/**
* Generate a new invite link (admin only)
*
* @param email The email address to invite
* @param role The role to assign (default: ROLE_USER)
* @param teamId The team to assign (optional, uses default team if not provided)
* @param expiryHours Custom expiry hours (optional, uses default from config)
* @param sendEmail Whether to send the invite link via email (default: false)
* @param principal The authenticated admin user
* @param request The HTTP request
* @return ResponseEntity with the invite link or error
*/
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/generate")
public ResponseEntity<?> generateInviteLink(
@RequestParam(name = "email", required = false) String email,
@RequestParam(name = "role", defaultValue = "ROLE_USER") String role,
@RequestParam(name = "teamId", required = false) Long teamId,
@RequestParam(name = "expiryHours", required = false) Integer expiryHours,
@RequestParam(name = "sendEmail", defaultValue = "false") boolean sendEmail,
Principal principal,
HttpServletRequest request) {
try {
// Check if email invites are enabled
if (!applicationProperties.getMail().isEnableInvites()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Email invites are not enabled"));
}
// If email is provided, validate and check for conflicts
if (email != null && !email.trim().isEmpty()) {
// Validate email format
if (!email.contains("@")) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Invalid email address"));
}
email = email.trim().toLowerCase();
// Check if user already exists
if (userService.usernameExistsIgnoreCase(email)) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "User already exists"));
}
// Check if there's already an active invite for this email
Optional<InviteToken> existingInvite = inviteTokenRepository.findByEmail(email);
if (existingInvite.isPresent() && existingInvite.get().isValid()) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(
Map.of(
"error",
"An active invite already exists for this email address"));
}
// If sendEmail is requested but no email provided, reject
if (sendEmail) {
// Email will be sent
}
} else {
// No email provided - this is a general invite link
email = null; // Ensure it's null, not empty string
// Cannot send email if no email address provided
if (sendEmail) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot send email without an email address"));
}
}
// Check license limits
if (applicationProperties.getPremium().isEnabled()) {
long currentUserCount = userService.getTotalUsersCount();
long activeInvites = inviteTokenRepository.countActiveInvites(LocalDateTime.now());
int maxUsers = applicationProperties.getPremium().getMaxUsers();
if (currentUserCount + activeInvites >= maxUsers) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
Map.of(
"error",
"Maximum number of users reached for your license"));
}
}
// Validate role
try {
Role roleEnum = Role.fromString(role);
if (roleEnum == Role.INTERNAL_API_USER) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot assign INTERNAL_API_USER role"));
}
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Invalid role specified"));
}
// Determine team
Long effectiveTeamId = teamId;
if (effectiveTeamId == null) {
Team defaultTeam =
teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
if (defaultTeam != null) {
effectiveTeamId = defaultTeam.getId();
}
} else {
Team selectedTeam = teamRepository.findById(effectiveTeamId).orElse(null);
if (selectedTeam != null
&& TeamService.INTERNAL_TEAM_NAME.equals(selectedTeam.getName())) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot assign users to Internal team"));
}
}
// Generate token
String token = UUID.randomUUID().toString();
// Determine expiry time
int effectiveExpiryHours =
(expiryHours != null && expiryHours > 0)
? expiryHours
: applicationProperties.getMail().getInviteLinkExpiryHours();
LocalDateTime expiresAt = LocalDateTime.now().plusHours(effectiveExpiryHours);
// Create invite token
InviteToken inviteToken = new InviteToken();
inviteToken.setToken(token);
inviteToken.setEmail(email);
inviteToken.setRole(role);
inviteToken.setTeamId(effectiveTeamId);
inviteToken.setExpiresAt(expiresAt);
inviteToken.setCreatedBy(principal.getName());
inviteTokenRepository.save(inviteToken);
// Build invite URL
// Use configured frontend URL if available, otherwise fall back to backend URL
String baseUrl;
String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl();
if (configuredFrontendUrl != null && !configuredFrontendUrl.trim().isEmpty()) {
// Use configured frontend URL (remove trailing slash if present)
baseUrl =
configuredFrontendUrl.endsWith("/")
? configuredFrontendUrl.substring(
0, configuredFrontendUrl.length() - 1)
: configuredFrontendUrl;
} else {
// Fall back to backend URL from request
baseUrl =
request.getScheme()
+ "://"
+ request.getServerName()
+ (request.getServerPort() != 80 && request.getServerPort() != 443
? ":" + request.getServerPort()
: "");
}
String inviteUrl = baseUrl + "/invite?token=" + token;
log.info("Generated invite link for {} by {}", email, principal.getName());
// Optionally send email
boolean emailSent = false;
String emailError = null;
if (sendEmail) {
if (!emailService.isPresent()) {
emailError = "Email service is not configured";
log.warn("Cannot send invite email: Email service not configured");
} else {
try {
emailService
.get()
.sendInviteLinkEmail(email, inviteUrl, expiresAt.toString());
emailSent = true;
log.info("Sent invite link email to: {}", email);
} catch (Exception emailEx) {
emailError = emailEx.getMessage();
log.error(
"Failed to send invite email to {}: {}",
email,
emailEx.getMessage());
}
}
}
Map<String, Object> response = new HashMap<>();
response.put("token", token);
response.put("inviteUrl", inviteUrl);
response.put("email", email);
response.put("expiresAt", expiresAt.toString());
response.put("expiryHours", effectiveExpiryHours);
if (sendEmail) {
response.put("emailSent", emailSent);
if (emailError != null) {
response.put("emailError", emailError);
}
}
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("Failed to generate invite link: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to generate invite link: " + e.getMessage()));
}
}
/**
* List all active invite links (admin only)
*
* @return List of active invite tokens
*/
@PreAuthorize("hasRole('ROLE_ADMIN')")
@GetMapping("/list")
public ResponseEntity<?> listInviteLinks() {
try {
List<InviteToken> activeInvites =
inviteTokenRepository.findByUsedFalseAndExpiresAtAfter(LocalDateTime.now());
List<Map<String, Object>> inviteList =
activeInvites.stream()
.map(
invite -> {
Map<String, Object> inviteMap = new HashMap<>();
inviteMap.put("id", invite.getId());
inviteMap.put("email", invite.getEmail());
inviteMap.put("role", invite.getRole());
inviteMap.put("teamId", invite.getTeamId());
inviteMap.put("createdBy", invite.getCreatedBy());
inviteMap.put(
"createdAt", invite.getCreatedAt().toString());
inviteMap.put(
"expiresAt", invite.getExpiresAt().toString());
return inviteMap;
})
.collect(Collectors.toList());
return ResponseEntity.ok(Map.of("invites", inviteList));
} catch (Exception e) {
log.error("Failed to list invite links: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to list invite links"));
}
}
/**
* Revoke an invite link (admin only)
*
* @param inviteId The invite token ID to revoke
* @return Success or error response
*/
@PreAuthorize("hasRole('ROLE_ADMIN')")
@DeleteMapping("/revoke/{inviteId}")
public ResponseEntity<?> revokeInviteLink(@PathVariable Long inviteId) {
try {
Optional<InviteToken> inviteOpt = inviteTokenRepository.findById(inviteId);
if (inviteOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "Invite not found"));
}
inviteTokenRepository.deleteById(inviteId);
log.info("Revoked invite link ID: {}", inviteId);
return ResponseEntity.ok(Map.of("message", "Invite link revoked successfully"));
} catch (Exception e) {
log.error("Failed to revoke invite link: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to revoke invite link"));
}
}
/**
* Clean up expired invite tokens (admin only)
*
* @return Number of deleted tokens
*/
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/cleanup")
public ResponseEntity<?> cleanupExpiredInvites() {
try {
List<InviteToken> expiredInvites =
inviteTokenRepository.findAll().stream()
.filter(invite -> !invite.isValid())
.collect(Collectors.toList());
int count = expiredInvites.size();
inviteTokenRepository.deleteAll(expiredInvites);
log.info("Cleaned up {} expired invite tokens", count);
return ResponseEntity.ok(Map.of("deletedCount", count));
} catch (Exception e) {
log.error("Failed to cleanup expired invites: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to cleanup expired invites"));
}
}
/**
* Validate an invite token (public endpoint)
*
* @param token The invite token to validate
* @return Invite details if valid, error otherwise
*/
@GetMapping("/validate/{token}")
public ResponseEntity<?> validateInviteToken(@PathVariable String token) {
try {
Optional<InviteToken> inviteOpt = inviteTokenRepository.findByToken(token);
if (inviteOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "Invalid invite link"));
}
InviteToken invite = inviteOpt.get();
if (invite.isUsed()) {
return ResponseEntity.status(HttpStatus.GONE)
.body(Map.of("error", "This invite link has already been used"));
}
if (invite.isExpired()) {
return ResponseEntity.status(HttpStatus.GONE)
.body(Map.of("error", "This invite link has expired"));
}
// Check if user already exists (only if email is pre-set)
if (invite.getEmail() != null
&& userService.usernameExistsIgnoreCase(invite.getEmail())) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "User already exists"));
}
Map<String, Object> response = new HashMap<>();
response.put("email", invite.getEmail());
response.put("role", invite.getRole());
response.put("expiresAt", invite.getExpiresAt().toString());
response.put("emailRequired", invite.getEmail() == null);
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("Failed to validate invite token: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to validate invite link"));
}
}
/**
* Accept an invite and create user account (public endpoint)
*
* @param token The invite token
* @param email The email address (required if not pre-set in invite)
* @param password The password to set for the new account
* @return Success or error response
*/
@PostMapping("/accept/{token}")
public ResponseEntity<?> acceptInvite(
@PathVariable String token,
@RequestParam(name = "email", required = false) String email,
@RequestParam(name = "password") String password) {
try {
// Validate password
if (password == null || password.isEmpty()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Password is required"));
}
Optional<InviteToken> inviteOpt = inviteTokenRepository.findByToken(token);
if (inviteOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "Invalid invite link"));
}
InviteToken invite = inviteOpt.get();
if (invite.isUsed()) {
return ResponseEntity.status(HttpStatus.GONE)
.body(Map.of("error", "This invite link has already been used"));
}
if (invite.isExpired()) {
return ResponseEntity.status(HttpStatus.GONE)
.body(Map.of("error", "This invite link has expired"));
}
// Determine the email to use
String effectiveEmail = invite.getEmail();
if (effectiveEmail == null) {
// Email not pre-set, must be provided by user
if (email == null || email.trim().isEmpty()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Email address is required"));
}
// Validate email format
if (!email.contains("@")) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Invalid email address"));
}
effectiveEmail = email.trim().toLowerCase();
}
// Check if user already exists
if (userService.usernameExistsIgnoreCase(effectiveEmail)) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "User already exists"));
}
// Create the user account
userService.saveUser(
effectiveEmail,
password,
invite.getTeamId(),
invite.getRole(),
false); // Don't force password change
// Mark invite as used
invite.setUsed(true);
invite.setUsedAt(LocalDateTime.now());
inviteTokenRepository.save(invite);
log.info(
"User account created via invite link: {} with role: {}",
effectiveEmail,
invite.getRole());
return ResponseEntity.ok(
Map.of("message", "Account created successfully", "username", effectiveEmail));
} catch (Exception e) {
log.error("Failed to accept invite: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to create account: " + e.getMessage()));
}
}
}
@@ -1,12 +1,10 @@
package stirling.software.proprietary.security.controller.api;
import java.util.Map;
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.view.RedirectView;
import jakarta.transaction.Transactional;
@@ -32,113 +30,98 @@ public class TeamController {
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/create")
public ResponseEntity<?> createTeam(@RequestParam("name") String name) {
public RedirectView createTeam(@RequestParam("name") String name) {
if (teamRepository.existsByNameIgnoreCase(name)) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "Team name already exists."));
return new RedirectView("/teams?messageType=teamExists");
}
Team team = new Team();
team.setName(name);
teamRepository.save(team);
return ResponseEntity.ok(Map.of("message", "Team created successfully"));
return new RedirectView("/teams?messageType=teamCreated");
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/rename")
public ResponseEntity<?> renameTeam(
public RedirectView renameTeam(
@RequestParam("teamId") Long teamId, @RequestParam("newName") String newName) {
Optional<Team> existing = teamRepository.findById(teamId);
if (existing.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "Team not found."));
return new RedirectView("/teams?messageType=teamNotFound");
}
if (teamRepository.existsByNameIgnoreCase(newName)) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "Team name already exists."));
return new RedirectView("/teams?messageType=teamNameExists");
}
Team team = existing.get();
// Prevent renaming the Internal team
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot rename Internal team."));
return new RedirectView("/teams?messageType=internalTeamNotAccessible");
}
team.setName(newName);
teamRepository.save(team);
return ResponseEntity.ok(Map.of("message", "Team renamed successfully"));
return new RedirectView("/teams?messageType=teamRenamed");
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/delete")
@Transactional
public ResponseEntity<?> deleteTeam(@RequestParam("teamId") Long teamId) {
public RedirectView deleteTeam(@RequestParam("teamId") Long teamId) {
Optional<Team> teamOpt = teamRepository.findById(teamId);
if (teamOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "Team not found."));
return new RedirectView("/teams?messageType=teamNotFound");
}
Team team = teamOpt.get();
// Prevent deleting the Internal team
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot delete Internal team."));
return new RedirectView("/teams?messageType=internalTeamNotAccessible");
}
long memberCount = userRepository.countByTeam(team);
if (memberCount > 0) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
Map.of(
"error",
"Team must be empty before deletion. Please remove all members first."));
return new RedirectView("/teams?messageType=teamHasUsers");
}
teamRepository.delete(team);
return ResponseEntity.ok(Map.of("message", "Team deleted successfully"));
return new RedirectView("/teams?messageType=teamDeleted");
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/addUser")
@Transactional
public ResponseEntity<?> addUserToTeam(
public RedirectView addUserToTeam(
@RequestParam("teamId") Long teamId, @RequestParam("userId") Long userId) {
// Find the team
Optional<Team> teamOpt = teamRepository.findById(teamId);
if (teamOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "Team not found."));
}
Team team = teamOpt.get();
Team team =
teamRepository
.findById(teamId)
.orElseThrow(() -> new RuntimeException("Team not found"));
// Prevent adding users to the Internal team
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot add users to Internal team."));
return new RedirectView("/teams?error=internalTeamNotAccessible");
}
// Find the user
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found."));
}
User user = userOpt.get();
User user =
userRepository
.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found"));
// Check if user is in the Internal team - prevent moving them
if (user.getTeam() != null
&& user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot move users from Internal team."));
return new RedirectView("/teams/" + teamId + "?error=cannotMoveInternalUsers");
}
// Assign user to team
user.setTeam(team);
userRepository.save(user);
return ResponseEntity.ok(Map.of("message", "User added to team successfully"));
// Redirect back to team details page
return new RedirectView("/teams/" + teamId + "?messageType=userAdded");
}
}
@@ -3,7 +3,6 @@ package stirling.software.proprietary.security.controller.api;
import java.io.IOException;
import java.security.Principal;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -16,7 +15,10 @@ import org.springframework.security.core.session.SessionInformation;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.springframework.web.servlet.view.RedirectView;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -36,11 +38,9 @@ import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@UserApi
@Slf4j
@@ -53,215 +53,125 @@ public class UserController {
private final ApplicationProperties applicationProperties;
private final TeamRepository teamRepository;
private final UserRepository userRepository;
private final Optional<EmailService> emailService;
private final UserLicenseSettingsService licenseSettingsService;
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody UsernameAndPass usernameAndPass)
public String register(@ModelAttribute UsernameAndPass requestModel, Model model)
throws SQLException, UnsupportedProviderException {
try {
log.debug("Registration attempt for user: {}", usernameAndPass.getUsername());
if (userService.usernameExistsIgnoreCase(usernameAndPass.getUsername())) {
log.warn(
"Registration failed: username already exists: {}",
usernameAndPass.getUsername());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "User already exists"));
}
if (!userService.isUsernameValid(usernameAndPass.getUsername())) {
log.warn(
"Registration failed: invalid username format: {}",
usernameAndPass.getUsername());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Invalid username format"));
}
if (usernameAndPass.getPassword() == null || usernameAndPass.getPassword().isEmpty()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Password is required"));
}
Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
User user =
userService.saveUser(
usernameAndPass.getUsername(),
usernameAndPass.getPassword(),
team,
Role.USER.getRoleId(),
false);
log.info("User registered successfully: {}", usernameAndPass.getUsername());
return ResponseEntity.status(HttpStatus.CREATED)
.body(
Map.of(
"user",
buildUserResponse(user),
"message",
"Account created successfully. Please log in."));
} catch (IllegalArgumentException e) {
log.error("Registration validation error: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", e.getMessage()));
} catch (Exception e) {
log.error("Registration error for user: {}", usernameAndPass.getUsername(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Registration failed: " + e.getMessage()));
if (userService.usernameExistsIgnoreCase(requestModel.getUsername())) {
model.addAttribute("error", "Username already exists");
return "register";
}
}
/**
* Helper method to build user response object
*
* @param user User entity
* @return Map containing user information
*/
private Map<String, Object> buildUserResponse(User user) {
Map<String, Object> userMap = new HashMap<>();
userMap.put("id", user.getId());
userMap.put("email", user.getUsername()); // Use username as email
userMap.put("username", user.getUsername());
userMap.put("role", user.getRolesAsString());
userMap.put("enabled", user.isEnabled());
// Add metadata for OAuth compatibility
Map<String, Object> appMetadata = new HashMap<>();
appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider
userMap.put("app_metadata", appMetadata);
return userMap;
try {
Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
userService.saveUser(
requestModel.getUsername(),
requestModel.getPassword(),
team,
Role.USER.getRoleId(),
false);
} catch (IllegalArgumentException e) {
return "redirect:/login?messageType=invalidUsername";
}
return "redirect:/login?registered=true";
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/change-username")
public ResponseEntity<?> changeUsername(
public RedirectView changeUsername(
Principal principal,
@RequestParam(name = "currentPasswordChangeUsername") String currentPassword,
@RequestParam(name = "newUsername") String newUsername,
HttpServletRequest request,
HttpServletResponse response)
HttpServletResponse response,
RedirectAttributes redirectAttributes)
throws IOException, SQLException, UnsupportedProviderException {
if (!userService.isUsernameValid(newUsername)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "invalidUsername", "message", "Invalid username format"));
return new RedirectView("/account?messageType=invalidUsername", true);
}
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "notAuthenticated", "message", "User not authenticated"));
return new RedirectView("/account?messageType=notAuthenticated", true);
}
// The username MUST be unique when renaming
Optional<User> userOpt = userService.findByUsername(principal.getName());
if (userOpt == null || userOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "userNotFound", "message", "User not found"));
return new RedirectView("/account?messageType=userNotFound", true);
}
User user = userOpt.get();
if (user.getUsername().equals(newUsername)) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "usernameExists", "message", "Username already in use"));
return new RedirectView("/account?messageType=usernameExists", true);
}
if (!userService.isPasswordCorrect(user, currentPassword)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "incorrectPassword", "message", "Incorrect password"));
return new RedirectView("/account?messageType=incorrectPassword", true);
}
if (!user.getUsername().equals(newUsername) && userService.usernameExists(newUsername)) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "usernameExists", "message", "Username already exists"));
return new RedirectView("/account?messageType=usernameExists", true);
}
if (newUsername != null && newUsername.length() > 0) {
try {
userService.changeUsername(user, newUsername);
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
Map.of(
"error",
"invalidUsername",
"message",
"Invalid username format"));
return new RedirectView("/account?messageType=invalidUsername", true);
}
}
// Logout using Spring's utility
new SecurityContextLogoutHandler().logout(request, response, null);
return ResponseEntity.ok(
Map.of(
"message",
"credsUpdated",
"description",
"Username changed successfully. Please log in again."));
return new RedirectView(LOGIN_MESSAGETYPE_CREDSUPDATED, true);
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/change-password-on-login")
public ResponseEntity<?> changePasswordOnLogin(
public RedirectView changePasswordOnLogin(
Principal principal,
@RequestParam(name = "currentPassword") String currentPassword,
@RequestParam(name = "newPassword") String newPassword,
HttpServletRequest request,
HttpServletResponse response)
HttpServletResponse response,
RedirectAttributes redirectAttributes)
throws SQLException, UnsupportedProviderException {
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "notAuthenticated", "message", "User not authenticated"));
return new RedirectView("/change-creds?messageType=notAuthenticated", true);
}
Optional<User> userOpt = userService.findByUsernameIgnoreCase(principal.getName());
if (userOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "userNotFound", "message", "User not found"));
return new RedirectView("/change-creds?messageType=userNotFound", true);
}
User user = userOpt.get();
if (!userService.isPasswordCorrect(user, currentPassword)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "incorrectPassword", "message", "Incorrect password"));
return new RedirectView("/change-creds?messageType=incorrectPassword", true);
}
userService.changePassword(user, newPassword);
userService.changeFirstUse(user, false);
// Logout using Spring's utility
new SecurityContextLogoutHandler().logout(request, response, null);
return ResponseEntity.ok(
Map.of(
"message",
"credsUpdated",
"description",
"Password changed successfully. Please log in again."));
return new RedirectView(LOGIN_MESSAGETYPE_CREDSUPDATED, true);
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/change-password")
public ResponseEntity<?> changePassword(
public RedirectView changePassword(
Principal principal,
@RequestParam(name = "currentPassword") String currentPassword,
@RequestParam(name = "newPassword") String newPassword,
HttpServletRequest request,
HttpServletResponse response)
HttpServletResponse response,
RedirectAttributes redirectAttributes)
throws SQLException, UnsupportedProviderException {
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "notAuthenticated", "message", "User not authenticated"));
return new RedirectView("/account?messageType=notAuthenticated", true);
}
Optional<User> userOpt = userService.findByUsernameIgnoreCase(principal.getName());
if (userOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "userNotFound", "message", "User not found"));
return new RedirectView("/account?messageType=userNotFound", true);
}
User user = userOpt.get();
if (!userService.isPasswordCorrect(user, currentPassword)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "incorrectPassword", "message", "Incorrect password"));
return new RedirectView("/account?messageType=incorrectPassword", true);
}
userService.changePassword(user, newPassword);
// Logout using Spring's utility
new SecurityContextLogoutHandler().logout(request, response, null);
return ResponseEntity.ok(
Map.of(
"message",
"credsUpdated",
"description",
"Password changed successfully. Please log in again."));
return new RedirectView(LOGIN_MESSAGETYPE_CREDSUPDATED, true);
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@@ -279,23 +189,23 @@ public class UserController {
* </ul>
* Keys not listed above will be ignored.
* @param principal The currently authenticated user.
* @return A ResponseEntity with success or error information.
* @return A redirect string to the account page after updating the settings.
* @throws SQLException If a database error occurs.
* @throws UnsupportedProviderException If the operation is not supported for the user's
* provider.
*/
public ResponseEntity<?> updateUserSettings(
@RequestBody Map<String, String> updates, Principal principal)
public String updateUserSettings(@RequestBody Map<String, String> updates, Principal principal)
throws SQLException, UnsupportedProviderException {
log.debug("Processed updates: {}", updates);
// Assuming you have a method in userService to update the settings for a user
userService.updateUserSettings(principal.getName(), updates);
return ResponseEntity.ok(Map.of("message", "Settings updated successfully"));
// Redirect to a page of your choice after updating
return "redirect:/account";
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/saveUser")
public ResponseEntity<?> saveUser(
public RedirectView saveUser(
@RequestParam(name = "username", required = true) String username,
@RequestParam(name = "password", required = false) String password,
@RequestParam(name = "role") String role,
@@ -305,48 +215,33 @@ public class UserController {
boolean forceChange)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
if (!userService.isUsernameValid(username)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
Map.of(
"error",
"Invalid username format. Username must be 3-50 characters."));
return new RedirectView("/adminSettings?messageType=invalidUsername", true);
}
if (licenseSettingsService.wouldExceedLimit(1)) {
long availableSlots = licenseSettingsService.getAvailableUserSlots();
int maxAllowed = licenseSettingsService.calculateMaxAllowedUsers();
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
Map.of(
"error",
"Maximum number of users reached. Allowed: "
+ maxAllowed
+ ", Available slots: "
+ availableSlots));
if (applicationProperties.getPremium().isEnabled()
&& applicationProperties.getPremium().getMaxUsers()
<= userService.getTotalUsersCount()) {
return new RedirectView("/adminSettings?messageType=maxUsersReached", true);
}
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
if (userOpt.isPresent()) {
User user = userOpt.get();
if (user.getUsername().equalsIgnoreCase(username)) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "Username already exists."));
return new RedirectView("/adminSettings?messageType=usernameExists", true);
}
}
if (userService.usernameExistsIgnoreCase(username)) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "Username already exists."));
return new RedirectView("/adminSettings?messageType=usernameExists", true);
}
try {
// Validate the role
Role roleEnum = Role.fromString(role);
if (roleEnum == Role.INTERNAL_API_USER) {
// If the role is INTERNAL_API_USER, reject the request
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot assign INTERNAL_API_USER role."));
return new RedirectView("/adminSettings?messageType=invalidRole", true);
}
} catch (IllegalArgumentException e) {
// If the role ID is not valid, return error
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Invalid role specified."));
// If the role ID is not valid, redirect with an error message
return new RedirectView("/adminSettings?messageType=invalidRole", true);
}
// Use teamId if provided, otherwise use default team
@@ -362,143 +257,28 @@ public class UserController {
Team selectedTeam = teamRepository.findById(effectiveTeamId).orElse(null);
if (selectedTeam != null
&& TeamService.INTERNAL_TEAM_NAME.equals(selectedTeam.getName())) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot assign users to Internal team."));
return new RedirectView(
"/adminSettings?messageType=internalTeamNotAccessible", true);
}
}
if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) {
userService.saveUser(username, AuthenticationType.SSO, effectiveTeamId, role);
} else {
if (password == null || password.isBlank()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Password is required."));
}
if (password.length() < 6) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Password must be at least 6 characters."));
if (password.isBlank()) {
return new RedirectView("/adminSettings?messageType=invalidPassword", true);
}
userService.saveUser(username, password, effectiveTeamId, role, forceChange);
}
return ResponseEntity.ok(Map.of("message", "User created successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/inviteUsers")
public ResponseEntity<?> inviteUsers(
@RequestParam(name = "emails", required = true) String emails,
@RequestParam(name = "role", defaultValue = "ROLE_USER") String role,
@RequestParam(name = "teamId", required = false) Long teamId)
throws SQLException, UnsupportedProviderException {
// Check if email invites are enabled
if (!applicationProperties.getMail().isEnableInvites()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Email invites are not enabled"));
}
// Check if email service is available
if (!emailService.isPresent()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(
Map.of(
"error",
"Email service is not configured. Please configure SMTP settings."));
}
// Parse comma-separated email addresses
String[] emailArray = emails.split(",");
if (emailArray.length == 0) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "At least one email address is required"));
}
// Check license limits
if (licenseSettingsService.wouldExceedLimit(emailArray.length)) {
long availableSlots = licenseSettingsService.getAvailableUserSlots();
int maxAllowed = licenseSettingsService.calculateMaxAllowedUsers();
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
Map.of(
"error",
"Not enough user slots available. Allowed: "
+ maxAllowed
+ ", Available: "
+ availableSlots
+ ", Requested: "
+ emailArray.length));
}
// Validate role
try {
Role roleEnum = Role.fromString(role);
if (roleEnum == Role.INTERNAL_API_USER) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot assign INTERNAL_API_USER role"));
}
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Invalid role specified"));
}
// Determine team
Long effectiveTeamId = teamId;
if (effectiveTeamId == null) {
Team defaultTeam =
teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
if (defaultTeam != null) {
effectiveTeamId = defaultTeam.getId();
}
} else {
Team selectedTeam = teamRepository.findById(effectiveTeamId).orElse(null);
if (selectedTeam != null
&& TeamService.INTERNAL_TEAM_NAME.equals(selectedTeam.getName())) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot assign users to Internal team"));
}
}
int successCount = 0;
int failureCount = 0;
StringBuilder errors = new StringBuilder();
// Process each email
for (String email : emailArray) {
email = email.trim();
if (email.isEmpty()) {
continue;
}
InviteResult result = processEmailInvite(email, effectiveTeamId, role);
if (result.isSuccess()) {
successCount++;
} else {
failureCount++;
errors.append(result.getErrorMessage()).append("; ");
}
}
Map<String, Object> response = new HashMap<>();
response.put("successCount", successCount);
response.put("failureCount", failureCount);
if (failureCount > 0) {
response.put("errors", errors.toString());
}
if (successCount > 0) {
response.put("message", successCount + " user(s) invited successfully");
return ResponseEntity.ok(response);
} else {
response.put("error", "Failed to invite any users");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
return new RedirectView(
"/adminSettings", // Redirect to account page after adding the user
true);
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/changeRole")
@Transactional
public ResponseEntity<?> changeRole(
public RedirectView changeRole(
@RequestParam(name = "username") String username,
@RequestParam(name = "role") String role,
@RequestParam(name = "teamId", required = false) Long teamId,
@@ -506,32 +286,27 @@ public class UserController {
throws SQLException, UnsupportedProviderException {
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
if (!userOpt.isPresent()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found."));
return new RedirectView("/adminSettings?messageType=userNotFound", true);
}
if (!userService.usernameExistsIgnoreCase(username)) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found."));
return new RedirectView("/adminSettings?messageType=userNotFound", true);
}
// Get the currently authenticated username
String currentUsername = authentication.getName();
// Check if the provided username matches the current session's username
if (currentUsername.equalsIgnoreCase(username)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot change your own role."));
return new RedirectView("/adminSettings?messageType=downgradeCurrentUser", true);
}
try {
// Validate the role
Role roleEnum = Role.fromString(role);
if (roleEnum == Role.INTERNAL_API_USER) {
// If the role is INTERNAL_API_USER, reject the request
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot assign INTERNAL_API_USER role."));
return new RedirectView("/adminSettings?messageType=invalidRole", true);
}
} catch (IllegalArgumentException e) {
// If the role ID is not valid, return error
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Invalid role specified."));
// If the role ID is not valid, redirect with an error message
return new RedirectView("/adminSettings?messageType=invalidRole", true);
}
User user = userOpt.get();
@@ -541,15 +316,15 @@ public class UserController {
if (team != null) {
// Prevent assigning to Internal team
if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot assign users to Internal team."));
return new RedirectView(
"/adminSettings?messageType=internalTeamNotAccessible", true);
}
// Prevent moving users from Internal team
if (user.getTeam() != null
&& TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot move users from Internal team."));
return new RedirectView(
"/adminSettings?messageType=cannotMoveInternalUsers", true);
}
user.setTeam(team);
@@ -558,31 +333,30 @@ public class UserController {
}
userService.changeRole(user, role);
return ResponseEntity.ok(Map.of("message", "User role updated successfully"));
return new RedirectView(
"/adminSettings", // Redirect to account page after adding the user
true);
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/changeUserEnabled/{username}")
public ResponseEntity<?> changeUserEnabled(
public RedirectView changeUserEnabled(
@PathVariable("username") String username,
@RequestParam("enabled") boolean enabled,
Authentication authentication)
throws SQLException, UnsupportedProviderException {
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
if (userOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found."));
return new RedirectView("/adminSettings?messageType=userNotFound", true);
}
if (!userService.usernameExistsIgnoreCase(username)) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found."));
return new RedirectView("/adminSettings?messageType=userNotFound", true);
}
// Get the currently authenticated username
String currentUsername = authentication.getName();
// Check if the provided username matches the current session's username
if (currentUsername.equalsIgnoreCase(username)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot disable your own account."));
return new RedirectView("/adminSettings?messageType=disabledCurrentUser", true);
}
User user = userOpt.get();
userService.changeUserEnabled(user, enabled);
@@ -609,24 +383,23 @@ public class UserController {
}
}
}
return ResponseEntity.ok(
Map.of("message", "User " + (enabled ? "enabled" : "disabled") + " successfully"));
return new RedirectView(
"/adminSettings", // Redirect to account page after adding the user
true);
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/deleteUser/{username}")
public ResponseEntity<?> deleteUser(
public RedirectView deleteUser(
@PathVariable("username") String username, Authentication authentication) {
if (!userService.usernameExistsIgnoreCase(username)) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found."));
return new RedirectView("/adminSettings?messageType=deleteUsernameExists", true);
}
// Get the currently authenticated username
String currentUsername = authentication.getName();
// Check if the provided username matches the current session's username
if (currentUsername.equalsIgnoreCase(username)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot delete your own account."));
return new RedirectView("/adminSettings?messageType=deleteCurrentUser", true);
}
// Invalidate all sessions before deleting the user
List<SessionInformation> sessionsInformations =
@@ -636,7 +409,7 @@ public class UserController {
sessionRegistry.removeSessionInformation(sessionsInformation.getSessionId());
}
userService.deleteUser(username);
return ResponseEntity.ok(Map.of("message", "User deleted successfully"));
return new RedirectView("/adminSettings", true);
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@@ -667,73 +440,4 @@ public class UserController {
}
return ResponseEntity.ok(apiKey);
}
/**
* Helper method to process a single email invitation.
*
* @param email The email address to invite
* @param teamId The team ID to assign the user to
* @param role The role to assign to the user
* @return InviteResult containing success status and optional error message
*/
private InviteResult processEmailInvite(String email, Long teamId, String role) {
try {
// Validate email format (basic check)
if (!email.contains("@") || !email.contains(".")) {
return InviteResult.failure(email + ": Invalid email format");
}
// Check if user already exists
if (userService.usernameExistsIgnoreCase(email)) {
return InviteResult.failure(email + ": User already exists");
}
// Generate random password
String temporaryPassword = java.util.UUID.randomUUID().toString().substring(0, 12);
// Create user with forceChange=true
userService.saveUser(email, temporaryPassword, teamId, role, true);
// Send invite email
try {
emailService.get().sendInviteEmail(email, email, temporaryPassword);
log.info("Sent invite email to: {}", email);
return InviteResult.success();
} catch (Exception emailEx) {
log.error("Failed to send invite email to {}: {}", email, emailEx.getMessage());
return InviteResult.failure(email + ": User created but email failed to send");
}
} catch (Exception e) {
log.error("Failed to invite user {}: {}", email, e.getMessage());
return InviteResult.failure(email + ": " + e.getMessage());
}
}
/** Result object for individual email invite processing. */
private static class InviteResult {
private final boolean success;
private final String errorMessage;
private InviteResult(boolean success, String errorMessage) {
this.success = success;
this.errorMessage = errorMessage;
}
static InviteResult success() {
return new InviteResult(true, null);
}
static InviteResult failure(String errorMessage) {
return new InviteResult(false, errorMessage);
}
boolean isSuccess() {
return success;
}
String getErrorMessage() {
return errorMessage;
}
}
}
@@ -22,8 +22,6 @@ public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByApiKey(String apiKey);
Optional<User> findBySsoProviderAndSsoProviderId(String ssoProvider, String ssoProviderId);
List<User> findByAuthenticationTypeIgnoreCase(String authenticationType);
@Query("SELECT u FROM User u WHERE u.team IS NULL")
@@ -0,0 +1,77 @@
package stirling.software.proprietary.security.filter;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Optional;
import org.springframework.context.annotation.Lazy;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
@Slf4j
@Component
public class FirstLoginFilter extends OncePerRequestFilter {
@Lazy private final UserService userService;
public FirstLoginFilter(@Lazy UserService userService) {
this.userService = userService;
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String method = request.getMethod();
String requestURI = request.getRequestURI();
String contextPath = request.getContextPath();
// Check if the request is for static resources
boolean isStaticResource = RequestUriUtils.isStaticResource(contextPath, requestURI);
// If it's a static resource, just continue the filter chain and skip the logic below
if (isStaticResource) {
filterChain.doFilter(request, response);
return;
}
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
Optional<User> user = userService.findByUsernameIgnoreCase(authentication.getName());
if ("GET".equalsIgnoreCase(method)
&& user.isPresent()
&& user.get().isFirstLogin()
&& !(contextPath + "/change-creds").equals(requestURI)) {
response.sendRedirect(contextPath + "/change-creds");
return;
}
}
if (log.isDebugEnabled()) {
HttpSession session = request.getSession(true);
SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm:ss");
String creationTime = timeFormat.format(new Date(session.getCreationTime()));
log.debug(
"Request Info - New: {}, creationTimeSession {}, ID: {}, IP: {}, User-Agent: {}, Referer: {}, Request URL: {}",
session.isNew(),
creationTime,
session.getId(),
request.getRemoteAddr(),
request.getHeader("User-Agent"),
request.getHeader("Referer"),
request.getRequestURL().toString());
}
filterChain.doFilter(request, response);
}
}
@@ -1,9 +1,8 @@
package stirling.software.proprietary.security.filter;
import static stirling.software.common.util.RequestUriUtils.isStaticResource;
import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2;
import static stirling.software.proprietary.security.model.AuthenticationType.*;
import static stirling.software.proprietary.security.model.AuthenticationType.SAML2;
import static stirling.software.proprietary.security.model.AuthenticationType.WEB;
import java.io.IOException;
import java.sql.SQLException;
@@ -76,63 +75,29 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String jwtToken = jwtService.extractToken(request);
if (jwtToken == null) {
// Allow specific auth endpoints to pass through without JWT
// Any unauthenticated requests should redirect to /login
String requestURI = request.getRequestURI();
String contextPath = request.getContextPath();
// Public auth endpoints that don't require JWT
boolean isPublicAuthEndpoint =
requestURI.startsWith(contextPath + "/login")
|| requestURI.startsWith(contextPath + "/signup")
|| requestURI.startsWith(contextPath + "/invite")
|| requestURI.startsWith(contextPath + "/auth/")
|| requestURI.startsWith(contextPath + "/oauth2")
|| requestURI.startsWith(contextPath + "/api/v1/auth/login")
|| requestURI.startsWith(contextPath + "/api/v1/auth/register")
|| requestURI.startsWith(contextPath + "/api/v1/auth/refresh")
|| requestURI.startsWith(contextPath + "/api/v1/invite/validate")
|| requestURI.startsWith(contextPath + "/api/v1/invite/accept");
if (!isPublicAuthEndpoint) {
// For API requests, return 401 JSON
String acceptHeader = request.getHeader("Accept");
if (requestURI.startsWith(contextPath + "/api/")
|| (acceptHeader != null
&& acceptHeader.contains("application/json"))) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Authentication required\"}");
return;
}
// For HTML requests (SPA routes), let React Router handle it (serve
// index.html)
filterChain.doFilter(request, response);
if (!requestURI.startsWith(contextPath + "/login")) {
response.sendRedirect("/login");
return;
}
// For public auth endpoints without JWT, continue to the endpoint
filterChain.doFilter(request, response);
return;
}
try {
log.debug("Validating JWT token");
jwtService.validateToken(jwtToken);
log.debug("JWT token validated successfully");
} catch (AuthenticationFailureException e) {
log.warn("JWT validation failed: {}", e.getMessage());
jwtService.clearToken(response);
handleAuthenticationFailure(request, response, e);
return;
}
Map<String, Object> claims = jwtService.extractClaims(jwtToken);
String tokenUsername = claims.get("sub").toString();
log.debug("JWT token username: {}", tokenUsername);
try {
authenticate(request, claims);
log.debug("Authentication successful for user: {}", tokenUsername);
} catch (SQLException | UnsupportedProviderException e) {
log.error("Error processing user authentication for user: {}", tokenUsername, e);
handleAuthenticationFailure(
@@ -210,26 +175,21 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private void processUserAuthenticationType(Map<String, Object> claims, String username)
throws SQLException, UnsupportedProviderException {
AuthenticationType authenticationType =
AuthenticationType.valueOf(
claims.getOrDefault("authType", WEB).toString().toUpperCase());
AuthenticationType.valueOf(claims.getOrDefault("authType", WEB).toString());
log.debug("Processing {} login for {} user", authenticationType, username);
switch (authenticationType) {
case OAUTH2 -> {
ApplicationProperties.Security.OAUTH2 oauth2Properties =
securityProperties.getOauth2();
// Provider IDs should already be set during initial authentication
// Pass null here since this is validating an existing JWT token
userService.processSSOPostLogin(
username, null, null, oauth2Properties.getAutoCreateUser(), OAUTH2);
username, oauth2Properties.getAutoCreateUser(), OAUTH2);
}
case SAML2 -> {
ApplicationProperties.Security.SAML2 saml2Properties =
securityProperties.getSaml2();
// Provider IDs should already be set during initial authentication
// Pass null here since this is validating an existing JWT token
userService.processSSOPostLogin(
username, null, null, saml2Properties.getAutoCreateUser(), SAML2);
username, saml2Properties.getAutoCreateUser(), SAML2);
}
}
}
@@ -227,7 +227,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
contextPath + "/login",
contextPath + "/signup",
contextPath + "/register",
contextPath + "/invite",
contextPath + "/error",
contextPath + "/images/",
contextPath + "/public/",
@@ -237,12 +236,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
contextPath + "/pdfjs/",
contextPath + "/pdfjs-legacy/",
contextPath + "/api/v1/info/status",
contextPath + "/api/v1/auth/login",
contextPath + "/api/v1/auth/register",
contextPath + "/api/v1/auth/refresh",
contextPath + "/api/v1/auth/me",
contextPath + "/api/v1/invite/validate",
contextPath + "/api/v1/invite/accept",
contextPath + "/site.webmanifest"
};
@@ -1,62 +0,0 @@
package stirling.software.proprietary.security.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "invite_tokens")
@NoArgsConstructor
@Getter
@Setter
public class InviteToken implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "token", unique = true, nullable = false, length = 100)
private String token;
@Column(name = "email", nullable = true, length = 255)
private String email; // Optional - if not set, user can provide their own email
@Column(name = "role", nullable = false, length = 50)
private String role;
@Column(name = "team_id")
private Long teamId;
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
@Column(name = "used", nullable = false)
private boolean used = false;
@Column(name = "created_by", nullable = false, length = 255)
private String createdBy;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(name = "used_at")
private LocalDateTime usedAt;
public boolean isExpired() {
return LocalDateTime.now().isAfter(expiresAt);
}
public boolean isValid() {
return !used && !isExpired();
}
}
@@ -1,15 +1,12 @@
package stirling.software.proprietary.security.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.springframework.security.core.userdetails.UserDetails;
import com.fasterxml.jackson.annotation.JsonIgnore;
@@ -62,17 +59,12 @@ public class User implements UserDetails, Serializable {
@Column(name = "authenticationtype")
private String authenticationType;
@Column(name = "sso_provider_id")
private String ssoProviderId;
@Column(name = "sso_provider")
private String ssoProvider;
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
private Set<Authority> authorities = new HashSet<>();
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "team_id")
@JsonIgnore
private Team team;
@ElementCollection
@@ -80,17 +72,8 @@ public class User implements UserDetails, Serializable {
@Lob
@Column(name = "setting_value", columnDefinition = "text")
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
@JsonIgnore
private Map<String, String> settings = new HashMap<>(); // Key-value pairs of settings.
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
public String getRoleName() {
return Role.getRoleNameByRoleId(getRolesAsString());
}
@@ -10,7 +10,6 @@ import java.util.Map;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.SavedRequest;
@@ -73,6 +72,12 @@ public class CustomOAuth2AuthenticationSuccessHandler
throw new LockedException(
"Your account has been locked due to too many failed login attempts.");
}
if (jwtService.isJwtEnabled()) {
String jwt =
jwtService.generateToken(
authentication, Map.of("authType", AuthenticationType.OAUTH2));
jwtService.addToken(response, jwt);
}
if (userService.isUserDisabled(username)) {
getRedirectStrategy()
.sendRedirect(request, response, "/logout?userIsDisabled=true");
@@ -93,95 +98,14 @@ public class CustomOAuth2AuthenticationSuccessHandler
response.sendRedirect(contextPath + "/logout?oAuth2AdminBlockedUser=true");
return;
}
if (principal instanceof OAuth2User oAuth2User) {
// Extract SSO provider information from OAuth2User
String ssoProviderId = oAuth2User.getAttribute("sub"); // OIDC ID
// Extract provider from authentication - need to get it from the token/request
// For now, we'll extract it in a more generic way
String ssoProvider = extractProviderFromAuthentication(authentication);
if (principal instanceof OAuth2User) {
userService.processSSOPostLogin(
username,
ssoProviderId,
ssoProvider,
oauth2Properties.getAutoCreateUser(),
OAUTH2);
}
// Generate JWT if v2 is enabled
if (jwtService.isJwtEnabled()) {
String jwt =
jwtService.generateToken(
authentication, Map.of("authType", AuthenticationType.OAUTH2));
// Build context-aware redirect URL based on the original request
String redirectUrl = buildContextAwareRedirectUrl(request, contextPath, jwt);
response.sendRedirect(redirectUrl);
} else {
// v1: redirect directly to home
response.sendRedirect(contextPath + "/");
username, oauth2Properties.getAutoCreateUser(), OAUTH2);
}
response.sendRedirect(contextPath + "/");
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
response.sendRedirect(contextPath + "/logout?invalidUsername=true");
}
}
}
/**
* Extracts the OAuth2 provider registration ID from the authentication object.
*
* @param authentication The authentication object
* @return The provider registration ID (e.g., "google", "github"), or null if not available
*/
private String extractProviderFromAuthentication(Authentication authentication) {
if (authentication instanceof OAuth2AuthenticationToken oauth2Token) {
return oauth2Token.getAuthorizedClientRegistrationId();
}
return null;
}
/**
* Builds a context-aware redirect URL based on the request's origin
*
* @param request The HTTP request
* @param contextPath The application context path
* @param jwt The JWT token to include
* @return The appropriate redirect URL
*/
private String buildContextAwareRedirectUrl(
HttpServletRequest request, String contextPath, String jwt) {
// Try to get the origin from the Referer header first
String referer = request.getHeader("Referer");
if (referer != null && !referer.isEmpty()) {
try {
java.net.URL refererUrl = new java.net.URL(referer);
String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost();
if (refererUrl.getPort() != -1
&& refererUrl.getPort() != 80
&& refererUrl.getPort() != 443) {
origin += ":" + refererUrl.getPort();
}
return origin + "/auth/callback#access_token=" + jwt;
} catch (java.net.MalformedURLException e) {
// Fall back to other methods if referer is malformed
}
}
// Fall back to building from request host/port
String scheme = request.getScheme();
String serverName = request.getServerName();
int serverPort = request.getServerPort();
StringBuilder origin = new StringBuilder();
origin.append(scheme).append("://").append(serverName);
// Only add port if it's not the default port for the scheme
if ((!"http".equals(scheme) || serverPort != 80)
&& (!"https".equals(scheme) || serverPort != 443)) {
origin.append(":").append(serverPort);
}
return origin.toString() + "/auth/callback#access_token=" + jwt;
}
}
@@ -10,6 +10,7 @@ import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -40,7 +41,7 @@ import stirling.software.proprietary.security.service.UserService;
@Slf4j
@Configuration
@ConditionalOnProperty(prefix = "security", name = "oauth2.enabled", havingValue = "true")
@ConditionalOnBooleanProperty("security.oauth2.enabled")
public class OAuth2Configuration {
public static final String REDIRECT_URI_PATH = "{baseUrl}/login/oauth2/code/";
@@ -52,9 +53,6 @@ public class OAuth2Configuration {
ApplicationProperties applicationProperties, @Lazy UserService userService) {
this.userService = userService;
this.applicationProperties = applicationProperties;
log.info(
"OAuth2Configuration initialized - OAuth2 enabled: {}",
applicationProperties.getSecurity().getOauth2().getEnabled());
}
@Bean
@@ -77,7 +75,7 @@ public class OAuth2Configuration {
private Optional<ClientRegistration> keycloakClientRegistration() {
OAUTH2 oauth2 = applicationProperties.getSecurity().getOauth2();
if (isOAuth2Disabled(oauth2) || isClientInitialised(oauth2)) {
if (isOAuth2Enabled(oauth2) || isClientInitialised(oauth2)) {
return Optional.empty();
}
@@ -107,7 +105,7 @@ public class OAuth2Configuration {
private Optional<ClientRegistration> googleClientRegistration() {
OAUTH2 oAuth2 = applicationProperties.getSecurity().getOauth2();
if (isOAuth2Disabled(oAuth2) || isClientInitialised(oAuth2)) {
if (isOAuth2Enabled(oAuth2) || isClientInitialised(oAuth2)) {
return Optional.empty();
}
@@ -140,23 +138,12 @@ public class OAuth2Configuration {
private Optional<ClientRegistration> githubClientRegistration() {
OAUTH2 oAuth2 = applicationProperties.getSecurity().getOauth2();
if (isOAuth2Disabled(oAuth2)) {
log.debug("OAuth2 is disabled, skipping GitHub client registration");
if (isOAuth2Enabled(oAuth2)) {
return Optional.empty();
}
Client client = oAuth2.getClient();
if (client == null) {
log.debug("OAuth2 client configuration is null, skipping GitHub");
return Optional.empty();
}
GitHubProvider githubClient = client.getGithub();
if (githubClient == null) {
log.debug("GitHub client configuration is null");
return Optional.empty();
}
Provider github =
new GitHubProvider(
githubClient.getClientId(),
@@ -164,15 +151,7 @@ public class OAuth2Configuration {
githubClient.getScopes(),
githubClient.getUseAsUsername());
boolean isValid = validateProvider(github);
log.info(
"GitHub OAuth2 provider validation: {} (clientId: {}, clientSecret: {}, scopes: {})",
isValid,
githubClient.getClientId(),
githubClient.getClientSecret() != null ? "***" : "null",
githubClient.getScopes());
return isValid
return validateProvider(github)
? Optional.of(
ClientRegistration.withRegistrationId(github.getName())
.clientId(github.getClientId())
@@ -192,7 +171,7 @@ public class OAuth2Configuration {
private Optional<ClientRegistration> oidcClientRegistration() {
OAUTH2 oauth = applicationProperties.getSecurity().getOauth2();
if (isOAuth2Disabled(oauth) || isClientInitialised(oauth)) {
if (isOAuth2Enabled(oauth) || isClientInitialised(oauth)) {
return Optional.empty();
}
@@ -228,7 +207,7 @@ public class OAuth2Configuration {
: Optional.empty();
}
private boolean isOAuth2Disabled(OAUTH2 oAuth2) {
private boolean isOAuth2Enabled(OAUTH2 oAuth2) {
return oAuth2 == null || !oAuth2.getEnabled();
}
@@ -1,32 +0,0 @@
package stirling.software.proprietary.security.repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import stirling.software.proprietary.security.model.InviteToken;
@Repository
public interface InviteTokenRepository extends JpaRepository<InviteToken, Long> {
Optional<InviteToken> findByToken(String token);
Optional<InviteToken> findByEmail(String email);
List<InviteToken> findByUsedFalseAndExpiresAtAfter(LocalDateTime now);
List<InviteToken> findByCreatedBy(String createdBy);
@Modifying
@Query("DELETE FROM InviteToken it WHERE it.expiresAt < :now")
void deleteExpiredTokens(@Param("now") LocalDateTime now);
@Query("SELECT COUNT(it) FROM InviteToken it WHERE it.used = false AND it.expiresAt > :now")
long countActiveInvites(@Param("now") LocalDateTime now);
}
@@ -1,21 +0,0 @@
package stirling.software.proprietary.security.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import stirling.software.proprietary.model.UserLicenseSettings;
@Repository
public interface UserLicenseSettingsRepository extends JpaRepository<UserLicenseSettings, Long> {
/**
* Finds the singleton UserLicenseSettings record.
*
* @return Optional containing the settings if they exist
*/
default Optional<UserLicenseSettings> findSettings() {
return findById(UserLicenseSettings.SINGLETON_ID);
}
}
@@ -116,41 +116,13 @@ public class CustomSaml2AuthenticationSuccessHandler
contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser");
return;
}
// Extract SSO provider information from SAML2 assertion
String ssoProviderId = saml2Principal.nameId();
String ssoProvider = "saml2"; // fixme
log.debug(
"Processing SSO post-login for user: {} (Provider: {}, ProviderId: {})",
username,
ssoProvider,
ssoProviderId);
log.debug("Processing SSO post-login for user: {}", username);
userService.processSSOPostLogin(
username,
ssoProviderId,
ssoProvider,
saml2Properties.getAutoCreateUser(),
SAML2);
username, saml2Properties.getAutoCreateUser(), SAML2);
log.debug("Successfully processed authentication for user: {}", username);
// Generate JWT if v2 is enabled
if (jwtService.isJwtEnabled()) {
String jwt =
jwtService.generateToken(
authentication,
Map.of("authType", AuthenticationType.SAML2));
// Build context-aware redirect URL based on the original request
String redirectUrl =
buildContextAwareRedirectUrl(request, contextPath, jwt);
response.sendRedirect(redirectUrl);
} else {
// v1: redirect directly to home
response.sendRedirect(contextPath + "/");
}
generateJwt(response, authentication);
response.sendRedirect(contextPath + "/");
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
log.debug(
"Invalid username detected for user: {}, redirecting to logout",
@@ -164,48 +136,12 @@ public class CustomSaml2AuthenticationSuccessHandler
}
}
/**
* Builds a context-aware redirect URL based on the request's origin
*
* @param request The HTTP request
* @param contextPath The application context path
* @param jwt The JWT token to include
* @return The appropriate redirect URL
*/
private String buildContextAwareRedirectUrl(
HttpServletRequest request, String contextPath, String jwt) {
// Try to get the origin from the Referer header first
String referer = request.getHeader("Referer");
if (referer != null && !referer.isEmpty()) {
try {
java.net.URL refererUrl = new java.net.URL(referer);
String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost();
if (refererUrl.getPort() != -1
&& refererUrl.getPort() != 80
&& refererUrl.getPort() != 443) {
origin += ":" + refererUrl.getPort();
}
return origin + "/auth/callback#access_token=" + jwt;
} catch (java.net.MalformedURLException e) {
log.debug(
"Malformed referer URL: {}, falling back to request-based origin", referer);
}
private void generateJwt(HttpServletResponse response, Authentication authentication) {
if (jwtService.isJwtEnabled()) {
String jwt =
jwtService.generateToken(
authentication, Map.of("authType", AuthenticationType.SAML2));
jwtService.addToken(response, jwt);
}
// Fall back to building from request host/port
String scheme = request.getScheme();
String serverName = request.getServerName();
int serverPort = request.getServerPort();
StringBuilder origin = new StringBuilder();
origin.append(scheme).append("://").append(serverName);
// Only add port if it's not the default port for the scheme
if ((!"http".equals(scheme) || serverPort != 80)
&& (!"https".equals(scheme) || serverPort != 443)) {
origin.append(":").append(serverPort);
}
return origin + "/auth/callback#access_token=" + jwt;
}
}
@@ -14,6 +14,7 @@ import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
import stirling.software.common.model.enumeration.UsernameAttribute;
import stirling.software.proprietary.security.model.User;
@@ -26,13 +27,13 @@ public class CustomOAuth2UserService implements OAuth2UserService<OidcUserReques
private final LoginAttemptService loginAttemptService;
private final ApplicationProperties.Security.OAUTH2 oauth2Properties;
private final ApplicationProperties.Security securityProperties;
public CustomOAuth2UserService(
ApplicationProperties.Security.OAUTH2 oauth2Properties,
ApplicationProperties.Security securityProperties,
UserService userService,
LoginAttemptService loginAttemptService) {
this.oauth2Properties = oauth2Properties;
this.securityProperties = securityProperties;
this.userService = userService;
this.loginAttemptService = loginAttemptService;
}
@@ -41,22 +42,14 @@ public class CustomOAuth2UserService implements OAuth2UserService<OidcUserReques
public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
try {
OidcUser user = delegate.loadUser(userRequest);
String usernameAttributeKey =
UsernameAttribute.valueOf(oauth2Properties.getUseAsUsername().toUpperCase())
.getName();
OAUTH2 oauth2 = securityProperties.getOauth2();
UsernameAttribute usernameAttribute =
UsernameAttribute.valueOf(oauth2.getUseAsUsername().toUpperCase());
String usernameAttributeKey = usernameAttribute.getName();
// Extract SSO provider information
String ssoProviderId = user.getSubject(); // Standard OIDC 'sub' claim
String ssoProvider = userRequest.getClientRegistration().getRegistrationId();
String username = user.getAttribute(usernameAttributeKey);
log.debug(
"OAuth2 login - Provider: {}, ProviderId: {}, Username: {}",
ssoProvider,
ssoProviderId,
username);
Optional<User> internalUser = userService.findByUsernameIgnoreCase(username);
// todo: save user by OIDC ID instead of username
Optional<User> internalUser =
userService.findByUsernameIgnoreCase(user.getAttribute(usernameAttributeKey));
if (internalUser.isPresent()) {
String internalUsername = internalUser.get().getUsername();
@@ -73,144 +73,4 @@ public class EmailService {
// Sends the email via the configured mail sender
mailSender.send(message);
}
/**
* Sends a plain text/HTML email without attachments asynchronously.
*
* @param to The recipient email address
* @param subject The email subject
* @param body The email body (can contain HTML)
* @param isHtml Whether the body contains HTML content
* @throws MessagingException If there is an issue with creating or sending the email.
*/
@Async
public void sendPlainEmail(String to, String subject, String body, boolean isHtml)
throws MessagingException {
// Validate recipient email address
if (to == null || to.trim().isEmpty()) {
throw new MessagingException("Invalid recipient email address");
}
ApplicationProperties.Mail mailProperties = applicationProperties.getMail();
// Creates a MimeMessage to represent the email
MimeMessage message = mailSender.createMimeMessage();
// Helper class to set up the message content
MimeMessageHelper helper = new MimeMessageHelper(message, false);
// Sets the recipient, subject, body, and sender email
helper.addTo(to);
helper.setSubject(subject);
helper.setText(body, isHtml);
helper.setFrom(mailProperties.getFrom());
// Sends the email via the configured mail sender
mailSender.send(message);
}
/**
* Sends an invitation email to a new user with their credentials.
*
* @param to The recipient email address
* @param username The username for the new account
* @param temporaryPassword The temporary password
* @throws MessagingException If there is an issue with creating or sending the email.
*/
@Async
public void sendInviteEmail(String to, String username, String temporaryPassword)
throws MessagingException {
String subject = "Welcome to Stirling PDF";
String body =
"""
<html><body style="margin: 0; padding: 0;">
<div style="font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;">
<div style="max-width: 600px; margin: auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0;">
<!-- Logo -->
<div style="text-align: center; padding: 20px; background-color: #222;">
<img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling-transparent.svg" alt="Stirling PDF" style="max-height: 60px;">
</div>
<!-- Content -->
<div style="padding: 30px; color: #333;">
<h2 style="color: #222; margin-top: 0;">Welcome to Stirling PDF!</h2>
<p>Hi there,</p>
<p>You have been invited to join the workspace. Below are your login credentials:</p>
<!-- Credentials Box -->
<div style="background-color: #f8f9fa; border-left: 4px solid #007bff; padding: 15px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0 0 10px 0;"><strong>Username:</strong> %s</p>
<p style="margin: 0;"><strong>Temporary Password:</strong> %s</p>
</div>
<div style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; color: #856404;"><strong>⚠️ Important:</strong> You will be required to change your password upon first login for security reasons.</p>
</div>
<p>Please keep these credentials secure and do not share them with anyone.</p>
<p style="margin-bottom: 0;">— The Stirling PDF Team</p>
</div>
<!-- Footer -->
<div style="text-align: center; padding: 15px; font-size: 12px; color: #777; background-color: #f0f0f0;">
&copy; 2025 Stirling PDF. All rights reserved.
</div>
</div>
</div>
</body></html>
"""
.formatted(username, temporaryPassword);
sendPlainEmail(to, subject, body, true);
}
/**
* Sends an invitation link email to a new user.
*
* @param to The recipient email address
* @param inviteUrl The full URL for accepting the invite
* @param expiresAt The expiration timestamp
* @throws MessagingException If there is an issue with creating or sending the email.
*/
@Async
public void sendInviteLinkEmail(String to, String inviteUrl, String expiresAt)
throws MessagingException {
String subject = "You've been invited to Stirling PDF";
String body =
"""
<html><body style="margin: 0; padding: 0;">
<div style="font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;">
<div style="max-width: 600px; margin: auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0;">
<!-- Logo -->
<div style="text-align: center; padding: 20px; background-color: #222;">
<img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling-transparent.svg" alt="Stirling PDF" style="max-height: 60px;">
</div>
<!-- Content -->
<div style="padding: 30px; color: #333;">
<h2 style="color: #222; margin-top: 0;">Welcome to Stirling PDF!</h2>
<p>Hi there,</p>
<p>You have been invited to join the Stirling PDF workspace. Click the button below to set up your account:</p>
<!-- CTA Button -->
<div style="text-align: center; margin: 30px 0;">
<a href="%s" style="display: inline-block; background-color: #007bff; color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 5px; font-weight: bold;">Accept Invitation</a>
</div>
<p style="font-size: 14px; color: #666;">Or copy and paste this link in your browser:</p>
<div style="background-color: #f8f9fa; padding: 12px; margin: 15px 0; border-radius: 4px; word-break: break-all; font-size: 13px; color: #555;">
%s
</div>
<div style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; color: #856404; font-size: 14px;"><strong>⚠️ Important:</strong> This invitation link will expire on %s. Please complete your registration before then.</p>
</div>
<p>If you didn't expect this invitation, you can safely ignore this email.</p>
<p style="margin-bottom: 0;">— The Stirling PDF Team</p>
</div>
<!-- Footer -->
<div style="text-align: center; padding: 15px; font-size: 12px; color: #777; background-color: #f0f0f0;">
&copy; 2025 Stirling PDF. All rights reserved.
</div>
</div>
</div>
</body></html>
"""
.formatted(inviteUrl, inviteUrl, expiresAt);
sendPlainEmail(to, subject, body, true);
}
}
@@ -14,11 +14,14 @@ import java.util.function.Function;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseCookie;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import io.github.pixee.security.Newlines;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
@@ -26,7 +29,9 @@ import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.SignatureException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
@@ -38,9 +43,13 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
@Service
public class JwtService implements JwtServiceInterface {
private static final String ISSUER = "https://stirling.com";
private static final String JWT_COOKIE_NAME = "stirling_jwt";
private static final String ISSUER = "Stirling PDF";
private static final long EXPIRATION = 3600000;
@Value("${stirling.security.jwt.secureCookie:true}")
private boolean secureCookie;
private final KeyPersistenceServiceInterface keyPersistenceService;
private final boolean v2Enabled;
@@ -50,7 +59,6 @@ public class JwtService implements JwtServiceInterface {
KeyPersistenceServiceInterface keyPersistenceService) {
this.v2Enabled = v2Enabled;
this.keyPersistenceService = keyPersistenceService;
log.info("JwtService initialized");
}
@Override
@@ -252,18 +260,47 @@ public class JwtService implements JwtServiceInterface {
@Override
public String extractToken(HttpServletRequest request) {
// Extract from Authorization header Bearer token
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7); // Remove "Bearer " prefix
log.debug("JWT token extracted from Authorization header");
return token;
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (JWT_COOKIE_NAME.equals(cookie.getName())) {
return cookie.getValue();
}
}
}
log.debug("No JWT token found in Authorization header");
return null;
}
@Override
public void addToken(HttpServletResponse response, String token) {
ResponseCookie cookie =
ResponseCookie.from(JWT_COOKIE_NAME, Newlines.stripAll(token))
.httpOnly(true)
.secure(secureCookie)
.sameSite("Strict")
.maxAge(EXPIRATION / 1000)
.path("/")
.build();
response.addHeader("Set-Cookie", cookie.toString());
}
@Override
public void clearToken(HttpServletResponse response) {
ResponseCookie cookie =
ResponseCookie.from(JWT_COOKIE_NAME, "")
.httpOnly(true)
.secure(secureCookie)
.sameSite("None")
.maxAge(0)
.path("/")
.build();
response.addHeader("Set-Cookie", cookie.toString());
}
@Override
public boolean isJwtEnabled() {
return v2Enabled;
@@ -5,6 +5,7 @@ import java.util.Map;
import org.springframework.security.core.Authentication;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
public interface JwtServiceInterface {
@@ -65,6 +66,21 @@ public interface JwtServiceInterface {
*/
String extractToken(HttpServletRequest request);
/**
* Add JWT token to HTTP response (header and cookie)
*
* @param response HTTP servlet response
* @param token JWT token to add
*/
void addToken(HttpServletResponse response, String token);
/**
* Clear JWT token from HTTP response (remove cookie)
*
* @param response HTTP servlet response
*/
void clearToken(HttpServletResponse response);
/**
* Check if JWT authentication is enabled
*
@@ -60,46 +60,19 @@ public class UserService implements UserServiceInterface {
private final ApplicationProperties.Security.OAUTH2 oAuth2;
// Handle OAUTH2 login and user auto creation.
public void processSSOPostLogin(
String username,
String ssoProviderId,
String ssoProvider,
boolean autoCreateUser,
AuthenticationType type)
String username, boolean autoCreateUser, AuthenticationType type)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
if (!isUsernameValid(username)) {
return;
}
// Find user by SSO provider ID first
Optional<User> existingUser;
if (ssoProviderId != null && ssoProvider != null) {
existingUser =
userRepository.findBySsoProviderAndSsoProviderId(ssoProvider, ssoProviderId);
if (existingUser.isPresent()) {
log.debug("User found by SSO provider ID: {}", ssoProviderId);
return;
}
}
existingUser = findByUsernameIgnoreCase(username);
Optional<User> existingUser = findByUsernameIgnoreCase(username);
if (existingUser.isPresent()) {
User user = existingUser.get();
// Migrate existing user to use provider ID if not already set
if (user.getSsoProviderId() == null && ssoProviderId != null && ssoProvider != null) {
log.info("Migrating user {} to use SSO provider ID: {}", username, ssoProviderId);
user.setSsoProviderId(ssoProviderId);
user.setSsoProvider(ssoProvider);
userRepository.save(user);
databaseService.exportDatabase();
}
return;
}
if (autoCreateUser) {
saveUser(username, ssoProviderId, ssoProvider, type);
saveUser(username, type);
}
}
@@ -181,21 +154,6 @@ public class UserService implements UserServiceInterface {
saveUser(username, authenticationType, (Long) null, Role.USER.getRoleId());
}
public void saveUser(
String username,
String ssoProviderId,
String ssoProvider,
AuthenticationType authenticationType)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
saveUser(
username,
ssoProviderId,
ssoProvider,
authenticationType,
(Long) null,
Role.USER.getRoleId());
}
private User saveUser(Optional<User> user, String apiKey) {
if (user.isPresent()) {
user.get().setApiKey(apiKey);
@@ -210,30 +168,6 @@ public class UserService implements UserServiceInterface {
return saveUserCore(
username, // username
null, // password
null, // ssoProviderId
null, // ssoProvider
authenticationType, // authenticationType
teamId, // teamId
null, // team
role, // role
false, // firstLogin
true // enabled
);
}
public User saveUser(
String username,
String ssoProviderId,
String ssoProvider,
AuthenticationType authenticationType,
Long teamId,
String role)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
return saveUserCore(
username, // username
null, // password
ssoProviderId, // ssoProviderId
ssoProvider, // ssoProvider
authenticationType, // authenticationType
teamId, // teamId
null, // team
@@ -249,8 +183,6 @@ public class UserService implements UserServiceInterface {
return saveUserCore(
username, // username
null, // password
null, // ssoProviderId
null, // ssoProvider
authenticationType, // authenticationType
null, // teamId
team, // team
@@ -265,8 +197,6 @@ public class UserService implements UserServiceInterface {
return saveUserCore(
username, // username
password, // password
null, // ssoProviderId
null, // ssoProvider
AuthenticationType.WEB, // authenticationType
teamId, // teamId
null, // team
@@ -282,8 +212,6 @@ public class UserService implements UserServiceInterface {
return saveUserCore(
username, // username
password, // password
null, // ssoProviderId
null, // ssoProvider
AuthenticationType.WEB, // authenticationType
null, // teamId
team, // team
@@ -299,8 +227,6 @@ public class UserService implements UserServiceInterface {
return saveUserCore(
username, // username
password, // password
null, // ssoProviderId
null, // ssoProvider
AuthenticationType.WEB, // authenticationType
teamId, // teamId
null, // team
@@ -321,8 +247,6 @@ public class UserService implements UserServiceInterface {
saveUserCore(
username, // username
password, // password
null, // ssoProviderId
null, // ssoProvider
AuthenticationType.WEB, // authenticationType
teamId, // teamId
null, // team
@@ -487,8 +411,6 @@ public class UserService implements UserServiceInterface {
*
* @param username Username for the new user
* @param password Password for the user (may be null for SSO/OAuth users)
* @param ssoProviderId Unique identifier from SSO provider (may be null for non-SSO users)
* @param ssoProvider Name of the SSO provider (may be null for non-SSO users)
* @param authenticationType Type of authentication (WEB, SSO, etc.)
* @param teamId ID of the team to assign (may be null to use default)
* @param team Team object to assign (takes precedence over teamId if both provided)
@@ -503,8 +425,6 @@ public class UserService implements UserServiceInterface {
private User saveUserCore(
String username,
String password,
String ssoProviderId,
String ssoProvider,
AuthenticationType authenticationType,
Long teamId,
Team team,
@@ -525,12 +445,6 @@ public class UserService implements UserServiceInterface {
user.setPassword(passwordEncoder.encode(password));
}
// Set SSO provider details if provided
if (ssoProviderId != null && ssoProvider != null) {
user.setSsoProviderId(ssoProviderId);
user.setSsoProvider(ssoProvider);
}
// Set authentication type
user.setAuthenticationType(authenticationType);
@@ -642,21 +556,6 @@ public class UserService implements UserServiceInterface {
return null;
}
public boolean isCurrentUserAdmin() {
try {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null
&& authentication.isAuthenticated()
&& !"anonymousUser".equals(authentication.getPrincipal())) {
return authentication.getAuthorities().stream()
.anyMatch(auth -> Role.ADMIN.getRoleId().equals(auth.getAuthority()));
}
} catch (Exception e) {
log.debug("Error checking admin status", e);
}
return false;
}
@Transactional
public void syncCustomApiUser(String customApiKey) {
if (customApiKey == null || customApiKey.trim().isBlank()) {
@@ -30,8 +30,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
@Service
@Slf4j
@@ -53,12 +51,6 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
@Value("${system.serverCertificate.regenerateOnStartup:false}")
private boolean regenerateOnStartup;
private final LicenseKeyChecker licenseKeyChecker;
public ServerCertificateService(LicenseKeyChecker licenseKeyChecker) {
this.licenseKeyChecker = licenseKeyChecker;
}
static {
Security.addProvider(new BouncyCastleProvider());
}
@@ -67,13 +59,8 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
return Paths.get(InstallationPathConfig.getConfigPath(), KEYSTORE_FILENAME);
}
private boolean hasProOrEnterpriseAccess() {
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
return license == License.PRO || license == License.ENTERPRISE;
}
public boolean isEnabled() {
return enabled && hasProOrEnterpriseAccess();
return enabled;
}
public boolean hasServerCertificate() {
@@ -86,11 +73,6 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
return;
}
if (!hasProOrEnterpriseAccess()) {
log.info("Server certificate feature requires Pro or Enterprise license");
return;
}
Path keystorePath = getKeystorePath();
if (!Files.exists(keystorePath) || regenerateOnStartup) {
@@ -106,11 +88,6 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
}
public KeyStore getServerKeyStore() throws Exception {
if (!hasProOrEnterpriseAccess()) {
throw new IllegalStateException(
"Server certificate feature requires Pro or Enterprise license");
}
if (!enabled || !hasServerCertificate()) {
throw new IllegalStateException("Server certificate is not available");
}
@@ -137,11 +114,6 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
}
public void uploadServerCertificate(InputStream p12Stream, String password) throws Exception {
if (!hasProOrEnterpriseAccess()) {
throw new IllegalStateException(
"Server certificate feature requires Pro or Enterprise license");
}
// Validate the uploaded certificate
KeyStore uploadedKeyStore = KeyStore.getInstance("PKCS12");
uploadedKeyStore.load(p12Stream, password.toCharArray());
@@ -202,11 +174,6 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
}
private void generateServerCertificate() throws Exception {
if (!hasProOrEnterpriseAccess()) {
throw new IllegalStateException(
"Server certificate feature requires Pro or Enterprise license");
}
// Generate key pair
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
keyPairGenerator.initialize(2048, new SecureRandom());
@@ -1,411 +0,0 @@
package stirling.software.proprietary.service;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Optional;
import java.util.UUID;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.model.UserLicenseSettings;
import stirling.software.proprietary.security.repository.UserLicenseSettingsRepository;
import stirling.software.proprietary.security.service.UserService;
/**
* Service for managing user license settings and grandfathering logic.
*
* <p>User limit calculation:
*
* <ul>
* <li>Default limit: 5 users
* <li>Grandfathered limit: max(5, existing user count at initialization)
* <li>With pro license: grandfathered limit + license maxUsers
* <li>Without pro license: grandfathered limit
* </ul>
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class UserLicenseSettingsService {
private static final int DEFAULT_USER_LIMIT = 5;
private static final String SIGNATURE_SEPARATOR = ":";
private static final String DEFAULT_INTEGRITY_SECRET = "stirling-pdf-user-license-guard";
private final UserLicenseSettingsRepository settingsRepository;
private final UserService userService;
private final ApplicationProperties applicationProperties;
/**
* Gets the current user license settings, creating them if they don't exist.
*
* @return The current settings
*/
@Transactional
public UserLicenseSettings getOrCreateSettings() {
return settingsRepository
.findSettings()
.orElseGet(
() -> {
log.info("Initializing user license settings");
UserLicenseSettings settings = new UserLicenseSettings();
settings.setId(UserLicenseSettings.SINGLETON_ID);
settings.setGrandfatheredUserCount(0);
settings.setLicenseMaxUsers(0);
settings.setGrandfatheringLocked(false);
settings.setIntegritySalt(UUID.randomUUID().toString());
settings.setGrandfatheredUserSignature("");
return settingsRepository.save(settings);
});
}
/**
* Initializes the grandfathered user count if not already set. This should be called on
* application startup.
*
* <p>IMPORTANT: Once grandfathering is locked, this value can NEVER be changed. This prevents
* manipulation by deleting the settings table.
*
* <p>Logic:
*
* <ul>
* <li>If grandfatheringLocked is true: Skip initialization (already set permanently)
* <li>If users exist in database: Set to max(5, current user count) - this is an existing
* installation
* <li>If no users exist: Set to 5 (default) - this is a fresh installation
* <li>Lock grandfathering immediately after setting
* </ul>
*/
@Transactional
public void initializeGrandfatheredCount() {
UserLicenseSettings settings = getOrCreateSettings();
boolean changed = ensureIntegritySalt(settings);
// CRITICAL: Never change grandfathering once it's locked
if (settings.isGrandfatheringLocked()) {
if (settings.getGrandfatheredUserSignature() == null
|| settings.getGrandfatheredUserSignature().isBlank()) {
settings.setGrandfatheredUserSignature(
generateSignature(settings.getGrandfatheredUserCount(), settings));
changed = true;
}
if (changed) {
settingsRepository.save(settings);
}
log.debug(
"Grandfathering is locked. Current grandfathered count: {}",
settings.getGrandfatheredUserCount());
return;
}
// Determine if this is an existing installation or fresh install
long currentUserCount = userService.getTotalUsersCount();
boolean isExistingInstallation = currentUserCount > 0;
int grandfatheredCount;
if (isExistingInstallation) {
// Existing installation (v2.0+ or has users) - grandfather current user count
grandfatheredCount = Math.max(DEFAULT_USER_LIMIT, (int) currentUserCount);
log.info(
"Existing installation detected. Grandfathering {} users (current: {}, minimum:"
+ " {})",
grandfatheredCount,
currentUserCount,
DEFAULT_USER_LIMIT);
} else {
// Fresh installation - set to default
grandfatheredCount = DEFAULT_USER_LIMIT;
log.info(
"Fresh installation detected. Setting default grandfathered limit: {}",
grandfatheredCount);
}
// Set and LOCK the grandfathering permanently
settings.setGrandfatheredUserCount(grandfatheredCount);
settings.setGrandfatheringLocked(true);
settings.setGrandfatheredUserSignature(generateSignature(grandfatheredCount, settings));
settingsRepository.save(settings);
log.warn(
"GRANDFATHERING LOCKED: {} users. This value can never be changed.",
grandfatheredCount);
}
/**
* Updates the license max users from the application properties. This should be called when the
* license is validated.
*/
@Transactional
public void updateLicenseMaxUsers() {
UserLicenseSettings settings = getOrCreateSettings();
int licenseMaxUsers = 0;
if (applicationProperties.getPremium().isEnabled()) {
licenseMaxUsers = applicationProperties.getPremium().getMaxUsers();
}
if (settings.getLicenseMaxUsers() != licenseMaxUsers) {
settings.setLicenseMaxUsers(licenseMaxUsers);
settingsRepository.save(settings);
log.info("Updated license max users to: {}", licenseMaxUsers);
}
}
/**
* Validates and enforces the integrity of license settings. This ensures that even if someone
* manually modifies the database, the grandfathering rules are still enforced.
*/
@Transactional
public void validateSettingsIntegrity() {
UserLicenseSettings settings = getOrCreateSettings();
boolean changed = ensureIntegritySalt(settings);
Optional<Integer> signedCountOpt = extractSignedCount(settings);
boolean signatureValid =
signedCountOpt.isPresent()
&& signatureMatches(
signedCountOpt.get(),
settings.getGrandfatheredUserSignature(),
settings);
int targetCount = settings.getGrandfatheredUserCount();
String targetSignature = settings.getGrandfatheredUserSignature();
if (!signatureValid) {
int restoredCount =
signedCountOpt.orElseGet(
() ->
Math.max(
DEFAULT_USER_LIMIT,
(int) userService.getTotalUsersCount()));
log.error(
"Grandfathered user signature invalid or missing. Restoring locked count to {}.",
restoredCount);
targetCount = restoredCount;
targetSignature = generateSignature(targetCount, settings);
changed = true;
} else {
int signedCount = signedCountOpt.get();
if (targetCount != signedCount) {
log.error(
"Grandfathered user count ({}) was modified without signature update. Restoring to {}.",
targetCount,
signedCount);
targetCount = signedCount;
targetSignature = generateSignature(targetCount, settings);
changed = true;
}
}
if (targetCount < DEFAULT_USER_LIMIT) {
if (targetCount != DEFAULT_USER_LIMIT) {
log.warn(
"Grandfathered count ({}) is below minimum ({}). Enforcing minimum.",
targetCount,
DEFAULT_USER_LIMIT);
}
targetCount = DEFAULT_USER_LIMIT;
targetSignature = generateSignature(targetCount, settings);
changed = true;
}
if (targetSignature == null || targetSignature.isBlank()) {
targetSignature = generateSignature(targetCount, settings);
changed = true;
}
if (changed
|| settings.getGrandfatheredUserCount() != targetCount
|| (targetSignature != null
&& !targetSignature.equals(settings.getGrandfatheredUserSignature()))) {
settings.setGrandfatheredUserCount(targetCount);
settings.setGrandfatheredUserSignature(targetSignature);
settingsRepository.save(settings);
}
}
/**
* Calculates the maximum allowed users based on grandfathering rules.
*
* <p>Logic:
*
* <ul>
* <li>Grandfathered limit = max(5, existing user count at initialization)
* <li>If premium enabled: total limit = grandfathered limit + license maxUsers
* <li>If premium disabled: total limit = grandfathered limit
* </ul>
*
* @return Maximum number of users allowed
*/
public int calculateMaxAllowedUsers() {
validateSettingsIntegrity();
UserLicenseSettings settings = getOrCreateSettings();
int grandfatheredLimit = settings.getGrandfatheredUserCount();
if (grandfatheredLimit == 0) {
// Fallback if not initialized yet - should not happen with validation
log.warn("Grandfathered limit is 0, using default: {}", DEFAULT_USER_LIMIT);
grandfatheredLimit = DEFAULT_USER_LIMIT;
}
int totalLimit = grandfatheredLimit;
if (applicationProperties.getPremium().isEnabled()) {
totalLimit = grandfatheredLimit + settings.getLicenseMaxUsers();
}
log.debug(
"Calculated max allowed users: {} (grandfathered: {}, license: {}, premium enabled: {})",
totalLimit,
grandfatheredLimit,
settings.getLicenseMaxUsers(),
applicationProperties.getPremium().isEnabled());
return totalLimit;
}
/**
* Checks if adding new users would exceed the limit.
*
* @param newUsersCount Number of new users to add
* @return true if the addition would exceed the limit
*/
public boolean wouldExceedLimit(int newUsersCount) {
long currentUserCount = userService.getTotalUsersCount();
int maxAllowed = calculateMaxAllowedUsers();
return (currentUserCount + newUsersCount) > maxAllowed;
}
/**
* Gets the number of available user slots.
*
* @return Number of users that can still be added
*/
public long getAvailableUserSlots() {
long currentUserCount = userService.getTotalUsersCount();
int maxAllowed = calculateMaxAllowedUsers();
return Math.max(0, maxAllowed - currentUserCount);
}
/**
* Gets the grandfathered user count for display purposes. Returns only the excess users beyond
* the base limit (5).
*
* <p>Examples:
*
* <ul>
* <li>If grandfathered = 5: returns 0 (base amount, nothing special)
* <li>If grandfathered = 10: returns 5 (5 extra users)
* <li>If grandfathered = 15: returns 10 (10 extra users)
* </ul>
*
* @return Number of grandfathered users beyond the base limit
*/
public int getDisplayGrandfatheredCount() {
UserLicenseSettings settings = getOrCreateSettings();
int totalGrandfathered = settings.getGrandfatheredUserCount();
return Math.max(0, totalGrandfathered - DEFAULT_USER_LIMIT);
}
/** Gets the current settings. */
public UserLicenseSettings getSettings() {
return getOrCreateSettings();
}
private boolean ensureIntegritySalt(UserLicenseSettings settings) {
if (settings.getIntegritySalt() == null || settings.getIntegritySalt().isBlank()) {
settings.setIntegritySalt(UUID.randomUUID().toString());
return true;
}
return false;
}
private Optional<Integer> extractSignedCount(UserLicenseSettings settings) {
String signature = settings.getGrandfatheredUserSignature();
if (signature == null || signature.isBlank()) {
return Optional.empty();
}
String[] parts = signature.split(SIGNATURE_SEPARATOR, 2);
if (parts.length != 2) {
log.warn("Invalid grandfathered user signature format detected");
return Optional.empty();
}
try {
return Optional.of(Integer.parseInt(parts[0]));
} catch (NumberFormatException ex) {
log.warn("Unable to parse grandfathered user signature count", ex);
return Optional.empty();
}
}
private boolean signatureMatches(int count, String signature, UserLicenseSettings settings) {
if (signature == null || signature.isBlank()) {
return false;
}
return generateSignature(count, settings).equals(signature);
}
private String generateSignature(int count, UserLicenseSettings settings) {
if (settings.getIntegritySalt() == null || settings.getIntegritySalt().isBlank()) {
throw new IllegalStateException("Integrity salt must be initialized before signing.");
}
String payload = buildSignaturePayload(count, settings.getIntegritySalt());
String secret = deriveIntegritySecret();
String digest = computeHmac(payload, secret);
return count + SIGNATURE_SEPARATOR + digest;
}
private String buildSignaturePayload(int count, String salt) {
return count + SIGNATURE_SEPARATOR + salt;
}
private String deriveIntegritySecret() {
StringBuilder builder = new StringBuilder();
appendIfPresent(builder, applicationProperties.getAutomaticallyGenerated().getKey());
appendIfPresent(builder, applicationProperties.getAutomaticallyGenerated().getUUID());
appendIfPresent(builder, applicationProperties.getPremium().getKey());
if (builder.length() == 0) {
builder.append(DEFAULT_INTEGRITY_SECRET);
}
return builder.toString();
}
private void appendIfPresent(StringBuilder builder, String value) {
if (value != null && !value.isBlank()) {
if (builder.length() > 0) {
builder.append(SIGNATURE_SEPARATOR);
}
builder.append(value);
}
}
private String computeHmac(String payload, String secret) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec keySpec =
new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
mac.init(keySpec);
byte[] digest = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("Failed to compute grandfathered user signature", e);
} catch (InvalidKeyException e) {
throw new IllegalStateException("Invalid key for grandfathered user signature", e);
}
}
}
@@ -1,8 +1,6 @@
package stirling.software.proprietary.security;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.*;
import java.io.IOException;
@@ -40,6 +38,7 @@ class CustomLogoutSuccessHandlerTest {
when(response.isCommitted()).thenReturn(false);
when(jwtService.extractToken(request)).thenReturn(token);
doNothing().when(jwtService).clearToken(response);
when(request.getContextPath()).thenReturn("");
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
@@ -57,12 +56,14 @@ class CustomLogoutSuccessHandlerTest {
when(response.isCommitted()).thenReturn(false);
when(jwtService.extractToken(request)).thenReturn(token);
doNothing().when(jwtService).clearToken(response);
when(request.getContextPath()).thenReturn("");
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
customLogoutSuccessHandler.onLogoutSuccess(request, response, null);
verify(response).sendRedirect(logoutPath);
verify(jwtService).clearToken(response);
}
@Test
@@ -127,6 +127,7 @@ class JwtAuthenticationFilterTest {
.setAuthentication(any(UsernamePasswordAuthenticationToken.class));
verify(jwtService)
.generateToken(any(UsernamePasswordAuthenticationToken.class), eq(claims));
verify(jwtService).addToken(response, newToken);
verify(filterChain).doFilter(request, response);
}
}
@@ -8,6 +8,8 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.atLeast;
import static org.mockito.Mockito.contains;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
@@ -15,6 +17,7 @@ import static org.mockito.Mockito.when;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
@@ -24,10 +27,13 @@ import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.core.Authentication;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -53,7 +59,7 @@ class JwtServiceTest {
private JwtVerificationKey testVerificationKey;
@BeforeEach
void setUp() throws Exception {
void setUp() throws NoSuchAlgorithmException {
// Generate a test keypair
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
@@ -218,8 +224,7 @@ class JwtServiceTest {
assertEquals("admin", extractedClaims.get("role"));
assertEquals("IT", extractedClaims.get("department"));
assertEquals(username, extractedClaims.get("sub"));
// Verify the constant issuer is set correctly
assertEquals("https://stirling.com", extractedClaims.get("iss"));
assertEquals("Stirling PDF", extractedClaims.get("iss"));
}
@Test
@@ -234,27 +239,62 @@ class JwtServiceTest {
}
@Test
void testExtractTokenWithAuthorizationHeader() {
void testExtractTokenWithCookie() {
String token = "test-token";
when(request.getHeader("Authorization")).thenReturn("Bearer " + token);
Cookie[] cookies = {new Cookie("stirling_jwt", token)};
when(request.getCookies()).thenReturn(cookies);
assertEquals(token, jwtService.extractToken(request));
}
@Test
void testExtractTokenWithNoAuthorizationHeader() {
when(request.getHeader("Authorization")).thenReturn(null);
void testExtractTokenWithNoCookies() {
when(request.getCookies()).thenReturn(null);
assertNull(jwtService.extractToken(request));
}
@Test
void testExtractTokenWithInvalidAuthorizationHeaderFormat() {
when(request.getHeader("Authorization")).thenReturn("InvalidFormat token");
void testExtractTokenWithWrongCookie() {
Cookie[] cookies = {new Cookie("OTHER_COOKIE", "value")};
when(request.getCookies()).thenReturn(cookies);
assertNull(jwtService.extractToken(request));
}
@Test
void testExtractTokenWithInvalidAuthorizationHeader() {
when(request.getCookies()).thenReturn(null);
assertNull(jwtService.extractToken(request));
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
void testAddToken(boolean secureCookie) throws Exception {
String token = "test-token";
// Create new JwtService instance with the secureCookie parameter
JwtService testJwtService = createJwtServiceWithSecureCookie(secureCookie);
testJwtService.addToken(response, token);
verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt=" + token));
verify(response).addHeader(eq("Set-Cookie"), contains("HttpOnly"));
if (secureCookie) {
verify(response).addHeader(eq("Set-Cookie"), contains("Secure"));
}
}
@Test
void testClearToken() {
jwtService.clearToken(response);
verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt="));
verify(response).addHeader(eq("Set-Cookie"), contains("Max-Age=0"));
}
@Test
void testGenerateTokenWithKeyId() throws Exception {
String username = "testuser";
@@ -333,4 +373,17 @@ class JwtServiceTest {
// Verify fallback logic was used
verify(keystoreService, atLeast(1)).getActiveKey();
}
private JwtService createJwtServiceWithSecureCookie(boolean secureCookie) throws Exception {
// Use reflection to create JwtService with custom secureCookie value
JwtService testService = new JwtService(true, keystoreService);
// Set the secureCookie field using reflection
java.lang.reflect.Field secureCookieField =
JwtService.class.getDeclaredField("secureCookie");
secureCookieField.setAccessible(true);
secureCookieField.set(testService, secureCookie);
return testService;
}
}
+1 -32
View File
@@ -629,40 +629,9 @@ tasks.named('bootRun') {
tasks.named('build') {
group = 'build'
description = 'Delegates to :stirling-pdf:bootJar'
dependsOn ':stirling-pdf:bootJar', 'buildRestartHelper'
dependsOn ':stirling-pdf:bootJar'
doFirst {
println "Delegating to :stirling-pdf:bootJar"
}
}
// Task to compile RestartHelper.java
tasks.register('compileRestartHelper', JavaCompile) {
group = 'build'
description = 'Compiles the RestartHelper utility'
source = fileTree(dir: 'scripts', include: 'RestartHelper.java')
classpath = files()
destinationDirectory = file("${buildDir}/restart-helper-classes")
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
// Task to create restart-helper.jar
tasks.register('buildRestartHelper', Jar) {
group = 'build'
description = 'Builds the restart-helper.jar'
dependsOn 'compileRestartHelper'
from "${buildDir}/restart-helper-classes"
archiveFileName = 'restart-helper.jar'
destinationDirectory = file("${buildDir}/libs")
manifest {
attributes 'Main-Class': 'RestartHelper'
}
doLast {
println "restart-helper.jar created at: ${destinationDirectory.get()}/restart-helper.jar"
}
}
-141
View File
@@ -1,141 +0,0 @@
# Unified Dockerfile - Frontend + Backend in single container
# Supports MODE parameter: BOTH (default), FRONTEND, BACKEND
# Stage 1: Build Frontend
FROM node:20-alpine AS frontend-build
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend .
RUN npm run build
# Stage 2: Build Backend
FROM gradle:8.14-jdk21 AS backend-build
COPY build.gradle .
COPY settings.gradle .
COPY gradlew .
COPY gradle gradle/
COPY app/core/build.gradle core/.
COPY app/common/build.gradle common/.
COPY app/proprietary/build.gradle proprietary/.
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
WORKDIR /app
COPY . .
RUN DISABLE_ADDITIONAL_FEATURES=false \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Stage 3: Final unified image
FROM alpine:3.22.1
ARG VERSION_TAG
# Labels
LABEL org.opencontainers.image.title="Stirling-PDF Unified"
LABEL org.opencontainers.image.description="Unified container for Stirling-PDF - Frontend + Backend with MODE parameter"
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.vendor="Stirling-Tools"
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com"
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
LABEL maintainer="Stirling-Tools"
LABEL org.opencontainers.image.authors="Stirling-Tools"
LABEL org.opencontainers.image.version="${VERSION_TAG}"
LABEL org.opencontainers.image.keywords="PDF, manipulation, unified, API, Spring Boot, React"
# Copy backend files
COPY scripts /scripts
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
COPY --from=backend-build /app/app/core/build/libs/*.jar app.jar
# Copy frontend files
COPY --from=frontend-build /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY docker/unified/nginx.conf /etc/nginx/nginx.conf
COPY docker/unified/entrypoint.sh /entrypoint.sh
# Environment Variables
ENV DISABLE_ADDITIONAL_FEATURES=false \
VERSION_TAG=$VERSION_TAG \
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \
PUID=1000 \
PGID=1000 \
UMASK=022 \
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
UNO_PATH=/usr/lib/libreoffice/program \
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
PATH=$PATH:/opt/venv/bin \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf \
MODE=BOTH \
BACKEND_INTERNAL_PORT=8081 \
VITE_API_BASE_URL=http://localhost:8080
# Install all dependencies
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
apk upgrade --no-cache -a && \
apk add --no-cache \
ca-certificates \
tzdata \
tini \
bash \
curl \
shadow \
su-exec \
openssl \
openssl-dev \
openjdk21-jre \
nginx \
# Doc conversion
gcompat \
libc6-compat \
libreoffice \
# pdftohtml
poppler-utils \
# OCR MY PDF
unpaper \
tesseract-ocr-data-eng \
tesseract-ocr-data-chi_sim \
tesseract-ocr-data-deu \
tesseract-ocr-data-fra \
tesseract-ocr-data-por \
ocrmypdf \
# CV
py3-opencv \
python3 \
py3-pip \
py3-pillow@testing \
py3-pdf2image@testing && \
python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --upgrade pip setuptools && \
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
mv /usr/share/tessdata /usr/share/tessdata-original && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf /pipeline/watchedFolders /pipeline/finishedFolders && \
mkdir -p /var/lib/nginx/tmp /var/log/nginx && \
fc-cache -f -v && \
chmod +x /scripts/* && \
chmod +x /entrypoint.sh && \
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf /var/lib/nginx /var/log/nginx /usr/share/nginx && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar
EXPOSE 8080/tcp
ENTRYPOINT ["tini", "--", "/entrypoint.sh"]
+1 -8
View File
@@ -50,14 +50,7 @@ docker-compose -f docker/compose/docker-compose.fat.yml up --build
- **Custom Ports**: Modify port mappings in docker-compose files
- **Memory Limits**: Adjust memory limits per variant (2G ultra-lite, 4G standard, 6G fat)
### [Google Drive Integration](https://developers.google.com/workspace/drive/picker/guides/overview)
- **VITE_GOOGLE_DRIVE_CLIENT_ID**: [OAuth 2.0 Client ID](https://console.cloud.google.com/auth/clients/create)
- **VITE_GOOGLE_DRIVE_API_KEY**: [Create New API](https://console.cloud.google.com/apis)
- **VITE_GOOGLE_DRIVE_APP_ID**: This is your [project number](https://console.cloud.google.com/iam-admin/settings) in the GoogleCloud Settings
## Development vs Production
- **Development**: Keep backend port 8080 exposed for debugging
- **Production**: Remove backend port exposure, use only frontend proxy
- **Production**: Remove backend port exposure, use only frontend proxy
+1 -2
View File
@@ -30,7 +30,6 @@ COPY scripts /scripts
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
# first /app directory is for the build stage, second is for the final image
COPY --from=build /app/app/core/build/libs/*.jar app.jar
COPY --from=build /app/build/libs/restart-helper.jar restart-helper.jar
ARG VERSION_TAG
@@ -114,7 +113,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar
chown stirlingpdfuser:stirlingpdfgroup /app.jar
EXPOSE 8080/tcp
+1 -2
View File
@@ -30,7 +30,6 @@ COPY scripts /scripts
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
# first /app directory is for the build stage, second is for the final image
COPY --from=build /app/app/core/build/libs/*.jar app.jar
COPY --from=build /app/build/libs/restart-helper.jar restart-helper.jar
ARG VERSION_TAG
@@ -105,7 +104,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar
chown stirlingpdfuser:stirlingpdfgroup /app.jar
EXPOSE 8080/tcp
# Set user and run command
+1 -2
View File
@@ -45,7 +45,6 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \
COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
COPY scripts/installFonts.sh /scripts/installFonts.sh
COPY --from=build /app/app/core/build/libs/*.jar app.jar
COPY --from=build /app/build/libs/restart-helper.jar restart-helper.jar
# Set up necessary directories and permissions
RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
@@ -66,7 +65,7 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /et
chmod +x /scripts/*.sh && \
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /configs /customFiles /pipeline /tmp/stirling-pdf && \
chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar
chown stirlingpdfuser:stirlingpdfgroup /app.jar
# Set environment variables
ENV ENDPOINTS_GROUPS_TO_REMOVE=CLI
@@ -1,58 +0,0 @@
# Example Docker Compose for Unified Stirling-PDF Container
# MODE=BACKEND: Backend API only (no frontend)
services:
stirling-pdf-backend-only:
container_name: Stirling-PDF-Backend-Only
build:
context: ../..
dockerfile: docker/Dockerfile.unified
ports:
- "8080:8080"
volumes:
- ./stirling/data:/usr/share/tessdata:rw
- ./stirling/config:/configs:rw
- ./stirling/logs:/logs:rw
- ./stirling/customFiles:/customFiles:rw
- ./stirling/pipeline:/pipeline:rw
environment:
# MODE parameter: BACKEND only
MODE: BACKEND
# Standard Stirling-PDF configuration
DISABLE_ADDITIONAL_FEATURES: "false"
DOCKER_ENABLE_SECURITY: "false"
PUID: 1000
PGID: 1000
UMASK: "022"
# Application settings
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: Stirling-PDF
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
# Optional: Add OCR languages (comma-separated)
# TESSERACT_LANGS: "deu,fra,spa"
# Optional: Java memory settings
# JAVA_CUSTOM_OPTS: "-Xmx4g"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
deploy:
resources:
limits:
memory: 4G
reservations:
memory: 2G
# Access the API at: http://localhost:8080/api
# Swagger UI at: http://localhost:8080/swagger-ui/index.html
@@ -1,59 +0,0 @@
# Example Docker Compose for Unified Stirling-PDF Container
# MODE=BOTH (default): Frontend + Backend in single container on port 8080
services:
stirling-pdf-unified:
container_name: Stirling-PDF-Unified-Both
build:
context: ../..
dockerfile: docker/Dockerfile.unified
ports:
- "8080:8080"
volumes:
- ./stirling/data:/usr/share/tessdata:rw
- ./stirling/config:/configs:rw
- ./stirling/logs:/logs:rw
- ./stirling/customFiles:/customFiles:rw
- ./stirling/pipeline:/pipeline:rw
environment:
# MODE parameter: BOTH (default), FRONTEND, or BACKEND
MODE: BOTH
# Backend runs internally on this port when MODE=BOTH
BACKEND_INTERNAL_PORT: 8081
# Standard Stirling-PDF configuration
DISABLE_ADDITIONAL_FEATURES: "false"
DOCKER_ENABLE_SECURITY: "false"
PUID: 1000
PGID: 1000
UMASK: "022"
# Application settings
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: Stirling-PDF
UI_HOMEDESCRIPTION: Your locally hosted one-stop-shop for all your PDF needs
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
# Optional: Add OCR languages (comma-separated)
# TESSERACT_LANGS: "deu,fra,spa"
# Optional: Java memory settings
# JAVA_CUSTOM_OPTS: "-Xmx4g"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
deploy:
resources:
limits:
memory: 4G
reservations:
memory: 2G
@@ -1,63 +0,0 @@
# Example Docker Compose for Unified Stirling-PDF Container
# MODE=FRONTEND: Frontend only, connects to separate backend
services:
stirling-pdf-backend:
container_name: Stirling-PDF-Backend
build:
context: ../..
dockerfile: docker/Dockerfile.unified
ports:
- "8081:8080"
volumes:
- ./stirling/data:/usr/share/tessdata:rw
- ./stirling/config:/configs:rw
- ./stirling/logs:/logs:rw
- ./stirling/customFiles:/customFiles:rw
- ./stirling/pipeline:/pipeline:rw
environment:
MODE: BACKEND
DISABLE_ADDITIONAL_FEATURES: "false"
DOCKER_ENABLE_SECURITY: "false"
PUID: 1000
PGID: 1000
UMASK: "022"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status || exit 1"]
interval: 30s
timeout: 10s
retries: 3
deploy:
resources:
limits:
memory: 4G
stirling-pdf-frontend:
container_name: Stirling-PDF-Frontend
build:
context: ../..
dockerfile: docker/Dockerfile.unified
ports:
- "8080:8080"
environment:
MODE: FRONTEND
# Point to the backend service
VITE_API_BASE_URL: http://stirling-pdf-backend:8080
# Minimal config needed for frontend
PUID: 1000
PGID: 1000
depends_on:
- stirling-pdf-backend
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
deploy:
resources:
limits:
memory: 512M
-3
View File
@@ -47,9 +47,6 @@ services:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
depends_on:
- backend
networks:
@@ -44,9 +44,6 @@ services:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
depends_on:
- backend
networks:
-3
View File
@@ -46,9 +46,6 @@ services:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
depends_on:
- backend
networks:
+1 -1
View File
@@ -4,7 +4,7 @@ FROM node:20-alpine AS build
WORKDIR /app
# Copy package files
COPY frontend/package.json frontend/package-lock.json ./
COPY frontend/package*.json ./
# Install dependencies
RUN npm ci
-458
View File
@@ -1,458 +0,0 @@
# Stirling-PDF Unified Container
Single Docker container that can run as **frontend + backend**, **frontend only**, or **backend only** using the `MODE` environment variable.
## Quick Start
### MODE=BOTH (Default)
Single container with both frontend and backend on port 8080:
```bash
docker run -p 8080:8080 \
-e MODE=BOTH \
stirlingtools/stirling-pdf:unified
```
Access at: `http://localhost:8080`
### MODE=FRONTEND
Frontend only, connecting to separate backend:
```bash
docker run -p 8080:8080 \
-e MODE=FRONTEND \
-e VITE_API_BASE_URL=http://backend:8080 \
stirlingtools/stirling-pdf:unified
```
### MODE=BACKEND
Backend API only:
```bash
docker run -p 8080:8080 \
-e MODE=BACKEND \
stirlingtools/stirling-pdf:unified
```
Access API at: `http://localhost:8080/api`
Swagger UI at: `http://localhost:8080/swagger-ui/index.html`
---
## Architecture
### MODE=BOTH (Default)
```
┌─────────────────────────────────────┐
│ Port 8080 (External) │
│ ┌───────────────────────────────┐ │
│ │ Nginx │ │
│ │ • Serves frontend (/) │ │
│ │ • Proxies /api/* → backend │ │
│ └───────────┬───────────────────┘ │
│ │ │
│ ┌───────────▼───────────────────┐ │
│ │ Backend (Internal 8081) │ │
│ │ • Spring Boot │ │
│ │ • PDF Processing │ │
│ │ • UnoServer │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
```
### MODE=FRONTEND
```
┌─────────────────────────────┐ ┌──────────────────┐
│ Frontend Container │ │ Backend │
│ Port 8080 │ │ (External) │
│ ┌───────────────────────┐ │ │ │
│ │ Nginx │ │──────▶ :8080/api │
│ │ • Serves frontend │ │ │ │
│ │ • Proxies to backend │ │ │ │
│ └───────────────────────┘ │ └──────────────────┘
└─────────────────────────────┘
```
### MODE=BACKEND
```
┌─────────────────────────────┐
│ Backend Container │
│ Port 8080 │
│ ┌───────────────────────┐ │
│ │ Spring Boot │ │
│ │ • API Endpoints │ │
│ │ • PDF Processing │ │
│ │ • UnoServer │ │
│ └───────────────────────┘ │
└─────────────────────────────┘
```
---
## Environment Variables
### MODE Configuration
| Variable | Values | Default | Description |
|----------|--------|---------|-------------|
| `MODE` | `BOTH`, `FRONTEND`, `BACKEND` | `BOTH` | Container operation mode |
### MODE=BOTH Specific
| Variable | Default | Description |
|----------|---------|-------------|
| `BACKEND_INTERNAL_PORT` | `8081` | Internal port for backend when MODE=BOTH |
### MODE=FRONTEND Specific
| Variable | Default | Description |
|----------|---------|-------------|
| `VITE_API_BASE_URL` | `http://backend:8080` | Backend URL for API proxying |
### Standard Configuration
All modes support standard Stirling-PDF environment variables:
- `DISABLE_ADDITIONAL_FEATURES` - Enable/disable OCR and LibreOffice features
- `DOCKER_ENABLE_SECURITY` - Enable authentication
- `PUID` / `PGID` - User/Group IDs
- `SYSTEM_MAXFILESIZE` - Max upload size (MB)
- `TESSERACT_LANGS` - Comma-separated OCR language codes
- `JAVA_CUSTOM_OPTS` - Additional JVM options
See full configuration docs at: https://docs.stirlingpdf.com
---
## Docker Compose Examples
### Example 1: All-in-One (MODE=BOTH)
**File:** `docker/compose/docker-compose-unified-both.yml`
```yaml
services:
stirling-pdf:
image: stirlingtools/stirling-pdf:unified
ports:
- "8080:8080"
volumes:
- ./data:/usr/share/tessdata:rw
- ./config:/configs:rw
environment:
MODE: BOTH
restart: unless-stopped
```
### Example 2: Separate Frontend & Backend
**File:** `docker/compose/docker-compose-unified-frontend.yml`
```yaml
services:
backend:
image: stirlingtools/stirling-pdf:unified
ports:
- "8081:8080"
environment:
MODE: BACKEND
volumes:
- ./data:/usr/share/tessdata:rw
- ./config:/configs:rw
frontend:
image: stirlingtools/stirling-pdf:unified
ports:
- "8080:8080"
environment:
MODE: FRONTEND
VITE_API_BASE_URL: http://backend:8080
depends_on:
- backend
```
### Example 3: Backend API Only
**File:** `docker/compose/docker-compose-unified-backend.yml`
```yaml
services:
stirling-pdf-api:
image: stirlingtools/stirling-pdf:unified
ports:
- "8080:8080"
environment:
MODE: BACKEND
volumes:
- ./data:/usr/share/tessdata:rw
- ./config:/configs:rw
restart: unless-stopped
```
---
## Building the Image
```bash
# From repository root
docker build -t stirlingtools/stirling-pdf:unified -f docker/Dockerfile.unified .
```
### Build Arguments
| Argument | Description |
|----------|-------------|
| `VERSION_TAG` | Version tag for the image |
Example:
```bash
docker build \
--build-arg VERSION_TAG=v1.0.0 \
-t stirlingtools/stirling-pdf:unified \
-f docker/Dockerfile.unified .
```
---
## Use Cases
### 1. Simple Deployment (MODE=BOTH)
- **Best for:** Personal use, small teams, simple deployments
- **Pros:** Single container, easy setup, minimal configuration
- **Cons:** Frontend and backend scale together
### 2. Scaled Frontend (MODE=FRONTEND + BACKEND)
- **Best for:** High traffic, need to scale frontend independently
- **Pros:** Scale frontend containers separately, CDN-friendly
- **Example:**
```yaml
services:
backend:
image: stirlingtools/stirling-pdf:unified
environment:
MODE: BACKEND
deploy:
replicas: 1
frontend:
image: stirlingtools/stirling-pdf:unified
environment:
MODE: FRONTEND
VITE_API_BASE_URL: http://backend:8080
deploy:
replicas: 5 # Scale frontend independently
```
### 3. API-Only (MODE=BACKEND)
- **Best for:** Headless deployments, custom frontends, API integrations
- **Pros:** Minimal resources, no nginx overhead
- **Example:** Use with external frontend or API consumers
### 4. Multi-Backend Setup
- **Best for:** Load balancing, high availability
- **Example:**
```yaml
services:
backend-1:
image: stirlingtools/stirling-pdf:unified
environment:
MODE: BACKEND
backend-2:
image: stirlingtools/stirling-pdf:unified
environment:
MODE: BACKEND
frontend:
image: stirlingtools/stirling-pdf:unified
environment:
MODE: FRONTEND
VITE_API_BASE_URL: http://load-balancer:8080
```
---
## Port Configuration
All modes use **port 8080** by default:
- **MODE=BOTH**: Nginx listens on 8080, proxies to backend on internal 8081
- **MODE=FRONTEND**: Nginx listens on 8080
- **MODE=BACKEND**: Spring Boot listens on 8080
**Expose port 8080** in all configurations:
```yaml
ports:
- "8080:8080"
```
---
## Health Checks
### MODE=BOTH and MODE=BACKEND
```yaml
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status || exit 1"]
interval: 30s
timeout: 10s
retries: 3
```
### MODE=FRONTEND
```yaml
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
```
---
## Troubleshooting
### Check logs
```bash
docker logs stirling-pdf-container
```
Look for the startup banner:
```
===================================
Stirling-PDF Unified Container
MODE: BOTH
===================================
```
### Invalid MODE error
```
ERROR: Invalid MODE 'XYZ'. Must be BOTH, FRONTEND, or BACKEND
```
**Fix:** Set `MODE` to one of the three valid values.
### Frontend can't connect to backend (MODE=FRONTEND)
**Check:**
1. `VITE_API_BASE_URL` points to correct backend URL
2. Backend container is running and accessible
3. Network connectivity between containers
### Backend not starting (MODE=BOTH or BACKEND)
**Check:**
1. Sufficient memory allocated (4GB recommended)
2. Java heap size (`JAVA_CUSTOM_OPTS`)
3. Volume permissions for `/tmp/stirling-pdf`
---
## Migration Guide
### From Separate Containers → MODE=BOTH
**Before:**
```yaml
services:
frontend:
image: stirlingtools/stirling-pdf:frontend
ports: ["80:80"]
backend:
image: stirlingtools/stirling-pdf:backend
ports: ["8080:8080"]
```
**After:**
```yaml
services:
stirling-pdf:
image: stirlingtools/stirling-pdf:unified
ports: ["8080:8080"]
environment:
MODE: BOTH
```
### From Legacy → MODE=BACKEND
```yaml
services:
stirling-pdf:
image: stirlingtools/stirling-pdf:latest
ports: ["8080:8080"]
```
**Becomes:**
```yaml
services:
stirling-pdf:
image: stirlingtools/stirling-pdf:unified
ports: ["8080:8080"]
environment:
MODE: BACKEND
```
---
## Performance Tuning
### MODE=BOTH
```yaml
environment:
JAVA_CUSTOM_OPTS: "-Xmx4g -XX:MaxRAMPercentage=75"
BACKEND_INTERNAL_PORT: 8081
deploy:
resources:
limits:
memory: 4G
reservations:
memory: 2G
```
### MODE=FRONTEND (Lightweight)
```yaml
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
```
### MODE=BACKEND (Heavy Processing)
```yaml
environment:
JAVA_CUSTOM_OPTS: "-Xmx8g"
deploy:
resources:
limits:
memory: 10G
reservations:
memory: 4G
```
---
## Security Considerations
1. **MODE=BOTH**: Backend not exposed externally (runs on internal port)
2. **MODE=BACKEND**: API exposed directly - consider API authentication
3. **MODE=FRONTEND**: Only serves static files - minimal attack surface
Enable security features:
```yaml
environment:
DOCKER_ENABLE_SECURITY: "true"
SECURITY_ENABLELOGIN: "true"
```
---
## Support
- Documentation: https://docs.stirlingpdf.com
- GitHub Issues: https://github.com/Stirling-Tools/Stirling-PDF/issues
- Docker Hub: https://hub.docker.com/r/stirlingtools/stirling-pdf
---
## License
MIT License - See repository for full details
-38
View File
@@ -1,38 +0,0 @@
#!/bin/bash
# Build script for Stirling-PDF Unified Container
# Usage: ./build.sh [version-tag]
set -e
VERSION_TAG=${1:-latest}
IMAGE_NAME="stirlingtools/stirling-pdf:unified-${VERSION_TAG}"
echo "==================================="
echo "Building Stirling-PDF Unified Container"
echo "Version: $VERSION_TAG"
echo "Image: $IMAGE_NAME"
echo "==================================="
# Navigate to repository root (assuming script is in docker/unified/)
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
REPO_ROOT="$SCRIPT_DIR/../.."
cd "$REPO_ROOT"
# Build the image
docker build \
--build-arg VERSION_TAG="$VERSION_TAG" \
-t "$IMAGE_NAME" \
-f docker/Dockerfile.unified \
.
echo "==================================="
echo "✓ Build complete!"
echo "Image: $IMAGE_NAME"
echo ""
echo "Test the image:"
echo " MODE=BOTH: docker run -p 8080:8080 -e MODE=BOTH $IMAGE_NAME"
echo " MODE=FRONTEND: docker run -p 8080:8080 -e MODE=FRONTEND $IMAGE_NAME"
echo " MODE=BACKEND: docker run -p 8080:8080 -e MODE=BACKEND $IMAGE_NAME"
echo "==================================="
-176
View File
@@ -1,176 +0,0 @@
#!/bin/bash
set -e
# Default MODE to BOTH if not set
MODE=${MODE:-BOTH}
echo "==================================="
echo "Stirling-PDF Unified Container"
echo "MODE: $MODE"
echo "==================================="
# Function to setup OCR (from init.sh)
setup_ocr() {
echo "Setting up OCR languages..."
# Copy tessdata
mkdir -p /usr/share/tessdata
cp -rn /usr/share/tessdata-original/* /usr/share/tessdata 2>/dev/null || true
if [ -d /usr/share/tesseract-ocr/4.00/tessdata ]; then
cp -r /usr/share/tesseract-ocr/4.00/tessdata/* /usr/share/tessdata 2>/dev/null || true
fi
if [ -d /usr/share/tesseract-ocr/5/tessdata ]; then
cp -r /usr/share/tesseract-ocr/5/tessdata/* /usr/share/tessdata 2>/dev/null || true
fi
# Install additional languages if specified
if [[ -n "$TESSERACT_LANGS" ]]; then
SPACE_SEPARATED_LANGS=$(echo $TESSERACT_LANGS | tr ',' ' ')
pattern='^[a-zA-Z]{2,4}(_[a-zA-Z]{2,4})?$'
for LANG in $SPACE_SEPARATED_LANGS; do
if [[ $LANG =~ $pattern ]]; then
apk add --no-cache "tesseract-ocr-data-$LANG" 2>/dev/null || true
fi
done
fi
}
# Function to setup user permissions (from init-without-ocr.sh)
setup_permissions() {
echo "Setting up user permissions..."
export JAVA_TOOL_OPTIONS="${JAVA_BASE_OPTS} ${JAVA_CUSTOM_OPTS}"
# Update user and group IDs
if [ ! -z "$PUID" ] && [ "$PUID" != "$(id -u stirlingpdfuser)" ]; then
usermod -o -u "$PUID" stirlingpdfuser || true
fi
if [ ! -z "$PGID" ] && [ "$PGID" != "$(getent group stirlingpdfgroup | cut -d: -f3)" ]; then
groupmod -o -g "$PGID" stirlingpdfgroup || true
fi
umask "$UMASK" || true
# Install fonts if needed
if [[ -n "$LANGS" ]]; then
/scripts/installFonts.sh $LANGS
fi
# Ensure directories exist with correct permissions
mkdir -p /tmp/stirling-pdf || true
# Set ownership and permissions
chown -R stirlingpdfuser:stirlingpdfgroup \
$HOME /logs /scripts /usr/share/fonts/opentype/noto \
/configs /customFiles /pipeline /tmp/stirling-pdf \
/var/lib/nginx /var/log/nginx /usr/share/nginx \
/app.jar 2>/dev/null || echo "[WARN] Some chown operations failed, may run as host user"
chmod -R 755 /logs /scripts /usr/share/fonts/opentype/noto \
/configs /customFiles /pipeline /tmp/stirling-pdf 2>/dev/null || true
}
# Function to configure nginx
configure_nginx() {
local backend_url=$1
echo "Configuring nginx with backend URL: $backend_url"
sed -i "s|\${BACKEND_URL}|${backend_url}|g" /etc/nginx/nginx.conf
}
# Function to run as user or root depending on permissions
run_as_user() {
if [ "$(id -u)" = "0" ]; then
# Running as root, use su-exec
su-exec stirlingpdfuser "$@"
else
# Already running as non-root
exec "$@"
fi
}
# Setup OCR and permissions
setup_ocr
setup_permissions
# Handle different modes
case "$MODE" in
BOTH)
echo "Starting in BOTH mode: Frontend + Backend on port 8080"
# Configure nginx to proxy to internal backend
configure_nginx "http://localhost:${BACKEND_INTERNAL_PORT:-8081}"
# Start backend on internal port
echo "Starting backend on port ${BACKEND_INTERNAL_PORT:-8081}..."
run_as_user sh -c "java -Dfile.encoding=UTF-8 \
-Djava.io.tmpdir=/tmp/stirling-pdf \
-Dserver.port=${BACKEND_INTERNAL_PORT:-8081} \
-jar /app.jar" &
BACKEND_PID=$!
# Start unoserver for document conversion
run_as_user /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1 &
UNO_PID=$!
# Wait for backend to start
sleep 3
# Start nginx on port 8080
echo "Starting nginx on port 8080..."
run_as_user nginx -g "daemon off;" &
NGINX_PID=$!
echo "==================================="
echo "✓ Frontend available at: http://localhost:8080"
echo "✓ Backend API at: http://localhost:8080/api"
echo "✓ Backend running internally on port ${BACKEND_INTERNAL_PORT:-8081}"
echo "==================================="
;;
FRONTEND)
echo "Starting in FRONTEND mode: Frontend only on port 8080"
# Configure nginx with external backend URL
BACKEND_URL=${VITE_API_BASE_URL:-http://backend:8080}
configure_nginx "$BACKEND_URL"
# Start nginx on port 8080
echo "Starting nginx on port 8080..."
run_as_user nginx -g "daemon off;" &
NGINX_PID=$!
echo "==================================="
echo "✓ Frontend available at: http://localhost:8080"
echo "✓ Proxying API calls to: $BACKEND_URL"
echo "==================================="
;;
BACKEND)
echo "Starting in BACKEND mode: Backend only on port 8080"
# Start backend on port 8080
echo "Starting backend on port 8080..."
run_as_user sh -c "java -Dfile.encoding=UTF-8 \
-Djava.io.tmpdir=/tmp/stirling-pdf \
-Dserver.port=8080 \
-jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1" &
BACKEND_PID=$!
echo "==================================="
echo "✓ Backend API available at: http://localhost:8080/api"
echo "✓ Swagger UI at: http://localhost:8080/swagger-ui/index.html"
echo "==================================="
;;
*)
echo "ERROR: Invalid MODE '$MODE'. Must be BOTH, FRONTEND, or BACKEND"
exit 1
;;
esac
# Wait for all background processes
wait
-118
View File
@@ -1,118 +0,0 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Add .mjs MIME type mapping
types {
text/javascript mjs;
}
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html index.htm;
# Global settings for file uploads
client_max_body_size 100m;
# Handle client-side routing - support subpaths
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API calls to backend
location /api/ {
proxy_pass ${BACKEND_URL}/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# Additional headers for proper API proxying
proxy_set_header Connection '';
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
# Timeout settings for large file uploads
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Request size limits for file uploads
client_max_body_size 100m;
proxy_request_buffering off;
}
# Proxy Swagger UI to backend (including versioned paths)
location ~ ^/swagger-ui(.*)$ {
proxy_pass ${BACKEND_URL}/swagger-ui$1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Connection '';
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
}
# Proxy API docs to backend (with query parameters and sub-paths)
location ~ ^/v3/api-docs(.*)$ {
proxy_pass ${BACKEND_URL}/v3/api-docs$1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
}
# Proxy v1 API docs to backend (with query parameters and sub-paths)
location ~ ^/v1/api-docs(.*)$ {
proxy_pass ${BACKEND_URL}/v1/api-docs$1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
}
# Serve .mjs files with correct MIME type (must come before general static assets)
location ~* \.mjs$ {
try_files $uri =404;
add_header Content-Type "text/javascript; charset=utf-8" always;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
}
+21 -67
View File
@@ -1,88 +1,42 @@
// @ts-check
import eslint from '@eslint/js';
import globals from 'globals';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
import importPlugin from 'eslint-plugin-import';
const srcGlobs = [
'src/**/*.{js,mjs,jsx,ts,tsx}',
];
const nodeGlobs = [
'scripts/**/*.{js,ts,mjs}',
'*.config.{js,ts,mjs}',
];
export default defineConfig(
{
// Everything that contains 3rd party code that we don't want to lint
ignores: [
'dist',
'node_modules',
'public',
],
},
eslint.configs.recommended,
tseslint.configs.recommended,
{
ignores: [
"dist", // Contains 3rd party code
"public", // Contains 3rd party code
],
},
{
rules: {
'@typescript-eslint/no-empty-object-type': [
'error',
"no-undef": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-empty-object-type": [
"error",
{
// Allow empty extending interfaces because there's no real reason not to, and it makes it obvious where to put extra attributes in the future
allowInterfaces: 'with-single-extends',
},
],
'@typescript-eslint/no-explicit-any': 'off', // Temporarily disabled until codebase conformant
'@typescript-eslint/no-require-imports': 'off', // Temporarily disabled until codebase conformant
'@typescript-eslint/no-unused-vars': [
'error',
"@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-require-imports": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-unused-vars": [
"error",
{
'args': 'all', // All function args must be used (or explicitly ignored)
'argsIgnorePattern': '^_', // Allow unused variables beginning with an underscore
'caughtErrors': 'all', // Caught errors must be used (or explicitly ignored)
'caughtErrorsIgnorePattern': '^_', // Allow unused variables beginning with an underscore
'destructuredArrayIgnorePattern': '^_', // Allow unused variables beginning with an underscore
'varsIgnorePattern': '^_', // Allow unused variables beginning with an underscore
'ignoreRestSiblings': true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky)
"args": "all", // All function args must be used (or explicitly ignored)
"argsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"caughtErrors": "all", // Caught errors must be used (or explicitly ignored)
"caughtErrorsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"destructuredArrayIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"varsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"ignoreRestSiblings": true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky)
},
],
},
},
// Config for browser scripts
{
files: srcGlobs,
languageOptions: {
globals: {
...globals.browser,
}
}
},
// Config for node scripts
{
files: nodeGlobs,
languageOptions: {
globals: {
...globals.node,
}
}
},
// Config for import plugin
{
...importPlugin.flatConfigs.recommended,
...importPlugin.flatConfigs.typescript,
rules: {
// ...importPlugin.flatConfigs.recommended.rules, // Temporarily disabled until codebase conformant
...importPlugin.flatConfigs.typescript.rules,
'import/no-cycle': 'error',
},
settings: {
'import/resolver': {
typescript: {
project: './tsconfig.json',
},
},
},
},
}
);
+1 -1
View File
@@ -18,6 +18,6 @@
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>
+128 -3235
View File
File diff suppressed because it is too large Load Diff
+19 -38
View File
@@ -6,24 +6,22 @@
"proxy": "http://localhost:8080",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@embedpdf/core": "^1.3.14",
"@embedpdf/engines": "^1.3.14",
"@embedpdf/plugin-annotation": "^1.3.14",
"@embedpdf/plugin-export": "^1.3.14",
"@embedpdf/plugin-history": "^1.3.14",
"@embedpdf/plugin-interaction-manager": "^1.3.14",
"@embedpdf/plugin-loader": "^1.3.14",
"@embedpdf/plugin-pan": "^1.3.14",
"@embedpdf/plugin-render": "^1.3.14",
"@embedpdf/plugin-rotate": "^1.3.14",
"@embedpdf/plugin-scroll": "^1.3.14",
"@embedpdf/plugin-search": "^1.3.14",
"@embedpdf/plugin-selection": "^1.3.14",
"@embedpdf/plugin-spread": "^1.3.14",
"@embedpdf/plugin-thumbnail": "^1.3.14",
"@embedpdf/plugin-tiling": "^1.3.14",
"@embedpdf/plugin-viewport": "^1.3.14",
"@embedpdf/plugin-zoom": "^1.3.14",
"@embedpdf/core": "^1.3.0",
"@embedpdf/engines": "^1.2.1",
"@embedpdf/plugin-export": "^1.3.0",
"@embedpdf/plugin-interaction-manager": "^1.3.0",
"@embedpdf/plugin-loader": "^1.3.0",
"@embedpdf/plugin-pan": "^1.3.0",
"@embedpdf/plugin-render": "^1.3.0",
"@embedpdf/plugin-rotate": "^1.3.0",
"@embedpdf/plugin-scroll": "^1.3.0",
"@embedpdf/plugin-search": "^1.3.0",
"@embedpdf/plugin-selection": "^1.3.0",
"@embedpdf/plugin-spread": "^1.3.0",
"@embedpdf/plugin-thumbnail": "^1.3.0",
"@embedpdf/plugin-tiling": "^1.3.0",
"@embedpdf/plugin-viewport": "^1.3.0",
"@embedpdf/plugin-zoom": "^1.3.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -31,16 +29,12 @@
"@mantine/dates": "^8.3.1",
"@mantine/dropzone": "^8.3.1",
"@mantine/hooks": "^8.3.1",
"@stripe/react-stripe-js": "^4.0.2",
"@stripe/stripe-js": "^7.9.0",
"@mui/icons-material": "^7.3.2",
"@mui/material": "^7.3.2",
"@reactour/tour": "^3.8.0",
"@tailwindcss/postcss": "^4.1.13",
"@tanstack/react-virtual": "^3.13.12",
"autoprefixer": "^10.4.21",
"axios": "^1.12.2",
"globals": "^16.4.0",
"i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
@@ -53,26 +47,21 @@
"react-dom": "^19.1.1",
"react-i18next": "^15.7.3",
"react-router-dom": "^7.9.1",
"signature_pad": "^5.0.4",
"tailwindcss": "^4.1.13",
"web-vitals": "^5.1.0"
},
"scripts": {
"predev": "npm run generate-icons",
"dev": "vite",
"dev": "npm run typecheck && vite",
"prebuild": "npm run generate-icons",
"lint": "eslint --max-warnings=0",
"build": "vite build",
"lint": "eslint",
"build": "npm run typecheck && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"typecheck:core": "tsc --noEmit --project tsconfig.core.json",
"typecheck:proprietary": "tsc --noEmit --project tsconfig.proprietary.json",
"typecheck:all": "npm run typecheck:core && npm run typecheck:proprietary",
"check": "npm run typecheck && npm run lint && npm run test:run",
"generate-licenses": "node scripts/generate-licenses.js",
"generate-icons": "node scripts/generate-icons.js",
"generate-icons:verbose": "node scripts/generate-icons.js --verbose",
"generate-sample-pdf": "node scripts/sample-pdf/generate.mjs",
"test": "vitest",
"test:run": "vitest run",
"test:watch": "vitest --watch",
@@ -113,10 +102,6 @@
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/gapi": "^0.0.47",
"@types/gapi.client.drive-v3": "^0.0.5",
"@types/google.accounts": "^0.0.18",
"@types/google.picker": "^0.0.51",
"@types/node": "^24.5.2",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
@@ -125,8 +110,6 @@
"@vitejs/plugin-react-swc": "^4.1.0",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.36.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-react-hooks": "^5.2.0",
"jsdom": "^27.0.0",
"license-checker": "^25.0.1",
@@ -135,11 +118,9 @@
"postcss-cli": "^11.0.1",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"puppeteer": "^24.25.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.1",
"vite": "^7.1.7",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4"
},
"depcheck": {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
</svg>

Before

Width:  |  Height:  |  Size: 426 B

-6
View File
@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" fill="none">
<path d="M0 0h10.5v10.5H0V0z" fill="#F25022"/>
<path d="M12.5 0H23v10.5H12.5V0z" fill="#7FBA00"/>
<path d="M0 12.5h10.5V23H0V12.5z" fill="#00A4EF"/>
<path d="M12.5 12.5H23V23H12.5V12.5z" fill="#FFB900"/>
</svg>

Before

Width:  |  Height:  |  Size: 292 B

-3
View File
@@ -1,3 +0,0 @@
<svg width="37" height="36" viewBox="0 0 37 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.5 3.3125C16.5712 3.3125 14.6613 3.6924 12.8793 4.43052C11.0974 5.16864 9.47823 6.25051 8.11437 7.61437C5.35993 10.3688 3.8125 14.1046 3.8125 18C3.8125 24.4919 8.02781 29.9997 13.8588 31.9531C14.5931 32.0706 14.8281 31.6153 14.8281 31.2187V28.7366C10.7597 29.6178 9.89312 26.7684 9.89312 26.7684C9.2175 25.0647 8.26281 24.6094 8.26281 24.6094C6.92625 23.6987 8.36562 23.7281 8.36562 23.7281C9.83437 23.8309 10.6128 25.2409 10.6128 25.2409C11.8906 27.4734 14.0497 26.8125 14.8869 26.46C15.0191 25.5053 15.4009 24.8591 15.8122 24.4919C12.5516 24.1247 9.12937 22.8616 9.12937 17.2656C9.12937 15.6353 9.6875 14.3281 10.6422 13.2853C10.4953 12.9181 9.98125 11.3906 10.7891 9.40781C10.7891 9.40781 12.0228 9.01125 14.8281 10.9059C15.9884 10.5828 17.2516 10.4212 18.5 10.4212C19.7484 10.4212 21.0116 10.5828 22.1719 10.9059C24.9772 9.01125 26.2109 9.40781 26.2109 9.40781C27.0188 11.3906 26.5047 12.9181 26.3578 13.2853C27.3125 14.3281 27.8706 15.6353 27.8706 17.2656C27.8706 22.8762 24.4338 24.11 21.1584 24.4772C21.6872 24.9325 22.1719 25.8284 22.1719 27.1944V31.2187C22.1719 31.6153 22.4069 32.0853 23.1559 31.9531C28.9869 29.985 33.1875 24.4919 33.1875 18C33.1875 16.0712 32.8076 14.1613 32.0695 12.3793C31.3314 10.5974 30.2495 8.97823 28.8856 7.61437C27.5218 6.25051 25.9026 5.16864 24.1207 4.43052C22.3387 3.6924 20.4288 3.3125 18.5 3.3125Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

-14
View File
@@ -1,14 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2781_85129)">
<path d="M8.36055 0.789432C5.96258 1.62131 3.89457 3.20024 2.46029 5.29431C1.026 7.38838 0.301037 9.8872 0.391883 12.4237C0.482728 14.9603 1.38459 17.4008 2.96501 19.3869C4.54543 21.373 6.72109 22.8 9.17243 23.4582C11.1598 23.971 13.2419 23.9935 15.2399 23.5238C17.0499 23.1172 18.7233 22.2476 20.0962 21.0001C21.5251 19.662 22.5622 17.9597 23.0962 16.0763C23.6765 14.0282 23.7798 11.8743 23.3981 9.78006H12.2381V14.4094H18.7012C18.572 15.1478 18.2952 15.8525 17.8873 16.4814C17.4795 17.1102 16.9489 17.6504 16.3274 18.0694C15.5382 18.5915 14.6485 18.9428 13.7156 19.1007C12.7798 19.2747 11.82 19.2747 10.8843 19.1007C9.93591 18.9046 9.03874 18.5132 8.24993 17.9513C6.98271 17.0543 6.0312 15.7799 5.53118 14.3101C5.02271 12.8127 5.02271 11.1893 5.53118 9.69193C5.8871 8.64234 6.47549 7.68669 7.25243 6.89631C8.14154 5.97521 9.26718 5.3168 10.5058 4.99332C11.7445 4.66985 13.0484 4.6938 14.2743 5.06256C15.232 5.35654 16.1078 5.87019 16.8318 6.56256C17.5606 5.83756 18.2881 5.11068 19.0143 4.38193C19.3893 3.99006 19.7981 3.61693 20.1674 3.21568C19.0622 2.1872 17.765 1.38691 16.3499 0.860682C13.7731 -0.0749615 10.9536 -0.100106 8.36055 0.789432Z" fill="white"/>
<path d="M8.3607 0.789367C10.9536 -0.100776 13.7731 -0.0762934 16.3501 0.858742C17.7654 1.38855 19.062 2.19269 20.1657 3.22499C19.7907 3.62624 19.3951 4.00124 19.0126 4.39124C18.2851 5.11749 17.5582 5.84124 16.832 6.56249C16.1079 5.87012 15.2321 5.35648 14.2745 5.06249C13.0489 4.69244 11.7451 4.66711 10.5061 4.98926C9.26712 5.31141 8.14079 5.96861 7.2507 6.88874C6.47377 7.67912 5.88538 8.63477 5.52945 9.68437L1.64258 6.67499C3.03384 3.91604 5.44273 1.80566 8.3607 0.789367Z" fill="#E33629"/>
<path d="M0.611401 9.65654C0.820316 8.62116 1.16716 7.61847 1.64265 6.67529L5.52953 9.69217C5.02105 11.1896 5.02105 12.8129 5.52953 14.3103C4.23453 15.3103 2.9389 16.3153 1.64265 17.3253C0.452308 14.9559 0.0892746 12.2562 0.611401 9.65654Z" fill="#F8BD00"/>
<path d="M12.2381 9.77783H23.3981C23.7799 11.8721 23.6766 14.026 23.0963 16.0741C22.5623 17.9575 21.5252 19.6597 20.0963 20.9978C18.8419 20.0191 17.5819 19.0478 16.3275 18.0691C16.9494 17.6496 17.4802 17.1089 17.8881 16.4793C18.296 15.8498 18.5726 15.1444 18.7013 14.4053H12.2381C12.2363 12.8641 12.2381 11.321 12.2381 9.77783Z" fill="#587DBD"/>
<path d="M1.64062 17.3251C2.93687 16.3251 4.2325 15.3201 5.5275 14.3101C6.02851 15.7804 6.98138 17.0549 8.25 17.9513C9.04126 18.5106 9.94037 18.8988 10.89 19.0913C11.8257 19.2653 12.7855 19.2653 13.7212 19.0913C14.6542 18.9334 15.5439 18.5821 16.3331 18.0601C17.5875 19.0388 18.8475 20.0101 20.1019 20.9888C18.7292 22.237 17.0558 23.1073 15.2456 23.5144C13.2476 23.9841 11.1655 23.9616 9.17812 23.4488C7.60632 23.0291 6.13814 22.2893 4.86562 21.2757C3.51874 20.2063 2.41867 18.8588 1.64062 17.3251Z" fill="#319F43"/>
</g>
<defs>
<clipPath id="clip0_2781_85129">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

-6
View File
@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" fill="none">
<path d="M0 0h10.5v10.5H0V0z" fill="#F25022"/>
<path d="M12.5 0H23v10.5H12.5V0z" fill="#7FBA00"/>
<path d="M0 12.5h10.5V23H0V12.5z" fill="#00A4EF"/>
<path d="M12.5 12.5H23V23H12.5V12.5z" fill="#FFB900"/>
</svg>

Before

Width:  |  Height:  |  Size: 292 B

File diff suppressed because it is too large Load Diff
+81 -308
View File
@@ -87,10 +87,7 @@
"showStack": "Stack-Trace anzeigen",
"copyStack": "Stack-Trace kopieren",
"githubSubmit": "GitHub - Ein Ticket einreichen",
"discordSubmit": "Discord - Unterstützungsbeitrag einreichen",
"dismissAllErrors": "Alle Fehler ausblenden",
"encryptedPdfMustRemovePassword": "Diese PDF ist verschlüsselt oder passwortgeschützt. Bitte entsperren Sie sie, bevor Sie in PDF/A konvertieren.",
"incorrectPasswordProvided": "Das PDF-Passwort ist falsch oder wurde nicht angegeben."
"discordSubmit": "Discord - Unterstützungsbeitrag einreichen"
},
"warning": {
"tooltipTitle": "Warnung"
@@ -361,223 +358,179 @@
"sortBy": "Sortieren nach:",
"multiTool": {
"title": "PDF-Multitool",
"desc": "Seiten zusammenführen, drehen, neu anordnen und entfernen",
"tags": "mehrere,werkzeuge"
"desc": "Seiten zusammenführen, drehen, neu anordnen und entfernen"
},
"merge": {
"title": "Zusammenführen",
"desc": "Mehrere PDF-Dateien zu einer einzigen zusammenführen",
"tags": "kombinieren,zusammenführen,vereinen"
"desc": "Mehrere PDF-Dateien zu einer einzigen zusammenführen"
},
"split": {
"title": "Aufteilen",
"desc": "PDFs in mehrere Dokumente aufteilen",
"tags": "teilen,trennen,aufteilen"
"desc": "PDFs in mehrere Dokumente aufteilen"
},
"rotate": {
"title": "Drehen",
"desc": "Drehen Sie Ihre PDFs ganz einfach",
"tags": "drehen,spiegeln,ausrichten"
"desc": "Drehen Sie Ihre PDFs ganz einfach"
},
"convert": {
"title": "Umwandeln",
"desc": "Dateien zwischen verschiedenen Formaten konvertieren",
"tags": "umwandeln,ändern"
"desc": "Dateien zwischen verschiedenen Formaten konvertieren"
},
"pdfOrganiser": {
"title": "Organisieren",
"desc": "Seiten entfernen und Seitenreihenfolge ändern",
"tags": "organisieren,umordnen,neu anordnen"
"desc": "Seiten entfernen und Seitenreihenfolge ändern"
},
"addImage": {
"title": "Bild einfügen",
"desc": "Fügt ein Bild an eine bestimmte Stelle im PDF ein (in Arbeit)",
"tags": "einfügen,einbetten,platzieren"
"desc": "Fügt ein Bild an eine bestimmte Stelle im PDF ein (in Arbeit)"
},
"addAttachments": {
"title": "Anhänge hinzufügen",
"desc": "Eingebettete Dateien (Anhänge) zu einer PDF hinzufügen oder entfernen",
"tags": "einbetten,anhängen,einfügen"
"desc": "Eingebettete Dateien (Anhänge) zu einer PDF hinzufügen oder entfernen"
},
"watermark": {
"title": "Wasserzeichen hinzufügen",
"desc": "Fügen Sie ein eigenes Wasserzeichen zu Ihrem PDF hinzu",
"tags": "stempel,markierung,überlagerung"
"desc": "Fügen Sie ein eigenes Wasserzeichen zu Ihrem PDF hinzu"
},
"removePassword": {
"title": "Passwort entfernen",
"desc": "Den Passwortschutz eines PDFs entfernen",
"tags": "entsperren"
"desc": "Den Passwortschutz eines PDFs entfernen"
},
"compress": {
"title": "Komprimieren",
"desc": "PDF komprimieren um die Dateigröße zu reduzieren",
"tags": "verkleinern,reduzieren,optimieren"
"desc": "PDF komprimieren um die Dateigröße zu reduzieren"
},
"unlockPDFForms": {
"title": "Schreibgeschützte PDF-Formfelder entfernen",
"desc": "Entfernen Sie die schreibgeschützte Eigenschaft von Formularfeldern in einem PDF-Dokument.",
"tags": "entsperren,aktivieren,bearbeiten"
"desc": "Entfernen Sie die schreibgeschützte Eigenschaft von Formularfeldern in einem PDF-Dokument."
},
"changeMetadata": {
"title": "Metadaten ändern",
"desc": "Ändern/Entfernen/Hinzufügen von Metadaten aus einem PDF-Dokument",
"tags": "bearbeiten,ändern,aktualisieren"
"desc": "Ändern/Entfernen/Hinzufügen von Metadaten aus einem PDF-Dokument"
},
"ocr": {
"title": "Führe OCR/Cleanup-Scans aus",
"desc": "Cleanup scannt und erkennt Text aus Bildern in einer PDF-Datei und fügt ihn erneut als Text hinzu",
"tags": "extrahieren,scannen"
"desc": "Cleanup scannt und erkennt Text aus Bildern in einer PDF-Datei und fügt ihn erneut als Text hinzu"
},
"extractImages": {
"title": "Bilder extrahieren",
"desc": "Extrahiert alle Bilder aus einer PDF-Datei und speichert sie als Zip-Archiv",
"tags": "extrahieren,speichern,exportieren"
"desc": "Extrahiert alle Bilder aus einer PDF-Datei und speichert sie als Zip-Archiv"
},
"scannerImageSplit": {
"title": "Gescannte Fotos erkennen/aufteilen",
"desc": "Teilt mehrere Fotos aus einem Foto/PDF auf",
"tags": "erkennen,teilen,fotos"
"desc": "Teilt mehrere Fotos aus einem Foto/PDF auf"
},
"sign": {
"title": "Signieren",
"desc": "Fügt PDF-Signaturen durch Zeichnung, Text oder Bild hinzu",
"tags": "unterschrift,autogramm"
"desc": "Fügt PDF-Signaturen durch Zeichnung, Text oder Bild hinzu"
},
"flatten": {
"title": "Abflachen",
"desc": "Alle interaktiven Elemente und Formulare aus einem PDF entfernen",
"tags": "vereinfachen,entfernen,interaktiv"
"desc": "Alle interaktiven Elemente und Formulare aus einem PDF entfernen"
},
"certSign": {
"title": "Mit Zertifikat signieren",
"desc": "Ein PDF mit einem Zertifikat/Schlüssel (PEM/P12) signieren",
"tags": "authentifizieren,PEM,P12,offiziell,verschlüsseln,signieren,zertifikat,PKCS12,JKS,server,manuell,auto"
"desc": "Ein PDF mit einem Zertifikat/Schlüssel (PEM/P12) signieren"
},
"repair": {
"title": "Reparatur",
"desc": "Versucht, ein beschädigtes/kaputtes PDF zu reparieren",
"tags": "reparieren,wiederherstellen"
"desc": "Versucht, ein beschädigtes/kaputtes PDF zu reparieren"
},
"removeBlanks": {
"title": "Leere Seiten entfernen",
"desc": "Erkennt und entfernt leere Seiten aus einem Dokument",
"tags": "löschen,bereinigen,leer"
"desc": "Erkennt und entfernt leere Seiten aus einem Dokument"
},
"removeAnnotations": {
"title": "Anmerkungen entfernen",
"desc": "Entfernt alle Kommentare/Anmerkungen aus einem PDF",
"tags": "löschen,bereinigen,entfernen"
"desc": "Entfernt alle Kommentare/Anmerkungen aus einem PDF"
},
"compare": {
"title": "Vergleichen",
"desc": "Vergleicht und zeigt die Unterschiede zwischen zwei PDF-Dokumenten an",
"tags": "unterschied"
"desc": "Vergleicht und zeigt die Unterschiede zwischen zwei PDF-Dokumenten an"
},
"removeCertSign": {
"title": "Zertifikatsignatur entfernen",
"desc": "Zertifikatsignatur aus PDF entfernen",
"tags": "entfernen,löschen,entsperren"
"desc": "Zertifikatsignatur aus PDF entfernen"
},
"pageLayout": {
"title": "Mehrseitiges Layout",
"desc": "Mehrere Seiten eines PDF zu einer Seite zusammenführen",
"tags": "layout,anordnen,kombinieren"
"desc": "Mehrere Seiten eines PDF zu einer Seite zusammenführen"
},
"bookletImposition": {
"title": "Broschüren-Layout",
"desc": "Broschüren mit korrekter Seitenreihenfolge und mehrseitigem Layout für Druck und Bindung erstellen",
"tags": "broschüre,druck,bindung"
"desc": "Broschüren mit korrekter Seitenreihenfolge und mehrseitigem Layout für Druck und Bindung erstellen"
},
"scalePages": {
"title": "Seitengröße/Skalierung anpassen",
"desc": "Größe/Skalierung der Seite und/oder des Inhalts ändern",
"tags": "größe ändern,anpassen,skalieren"
"desc": "Größe/Skalierung der Seite und/oder des Inhalts ändern"
},
"addPageNumbers": {
"title": "Seitenzahlen hinzufügen",
"desc": "Hinzufügen von Seitenzahlen an einer bestimmten Stelle",
"tags": "nummerieren,paginierung,zählen"
"desc": "Hinzufügen von Seitenzahlen an einer bestimmten Stelle"
},
"autoRename": {
"title": "PDF-Datei automatisch umbenennen",
"desc": "Benennt eine PDF-Datei automatisch basierend auf der erkannten Überschrift um",
"tags": "auto-erkennung,kopfzeilen-basiert,organisieren,umbenennen"
"desc": "Benennt eine PDF-Datei automatisch basierend auf der erkannten Überschrift um"
},
"adjustContrast": {
"title": "Farben/Kontrast anpassen",
"desc": "Kontrast, Sättigung und Helligkeit einer PDF anpassen",
"tags": "kontrast,helligkeit,sättigung"
"desc": "Kontrast, Sättigung und Helligkeit einer PDF anpassen"
},
"crop": {
"title": "PDF zuschneiden",
"desc": "PDF zuschneiden um die Größe zu verändern (Text bleibt erhalten!)",
"tags": "zuschneiden,schneiden,größe ändern"
"desc": "PDF zuschneiden um die Größe zu verändern (Text bleibt erhalten!)"
},
"autoSplitPDF": {
"title": "PDF automatisch teilen",
"desc": "Physisch gescannte PDF anhand von Splitter-Seiten und QR-Codes aufteilen",
"tags": "auto,teilen,QR"
"desc": "Physisch gescannte PDF anhand von Splitter-Seiten und QR-Codes aufteilen"
},
"sanitize": {
"title": "Bereinigen",
"desc": "Potentiell schädliche Elemente aus PDF-Dateien entfernen",
"tags": "bereinigen,löschen,entfernen"
"desc": "Potentiell schädliche Elemente aus PDF-Dateien entfernen"
},
"getPdfInfo": {
"title": "Alle Informationen anzeigen",
"desc": "Erfasst alle möglichen Informationen in einer PDF",
"tags": "info,metadaten,details"
"desc": "Erfasst alle möglichen Informationen in einer PDF"
},
"pdfToSinglePage": {
"title": "PDF zu einer Seite zusammenfassen",
"desc": "Fügt alle PDF-Seiten zu einer einzigen großen Seite zusammen",
"tags": "kombinieren,zusammenführen,einzeln"
"desc": "Fügt alle PDF-Seiten zu einer einzigen großen Seite zusammen"
},
"showJS": {
"title": "Javascript anzeigen",
"desc": "Alle Javascript Funktionen in einer PDF anzeigen",
"tags": "javascript,code,skript"
"desc": "Alle Javascript Funktionen in einer PDF anzeigen"
},
"redact": {
"title": "Manuell zensieren/schwärzen",
"desc": "Zensiere (Schwärze) eine PDF-Datei durch Auswählen von Text, gezeichneten Formen und/oder ausgewählten Seite(n)",
"tags": "zensieren,schwärzen,verbergen"
"desc": "Zensiere (Schwärze) eine PDF-Datei durch Auswählen von Text, gezeichneten Formen und/oder ausgewählten Seite(n)"
},
"overlayPdfs": {
"title": "PDFs überlagern",
"desc": "PDFs über eine andere PDF überlagern",
"tags": "überlagern,kombinieren,stapeln"
"desc": "PDFs über eine andere PDF überlagern"
},
"splitBySections": {
"title": "PDF nach Abschnitten aufteilen",
"desc": "Jede Seite einer PDF in kleinere horizontale und vertikale Abschnitte unterteilen",
"tags": "teilen,abschnitte,aufteilen"
"desc": "Jede Seite einer PDF in kleinere horizontale und vertikale Abschnitte unterteilen"
},
"addStamp": {
"title": "Stempel zu PDF hinzufügen",
"desc": "Text- oder Bildstempel an festgelegten Positionen hinzufügen",
"tags": "stempel,markierung,siegel"
"desc": "Text- oder Bildstempel an festgelegten Positionen hinzufügen"
},
"removeImage": {
"title": "Bild entfernen",
"desc": "Bild aus PDF entfernen, um die Dateigröße zu verringern",
"tags": "entfernen,löschen,bereinigen"
"desc": "Bild aus PDF entfernen, um die Dateigröße zu verringern"
},
"splitByChapters": {
"title": "PDF-Datei nach Kapiteln aufteilen",
"desc": "Aufteilung einer PDF-Datei in mehrere Dateien auf Basis der Kapitelstruktur.",
"tags": "teilen,kapitel,struktur"
"desc": "Aufteilung einer PDF-Datei in mehrere Dateien auf Basis der Kapitelstruktur."
},
"validateSignature": {
"title": "PDF-Signatur überprüfen",
"desc": "Digitale Signaturen und Zertifikate in PDF-Dokumenten überprüfen",
"tags": "validieren,überprüfen,zertifikat"
"desc": "Digitale Signaturen und Zertifikate in PDF-Dokumenten überprüfen"
},
"swagger": {
"title": "API-Dokumentation",
"desc": "API-Dokumentation anzeigen und Endpunkte testen",
"tags": "API,dokumentation,test"
"desc": "API-Dokumentation anzeigen und Endpunkte testen"
},
"fakeScan": {
"title": "Scan simulieren",
@@ -585,52 +538,42 @@
},
"editTableOfContents": {
"title": "Inhaltsverzeichnis bearbeiten",
"desc": "Hinzufügen oder Bearbeiten von Lesezeichen und Inhaltsverzeichnissen in PDF-Dokumenten",
"tags": "lesezeichen,inhalt,bearbeiten"
"desc": "Hinzufügen oder Bearbeiten von Lesezeichen und Inhaltsverzeichnissen in PDF-Dokumenten"
},
"manageCertificates": {
"title": "Zertifikate verwalten",
"desc": "Digitale Zertifikatsdateien für die PDF-Signierung importieren, exportieren oder löschen.",
"tags": "zertifikate,importieren,exportieren"
"desc": "Digitale Zertifikatsdateien für die PDF-Signierung importieren, exportieren oder löschen."
},
"read": {
"title": "Lesen",
"desc": "PDFs anzeigen und kommentieren. Text hervorheben, zeichnen oder Kommentare für Überprüfung und Zusammenarbeit einfügen.",
"tags": "anzeigen,öffnen,anzeigen"
"desc": "PDFs anzeigen und kommentieren. Text hervorheben, zeichnen oder Kommentare für Überprüfung und Zusammenarbeit einfügen."
},
"reorganizePages": {
"title": "Seiten neu anordnen",
"desc": "PDF-Seiten mit visueller Drag-and-Drop-Steuerung neu anordnen, duplizieren oder löschen.",
"tags": "umordnen,neu anordnen,organisieren"
"desc": "PDF-Seiten mit visueller Drag-and-Drop-Steuerung neu anordnen, duplizieren oder löschen."
},
"extractPages": {
"title": "Seiten extrahieren",
"desc": "Spezifische Seiten aus einem PDF-Dokument extrahieren",
"tags": "extrahieren,auswählen,kopieren"
"desc": "Spezifische Seiten aus einem PDF-Dokument extrahieren"
},
"removePages": {
"title": "Entfernen",
"desc": "Ungewollte Seiten aus dem PDF entfernen",
"tags": "löschen,extrahieren,ausschließen"
"desc": "Ungewollte Seiten aus dem PDF entfernen"
},
"autoSizeSplitPDF": {
"title": "Teilen nach Größe/Anzahl",
"desc": "Teilen Sie ein einzelnes PDF basierend auf Größe, Seitenanzahl oder Dokumentanzahl in mehrere Dokumente auf",
"tags": "auto,teilen,größe"
"desc": "Teilen Sie ein einzelnes PDF basierend auf Größe, Seitenanzahl oder Dokumentanzahl in mehrere Dokumente auf"
},
"replaceColorPdf": {
"title": "Farbe ersetzen und invertieren",
"desc": "Ersetzen Sie die Farbe des Texts und Hintergrund der PDF-Datei und invertieren Sie die komplette Farbe der PDF-Datei, um die Dateigröße zu reduzieren"
},
"devApi": {
"desc": "Link zur API-Dokumentation",
"tags": "API,entwicklung,dokumentation",
"title": "API"
"desc": "Link zur API-Dokumentation"
},
"devFolderScanning": {
"title": "Automatische Ordnerüberwachung",
"desc": "Link zum Leitfaden für automatisches Ordner-Scannen",
"tags": "automatisierung,ordner,scannen"
"desc": "Link zum Leitfaden für automatisches Ordner-Scannen"
},
"devSsoGuide": {
"title": "SSO-Anleitung",
@@ -650,17 +593,7 @@
},
"automate": {
"title": "Automatisieren",
"desc": "Mehrstufige Arbeitsabläufe durch Verkettung von PDF-Aktionen erstellen. Ideal für wiederkehrende Aufgaben.",
"tags": "arbeitsablauf,sequenz,automatisierung"
},
"replaceColor": {
"desc": "Farben in PDF-Dokumenten ersetzen oder invertieren",
"title": "Farbe ersetzen & invertieren"
},
"scannerEffect": {
"desc": "Erstellen Sie eine PDF, die aussieht, als wäre sie gescannt worden",
"tags": "scannen,simulieren,erstellen",
"title": "Scanner-Effekt"
"desc": "Mehrstufige Arbeitsabläufe durch Verkettung von PDF-Aktionen erstellen. Ideal für wiederkehrende Aufgaben."
}
},
"landing": {
@@ -700,18 +633,8 @@
"merge": {
"tags": "zusammenführen,seitenvorgänge,back end,serverseitig",
"title": "Zusammenführen",
"removeDigitalSignature": {
"tooltip": {
"description": "Digitale Signaturen werden beim Zusammenführen von Dateien ungültig. Aktivieren Sie diese Option, um sie aus der endgültigen zusammengeführten PDF zu entfernen.",
"title": "Digitale Signatur entfernen"
}
},
"generateTableOfContents": {
"tooltip": {
"description": "Erstellt automatisch ein klickbares Inhaltsverzeichnis in der zusammengeführten PDF basierend auf den ursprünglichen Dateinamen und Seitenzahlen.",
"title": "Inhaltsverzeichnis generieren"
}
},
"removeDigitalSignature": "Digitale Signatur in der zusammengeführten Datei entfernen?",
"generateTableOfContents": "Inhaltsverzeichnis in der zusammengeführten Datei erstellen?",
"submit": "Zusammenführen",
"sortBy": {
"description": "Dateien werden in der Reihenfolge zusammengeführt, in der sie ausgewählt wurden. Ziehen Sie zum Neuordnen oder sortieren Sie unten.",
@@ -937,13 +860,7 @@
"images": "Bilder",
"officeDocs": "Office-Dokumente (Word, Excel, PowerPoint)",
"imagesExt": "Bilder (JPG, PNG, usw.)",
"grayscale": "Graustufen",
"dpi": "DPI",
"markdown": "Markdown",
"odtExt": "OpenDocument Text (.odt)",
"pptExt": "PowerPoint (.pptx)",
"rtfExt": "Rich Text Format (.rtf)",
"textRtf": "Text/RTF"
"grayscale": "Graustufen"
},
"imageToPdf": {
"tags": "konvertierung,img,jpg,bild,foto"
@@ -983,20 +900,7 @@
"10": "Ungerade-Gerade-Zusammenführung",
"11": "Alle Seiten duplizieren"
},
"placeholder": "(z.B. 1,3,2 oder 4-8,2,10-12 oder 2n-1)",
"desc": {
"BOOKLET_SORT": "Seiten für den Broschüren-Druck anordnen (letzte, erste, zweite, vorletzte, …).",
"CUSTOM": "Verwenden Sie eine benutzerdefinierte Sequenz von Seitenzahlen oder Ausdrücken, um eine neue Reihenfolge zu definieren.",
"DUPLEX_SORT": "Vorder- und Rückseiten verschachteln, als ob ein Duplex-Scanner alle Vorderseiten und dann alle Rückseiten gescannt hätte (1, n, 2, n-1, …).",
"DUPLICATE": "Jede Seite entsprechend der benutzerdefinierten Anzahl duplizieren (z.B. 4 dupliziert jede Seite 4×).",
"ODD_EVEN_MERGE": "Zwei PDFs durch abwechselnde Seiten zusammenführen: ungerade aus der ersten, gerade aus der zweiten.",
"ODD_EVEN_SPLIT": "Das Dokument in zwei Ausgaben aufteilen: alle ungeraden Seiten und alle geraden Seiten.",
"REMOVE_FIRST": "Die erste Seite aus dem Dokument entfernen.",
"REMOVE_FIRST_AND_LAST": "Sowohl die erste als auch die letzte Seite aus dem Dokument entfernen.",
"REMOVE_LAST": "Die letzte Seite aus dem Dokument entfernen.",
"REVERSE_ORDER": "Das Dokument umkehren, sodass die letzte Seite zur ersten wird usw.",
"SIDE_STITCH_BOOKLET_SORT": "Seiten für den Seitenheft-Broschüren-Druck anordnen (optimiert für die Bindung an der Seite)."
}
"placeholder": "(z.B. 1,3,2 oder 4-8,2,10-12 oder 2n-1)"
},
"addImage": {
"tags": "img,jpg,bild,foto",
@@ -1025,8 +929,7 @@
"failed": "Ein Fehler ist beim Hinzufügen des Wasserzeichens zur PDF aufgetreten."
},
"watermarkType": {
"image": "Bild",
"text": "Text"
"image": "Bild"
},
"settings": {
"type": "Wasserzeichen-Typ",
@@ -1430,9 +1333,7 @@
},
"trapped": {
"label": "Trapped-Status",
"unknown": "Unbekannt",
"false": "Falsch",
"true": "Wahr"
"unknown": "Unbekannt"
},
"advanced": {
"title": "Erweiterte Optionen"
@@ -1621,13 +1522,7 @@
"header": "Bilder extrahieren",
"selectText": "Wählen Sie das Bildformat aus, in das extrahierte Bilder konvertiert werden sollen",
"allowDuplicates": "Doppelte Bilder speichern",
"submit": "Extrahieren",
"error": {
"failed": "Beim Extrahieren der Bilder aus der PDF ist ein Fehler aufgetreten."
},
"settings": {
"title": "Einstellungen"
}
"submit": "Extrahieren"
},
"pdfToPDFA": {
"tags": "archiv,langfristig,standard,konvertierung,speicherung,aufbewahrung",
@@ -1704,14 +1599,8 @@
"title": "Signieren",
"header": "PDFs signieren",
"upload": "Bild hochladen",
"draw": {
"clear": "Löschen",
"title": "Zeichnen Sie Ihre Unterschrift"
},
"text": {
"name": "Name des Unterzeichners",
"placeholder": "Geben Sie Ihren vollständigen Namen ein"
},
"draw": "Signatur zeichnen",
"text": "Texteingabe",
"clear": "Leeren",
"add": "Signieren",
"saved": "Gespeicherte Signaturen",
@@ -1727,35 +1616,7 @@
"previous": "Vorherige Seite",
"maintainRatio": "Seitenverhältnis beibehalten ein-/ausschalten",
"undo": "Rückgängig",
"redo": "Wiederherstellen",
"activate": "Signatur-Platzierung aktivieren",
"applySignatures": "Signaturen anwenden",
"deactivate": "Signatur-Platzierung beenden",
"error": {
"failed": "Beim Signieren der PDF ist ein Fehler aufgetreten."
},
"image": {
"hint": "Laden Sie ein PNG- oder JPG-Bild Ihrer Unterschrift hoch",
"label": "Unterschriftsbild hochladen",
"placeholder": "Bilddatei auswählen"
},
"instructions": {
"title": "So fügen Sie eine Unterschrift hinzu"
},
"results": {
"title": "Signatur-Ergebnisse"
},
"steps": {
"configure": "Signatur konfigurieren"
},
"submit": "Dokument signieren",
"type": {
"canvas": "Canvas",
"draw": "Zeichnen",
"image": "Bild",
"text": "Text",
"title": "Signaturtyp"
}
"redo": "Wiederherstellen"
},
"flatten": {
"tags": "statisch,deaktivieren,nicht interaktiv,optimieren",
@@ -1774,8 +1635,7 @@
"stepTitle": "Abflachungs-Optionen",
"title": "Abflachungs-Optionen",
"flattenOnlyForms.desc": "Nur Formularfelder vereinfachen, andere interaktive Elemente unverändert lassen",
"note": "Das Abflachen entfernt interaktive Elemente aus der PDF und macht sie nicht mehr bearbeitbar.",
"flattenOnlyForms": "Nur Formulare vereinfachen"
"note": "Das Abflachen entfernt interaktive Elemente aus der PDF und macht sie nicht mehr bearbeitbar."
},
"results": {
"title": "Reduzierungs-Ergebnisse"
@@ -1833,8 +1693,7 @@
"label": "Pixel-Weißheitsschwellwert"
},
"whitePercent": {
"label": "Weiß-Prozentsatz-Schwellwert",
"unit": "%"
"label": "Weiß-Prozentsatz-Schwellwert"
},
"includeBlankPages": {
"label": "Erkannte leere Seiten einschließen"
@@ -1871,17 +1730,7 @@
"tags": "kommentare,hervorheben,notizen,markieren,entfernen",
"title": "Kommentare entfernen",
"header": "Kommentare entfernen",
"submit": "Entfernen",
"error": {
"failed": "Beim Entfernen der Anmerkungen aus der PDF ist ein Fehler aufgetreten."
},
"info": {
"description": "Dieses Werkzeug entfernt alle Anmerkungen (Kommentare, Hervorhebungen, Notizen usw.) aus Ihren PDF-Dokumenten.",
"title": "Über Anmerkungen entfernen"
},
"settings": {
"title": "Einstellungen"
}
"submit": "Entfernen"
},
"compare": {
"tags": "differenzieren,kontrastieren,verändern,analysieren",
@@ -2166,9 +2015,7 @@
},
"pageSize": {
"label": "Ziel-Seitengröße",
"keep": "Ursprüngliche Größe beibehalten",
"legal": "Legal",
"letter": "Letter"
"keep": "Ursprüngliche Größe beibehalten"
},
"submit": "Seitenskalierung anpassen",
"error": {
@@ -2459,8 +2306,7 @@
"showLayers": "Ebenen anzeigen (Doppelklick, um alle Ebenen auf den Standardzustand zurückzusetzen)",
"colourPicker": "Farbwähler",
"findCurrentOutlineItem": "Aktuelles Gliederungselement finden",
"applyChanges": "Änderungen anwenden",
"zoom": "Zoom"
"applyChanges": "Änderungen anwenden"
}
},
"tableExtraxt": {
@@ -2650,8 +2496,7 @@
"magicLinkSent": "Magic Link wurde an {{email}} gesendet! Prüfen Sie Ihre E-Mails und klicken Sie auf den Link zur Anmeldung.",
"passwordResetSent": "Passwort-Reset-Link wurde an {{email}} gesendet! Prüfen Sie Ihre E-Mails und folgen Sie den Anweisungen.",
"failedToSignIn": "Anmeldung mit {{provider}} fehlgeschlagen: {{message}}",
"unexpectedError": "Unerwarteter Fehler: {{message}}",
"debug": "Debug"
"unexpectedError": "Unerwarteter Fehler: {{message}}"
},
"signup": {
"title": "Konto erstellen",
@@ -2673,8 +2518,7 @@
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"checkEmailConfirmation": "Prüfen Sie Ihre E-Mails auf einen Bestätigungslink, um die Registrierung abzuschließen.",
"accountCreatedSuccessfully": "Konto erfolgreich erstellt! Sie können sich jetzt anmelden.",
"unexpectedError": "Unerwarteter Fehler: {{message}}",
"name": "Name"
"unexpectedError": "Unerwarteter Fehler: {{message}}"
},
"pdfToSinglePage": {
"title": "PDF zu einer Seite zusammenfassen",
@@ -3117,12 +2961,7 @@
"selectedCount": "{{count}} ausgewählt",
"download": "Herunterladen",
"delete": "Löschen",
"unsupported": "Nicht unterstützt",
"fileFormat": "Format",
"fileName": "Name",
"fileVersion": "Version",
"googleDrive": "Google Drive",
"googleDriveShort": "Drive"
"unsupported": "Nicht unterstützt"
},
"storage": {
"temporaryNotice": "Dateien werden temporär in Ihrem Browser gespeichert und können automatisch gelöscht werden",
@@ -3153,24 +2992,12 @@
"options": {
"title": "Bereinigungs-Optionen",
"note": "Wählen Sie die Elemente aus, die Sie aus der PDF entfernen möchten. Mindestens eine Option muss ausgewählt werden.",
"removeJavaScript": {
"desc": "JavaScript-Aktionen und Skripte aus der PDF entfernen"
},
"removeEmbeddedFiles": {
"desc": "Alle in der PDF eingebetteten Dateien entfernen"
},
"removeXMPMetadata": {
"desc": "XMP-Metadaten aus der PDF entfernen"
},
"removeMetadata": {
"desc": "Dokumentinformations-Metadaten (Titel, Autor usw.) entfernen"
},
"removeLinks": {
"desc": "Externe Links und Launch-Aktionen aus der PDF entfernen"
},
"removeFonts": {
"desc": "Eingebettete Schriftarten aus der PDF entfernen"
}
"removeJavaScript": "JavaScript entfernen",
"removeEmbeddedFiles": "Eingebettete Dateien entfernen",
"removeXMPMetadata": "XMP-Metadaten entfernen",
"removeMetadata": "Dokument-Metadaten entfernen",
"removeLinks": "Links entfernen",
"removeFonts": "Schriftarten entfernen"
}
},
"addPassword": {
@@ -3198,8 +3025,7 @@
"keyLength": {
"label": "Verschlüsselungsschlüssellänge",
"40bit": "40-bit (Niedrig)",
"256bit": "256-bit (Hoch)",
"128bit": "128-bit (Standard)"
"256bit": "256-bit (Hoch)"
}
},
"results": {
@@ -3438,58 +3264,5 @@
},
"generateError": "Wir konnten Ihren API-Schlüssel nicht generieren."
}
},
"AddAttachmentsRequest": {
"addMoreFiles": "Weitere Dateien hinzufügen...",
"attachments": "Anhänge auswählen",
"info": "Wählen Sie Dateien aus, die Sie Ihrer PDF anhängen möchten. Diese Dateien werden eingebettet und über das Anhangs-Panel der PDF zugänglich sein.",
"placeholder": "Dateien auswählen...",
"results": {
"title": "Anhangs-Ergebnisse"
},
"selectFiles": "Dateien zum Anhängen auswählen",
"selectedFiles": "Ausgewählte Dateien",
"submit": "Anhänge hinzufügen"
},
"applyAndContinue": "Anwenden & Fortfahren",
"discardChanges": "Änderungen verwerfen",
"exportAndContinue": "Exportieren & Fortfahren",
"keepWorking": "Weiterarbeiten",
"logOut": "Abmelden",
"replaceColor": {
"tags": "Farbe ersetzen,Seitenoperationen,Backend,serverseitig"
},
"scannerImageSplit": {
"error": {
"failed": "Beim Extrahieren der Bild-Scans ist ein Fehler aufgetreten."
},
"submit": "Bild-Scans extrahieren",
"title": "Extrahierte Bilder",
"tooltip": {
"headsUp": "Hinweis",
"headsUpDesc": "Überlappende Fotos oder Hintergründe, die farblich sehr nah an den Fotos liegen, können die Genauigkeit verringern - versuchen Sie einen helleren oder dunkleren Hintergrund und lassen Sie mehr Platz.",
"problem1": "Fotos nicht erkannt → Toleranz auf 30-50 erhöhen",
"problem2": "Zu viele Falscherkennungen → Mindestfläche auf 15.000-20.000 erhöhen",
"problem3": "Zuschnitte sind zu eng → Randgröße auf 5-10 erhöhen",
"problem4": "Geneigte Fotos nicht begradigt → Winkelschwelle auf ~5° senken",
"problem5": "Staub-/Rausch-Boxen → Mindest-Konturfläche auf 1000-2000 erhöhen",
"quickFixes": "Schnelle Lösungen",
"setupTips": "Einrichtungstipps",
"tip1": "Verwenden Sie einen einfachen, hellen Hintergrund",
"tip2": "Lassen Sie einen kleinen Abstand (≈1 cm) zwischen den Fotos",
"tip3": "Scannen Sie mit 300-600 DPI",
"tip4": "Reinigen Sie die Scanner-Glasplatte",
"title": "Foto-Teiler",
"useCase1": "Ganze Album-Seiten in einem Durchgang scannen",
"useCase2": "Flachbett-Stapel in separate Dateien aufteilen",
"useCase3": "Collagen in einzelne Fotos aufteilen",
"useCase4": "Fotos aus Dokumenten extrahieren",
"whatThisDoes": "Was dies tut",
"whatThisDoesDesc": "Findet und extrahiert automatisch jedes Foto von einer gescannten Seite oder einem zusammengesetzten Bild - kein manuelles Zuschneiden erforderlich.",
"whenToUse": "Wann zu verwenden"
}
},
"termsAndConditions": "Allgemeine Geschäftsbedingungen",
"unsavedChanges": "Sie haben ungespeicherte Änderungen an Ihrer PDF. Was möchten Sie tun?",
"unsavedChangesTitle": "Ungespeicherte Änderungen"
}
}
File diff suppressed because it is too large Load Diff
+7 -74
View File
@@ -2,23 +2,6 @@
"language": {
"direction": "ltr"
},
"toolPanel": {
"modePrompt": {
"title": "Choose how you browse tools",
"description": "Preview both layouts and decide how you want to explore Stirling PDF tools.",
"sidebarTitle": "Sidebar mode",
"sidebarDescription": "Keep tools alongside your workspace for quick switching.",
"recommended": "Recommended",
"chooseSidebar": "Use sidebar mode",
"fullscreenTitle": "Fullscreen mode - (legacy)",
"fullscreenDescription": "Browse every tool in a catalogue that covers the workspace until you pick one.",
"chooseFullscreen": "Use fullscreen mode",
"dismiss": "Maybe later"
},
"fullscreen": {
"showDetails": "Show Details"
}
},
"addPageNumbers": {
"fontSize": "Font Size",
"fontName": "Font Name",
@@ -579,7 +562,7 @@
"adjustContrast": {
"tags": "contrast,brightness,saturation",
"title": "Adjust Colors/Contrast",
"desc": "Adjust Colors/Contrast, Saturation and Brightness of a PDF"
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
},
"crop": {
"tags": "trim,cut,resize",
@@ -1260,17 +1243,7 @@
"tags": "comments,highlight,notes,markup,remove",
"title": "Remove Annotations",
"header": "Remove Annotations",
"submit": "Remove",
"settings": {
"title": "Settings"
},
"info": {
"title": "About Remove Annotations",
"description": "This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents."
},
"error": {
"failed": "An error occurred while removing annotations from the PDF."
}
"submit": "Remove"
},
"compare": {
"tags": "differentiate,contrast,changes,analysis",
@@ -1538,15 +1511,11 @@
"overlay-pdfs": {
"tags": "Overlay",
"header": "Overlay PDF Files",
"title": "Overlay PDFs",
"desc": "Overlay one PDF on top of another",
"baseFile": {
"label": "Select Base PDF File"
},
"overlayFiles": {
"label": "Select Overlay PDF Files",
"placeholder": "Choose PDF(s)...",
"addMore": "Add more PDFs..."
"label": "Select Overlay PDF Files"
},
"mode": {
"label": "Select Overlay Mode",
@@ -1556,49 +1525,14 @@
},
"counts": {
"label": "Overlay Counts (for Fixed Repeat Mode)",
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)",
"item": "Count for file"
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)"
},
"position": {
"label": "Select Overlay Position",
"foreground": "Foreground",
"background": "Background"
},
"submit": "Submit",
"settings": {
"title": "Settings"
},
"results": {
"title": "Overlay Results"
},
"tooltip": {
"header": {
"title": "Overlay PDFs Overview"
},
"description": {
"title": "Description",
"text": "Combine a base PDF with one or more overlay PDFs. Overlays can be applied page-by-page in different modes and placed in the foreground or background."
},
"mode": {
"title": "Overlay Mode",
"text": "Choose how to distribute overlay pages across the base PDF pages.",
"sequential": "Sequential Overlay: Use pages from the first overlay PDF until it ends, then move to the next.",
"interleaved": "Interleaved Overlay: Take one page from each overlay in turn.",
"fixedRepeat": "Fixed Repeat Overlay: Take a set number of pages from each overlay before moving to the next. Use Counts to set the numbers."
},
"position": {
"title": "Overlay Position",
"text": "Foreground places the overlay on top of the page. Background places it behind."
},
"overlayFiles": {
"title": "Overlay Files",
"text": "Select one or more PDFs to overlay on the base. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes."
},
"counts": {
"title": "Counts (Fixed Repeat only)",
"text": "Provide a positive number for each overlay file showing how many pages to take before moving to the next. Required when mode is Fixed Repeat."
}
}
"submit": "Submit"
},
"split-by-sections": {
"tags": "Section Split, Divide, Customize",
@@ -1768,9 +1702,8 @@
"submit": "Sanitize PDF"
},
"adjustContrast": {
"title": "Adjust Colors/Contrast",
"header": "Adjust Colors/Contrast",
"basic": "Basic Adjustments",
"title": "Adjust Contrast",
"header": "Adjust Contrast",
"contrast": "Contrast:",
"brightness": "Brightness:",
"saturation": "Saturation:",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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