diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index bc5358b85..000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(chmod:*)", - "Bash(mkdir:*)", - "Bash(./gradlew:*)", - "Bash(grep:*)", - "Bash(cat:*)", - "Bash(find:*)", - "Bash(grep:*)", - "Bash(rg:*)", - "Bash(strings:*)", - "Bash(pkill:*)", - "Bash(true)" - ], - "deny": [] - } -} \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index 3f5158dea..5e5445769 100644 --- a/.editorconfig +++ b/.editorconfig @@ -24,7 +24,7 @@ indent_size = 2 insert_final_newline = false trim_trailing_whitespace = false -[*.js] +[{*.js,*.jsx,*.mjs,*.ts,*.tsx}] indent_size = 2 [*.css] diff --git a/.github/scripts/check_language_json.py b/.github/scripts/check_language_json.py new file mode 100644 index 000000000..3921bdaa5 --- /dev/null +++ b/.github/scripts/check_language_json.py @@ -0,0 +1,345 @@ +""" +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 --branch [--actor ] [--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) \ No newline at end of file diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml new file mode 100644 index 000000000..2cebfb650 --- /dev/null +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -0,0 +1,501 @@ +name: Auto PR V2 Deployment + +on: + pull_request: + types: [opened, synchronize, reopened, closed] + workflow_dispatch: + inputs: + pr: + description: "PR number to deploy" + required: true + allow_fork: + description: "Allow deploying fork PR?" + required: false + default: "false" + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + check-pr: + if: (github.event_name == 'pull_request' && github.event.action != 'closed') || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + outputs: + should_deploy: ${{ steps.decide.outputs.should_deploy }} + is_fork: ${{ steps.resolve.outputs.is_fork }} + allow_fork: ${{ steps.decide.outputs.allow_fork }} + pr_number: ${{ steps.resolve.outputs.pr_number }} + pr_repository: ${{ steps.resolve.outputs.repository }} + pr_ref: ${{ steps.resolve.outputs.ref }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + with: + egress-policy: audit + + - name: Resolve PR info + id: resolve + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const { owner, repo } = context.repo; + let prNumber = context.eventName === 'workflow_dispatch' + ? parseInt(process.env.INPUT_PR, 10) + : context.payload.number; + + if (!Number.isInteger(prNumber)) { core.setFailed('Invalid PR number'); return; } + + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber }); + core.setOutput('pr_number', String(prNumber)); + core.setOutput('repository', pr.head.repo.full_name); + core.setOutput('ref', pr.head.ref); + core.setOutput('is_fork', String(pr.head.repo.fork)); + core.setOutput('base_ref', pr.base.ref); + core.setOutput('author', pr.user.login); + core.setOutput('state', pr.state); + + - name: Decide deploy + id: decide + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + STATE: ${{ steps.resolve.outputs.state }} + IS_FORK: ${{ steps.resolve.outputs.is_fork }} + # nur bei workflow_dispatch gesetzt: + ALLOW_FORK_INPUT: ${{ inputs.allow_fork }} + # für Auto-PR-Logik: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_BASE: ${{ steps.resolve.outputs.base_ref }} + PR_AUTHOR: ${{ steps.resolve.outputs.author }} + run: | + set -e + # Standard: nichts deployen + should=false + allow_fork="$(echo "${ALLOW_FORK_INPUT:-false}" | tr '[:upper:]' '[:lower:]')" + + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + if [ "$STATE" != "open" ]; then + echo "PR not open -> skip" + else + if [ "$IS_FORK" = "true" ] && [ "$allow_fork" != "true" ]; then + echo "Fork PR and allow_fork=false -> skip" + else + should=true + fi + fi + else + auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96") + is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done + if [ "$PR_BASE" = "V2" ] && [ "$is_auth" = true ]; then + should=true + else + title_has_v2=false; echo "$PR_TITLE" | grep -qiE 'v2|version.?2|version.?two' && title_has_v2=true + branch_has_kw=false; echo "$PR_BRANCH" | grep -qiE 'v2|react' && branch_has_kw=true + if [ "$is_auth" = true ] && { [ "$title_has_v2" = true ] || [ "$branch_has_kw" = true ]; }; then + should=true + fi + fi + fi + + echo "should_deploy=$should" >> $GITHUB_OUTPUT + echo "allow_fork=${allow_fork:-false}" >> $GITHUB_OUTPUT + + deploy-v2-pr: + needs: check-pr + runs-on: ubuntu-latest + if: needs.check-pr.outputs.should_deploy == 'true' && (needs.check-pr.outputs.is_fork == 'false' || needs.check-pr.outputs.allow_fork == 'true') + # Concurrency control - only one deployment per PR at a time + concurrency: + group: v2-deploy-pr-${{ needs.check-pr.outputs.pr_number }} + cancel-in-progress: true + permissions: + contents: read + issues: write + pull-requests: write + + steps: + - name: Harden Runner + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + with: + egress-policy: audit + + - name: Checkout main repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: ${{ github.repository }} + ref: main + + - name: Setup GitHub App Bot + if: github.actor != 'dependabot[bot]' + id: setup-bot + uses: ./.github/actions/setup-bot + continue-on-error: true + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + - name: Add deployment started comment + id: deployment-started + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + github-token: ${{ steps.setup-bot.outputs.token }} + script: | + const { owner, repo } = context.repo; + const prNumber = ${{ needs.check-pr.outputs.pr_number }}; + + // Delete previous V2 deployment comments to avoid clutter + const { data: comments } = await github.rest.issues.listComments({ + owner, + repo, + issue_number: prNumber, + per_page: 100 + }); + + const v2Comments = comments.filter(comment => + comment.body.includes('🚀 **Auto-deploying V2 version**') || + comment.body.includes('## 🚀 V2 Auto-Deployment Complete!') || + comment.body.includes('❌ **V2 Auto-deployment failed**') + ); + + for (const comment of v2Comments) { + console.log(`Deleting old V2 comment: ${comment.id}`); + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: comment.id + }); + } + + // Create new deployment started comment + const { data: newComment } = await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment triggered by V2/version2 keywords in the PR title or V2/React keywords in the branch name._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.` + }); + return newComment.id; + + - name: Checkout PR + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: ${{ needs.check-pr.outputs.pr_repository }} + ref: ${{ needs.check-pr.outputs.pr_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 # Fetch full history for commit hash detection + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + + - name: Get version number + id: versionNumber + run: | + VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}') + echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT + + - name: Login to Docker Hub + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_API }} + + - name: Get commit hashes for frontend and backend + id: commit-hashes + run: | + # Get last commit that touched the frontend folder, docker/frontend, or docker/compose + FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "") + if [ -z "$FRONTEND_HASH" ]; then + FRONTEND_HASH="no-frontend-changes" + fi + + # Get last commit that touched backend code, docker/backend, or docker/compose + BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "") + if [ -z "$BACKEND_HASH" ]; then + BACKEND_HASH="no-backend-changes" + fi + + echo "Frontend hash: $FRONTEND_HASH" + echo "Backend hash: $BACKEND_HASH" + + echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT + echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT + + # Short hashes for tags + if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then + echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT + else + echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT + fi + + if [ "$BACKEND_HASH" = "no-backend-changes" ]; then + echo "backend_short=no-backend" >> $GITHUB_OUTPUT + else + echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT + fi + + - name: Check if frontend image exists + id: check-frontend + run: | + if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then + echo "exists=true" >> $GITHUB_OUTPUT + echo "Frontend image already exists, skipping build" + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "Frontend image needs to be built" + fi + + - name: Check if backend image exists + id: check-backend + run: | + if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then + echo "exists=true" >> $GITHUB_OUTPUT + echo "Backend image already exists, skipping build" + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "Backend image needs to be built" + fi + + - name: Build and push V2 frontend image + if: steps.check-frontend.outputs.exists == 'false' + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + context: . + file: ./docker/frontend/Dockerfile + push: true + tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} + build-args: VERSION_TAG=v2-alpha + platforms: linux/amd64 + + - name: Build and push V2 backend image + if: steps.check-backend.outputs.exists == 'false' + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + context: . + file: ./docker/backend/Dockerfile + push: true + tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} + build-args: VERSION_TAG=v2-alpha + platforms: linux/amd64 + + - name: Set up SSH + run: | + mkdir -p ~/.ssh/ + echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key + sudo chmod 600 ../private.key + + - name: Deploy V2 to VPS + id: deploy + run: | + # Use same port strategy as regular PRs - just the PR number + V2_PORT=${{ needs.check-pr.outputs.pr_number }} + BACKEND_PORT=$((V2_PORT + 10000)) # Backend on higher port to avoid conflicts + + # Create docker-compose for V2 with separate frontend and backend + cat > docker-compose.yml << EOF + version: '3.3' + services: + stirling-pdf-v2-backend: + container_name: stirling-pdf-v2-backend-pr-${{ needs.check-pr.outputs.pr_number }} + image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} + ports: + - "${BACKEND_PORT}:8080" # Backend API port + volumes: + - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/data:/usr/share/tessdata:rw + - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/config:/configs:rw + - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/logs:/logs:rw + environment: + DISABLE_ADDITIONAL_FEATURES: "true" + SECURITY_ENABLELOGIN: "false" + SYSTEM_DEFAULTLOCALE: en-GB + UI_APPNAME: "Stirling-PDF V2 PR#${{ needs.check-pr.outputs.pr_number }}" + UI_HOMEDESCRIPTION: "V2 PR#${{ needs.check-pr.outputs.pr_number }} - Frontend/Backend Split Architecture" + UI_APPNAMENAVBAR: "V2 PR#${{ needs.check-pr.outputs.pr_number }}" + SYSTEM_MAXFILESIZE: "100" + METRICS_ENABLED: "true" + SYSTEM_GOOGLEVISIBILITY: "false" + SWAGGER_SERVER_URL: "https://${V2_PORT}.ssl.stirlingpdf.cloud" + baseUrl: "https://${V2_PORT}.ssl.stirlingpdf.cloud" + restart: on-failure:5 + + stirling-pdf-v2-frontend: + container_name: stirling-pdf-v2-frontend-pr-${{ needs.check-pr.outputs.pr_number }} + image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} + ports: + - "${V2_PORT}:80" # Frontend port (same as regular PRs) + environment: + VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:${BACKEND_PORT}" + depends_on: + - stirling-pdf-v2-backend + restart: on-failure:5 + EOF + + # Deploy to VPS + scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose-v2.yml + + ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH + # Create V2 PR-specific directories + mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs} + + # Move docker-compose file to correct location + mv /tmp/docker-compose-v2.yml /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/docker-compose.yml + + # Stop any existing container and clean up + cd /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }} + docker-compose down --remove-orphans 2>/dev/null || true + + # Start the new container + docker-compose pull + docker-compose up -d + + # Clean up unused Docker resources to save space + docker system prune -af --volumes || true + + # Clean up old backend/frontend images (older than 2 weeks) + docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true + ENDSSH + + # Set port for output + echo "v2_port=${V2_PORT}" >> $GITHUB_OUTPUT + + - name: Post V2 deployment URL to PR + if: success() + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + github-token: ${{ steps.setup-bot.outputs.token }} + script: | + const { owner, repo } = context.repo; + const prNumber = ${{ needs.check-pr.outputs.pr_number }}; + const v2Port = ${{ steps.deploy.outputs.v2_port }}; + + // Delete the "deploying..." comment since we're posting the final result + const deploymentStartedId = ${{ steps.deployment-started.outputs.result }}; + if (deploymentStartedId) { + console.log(`Deleting deployment started comment: ${deploymentStartedId}`); + try { + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: deploymentStartedId + }); + } catch (error) { + console.log(`Could not delete deployment started comment: ${error.message}`); + } + } + + const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${v2Port}`; + const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`; + + const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` + + `Your V2 PR with the new frontend/backend split architecture has been deployed!\n\n` + + `🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` + + `🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` + + `_This deployment will be automatically cleaned up when the PR is closed._\n\n` + + `🔄 **Auto-deployed** because PR title or branch name contains V2/version2/React keywords.`; + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: commentBody + }); + + cleanup-v2-deployment: + if: github.event.action == 'closed' + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: write + + steps: + - name: Harden Runner + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup GitHub App Bot + if: github.actor != 'dependabot[bot]' + id: setup-bot + uses: ./.github/actions/setup-bot + continue-on-error: true + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + - name: Clean up V2 deployment comments + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + github-token: ${{ steps.setup-bot.outputs.token }} + script: | + const { owner, repo } = context.repo; + const prNumber = ${{ github.event.pull_request.number }}; + + // Find and delete V2 deployment comments + const { data: comments } = await github.rest.issues.listComments({ + owner, + repo, + issue_number: prNumber + }); + + const v2Comments = comments.filter(c => + c.body?.includes("## 🚀 V2 Auto-Deployment Complete!") && + c.user?.type === "Bot" + ); + + for (const comment of v2Comments) { + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: comment.id + }); + console.log(`Deleted V2 deployment comment (ID: ${comment.id})`); + } + + - name: Set up SSH + run: | + mkdir -p ~/.ssh/ + echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key + sudo chmod 600 ../private.key + + - name: Cleanup V2 deployment + run: | + ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH' + if [ -d "/stirling/V2-PR-${{ github.event.pull_request.number }}" ]; then + echo "Found V2 PR directory, proceeding with cleanup..." + + # Stop and remove V2 containers + cd /stirling/V2-PR-${{ github.event.pull_request.number }} + docker-compose down || true + + # Go back to root before removal + cd / + + # Remove V2 PR-specific directories + rm -rf /stirling/V2-PR-${{ github.event.pull_request.number }} + + # Clean up V2 containers by name (in case compose cleanup missed them) + docker rm -f stirling-pdf-v2-frontend-pr-${{ github.event.pull_request.number }} || true + docker rm -f stirling-pdf-v2-backend-pr-${{ github.event.pull_request.number }} || true + + echo "V2 cleanup completed" + else + echo "V2 PR directory not found, nothing to clean up" + fi + + # Clean up old unused images (older than 2 weeks) but keep recent ones for reuse + docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true + + # Note: We don't remove the commit-based images since they can be reused across PRs + # Only remove PR-specific containers and directories + ENDSSH + + - name: Cleanup temporary files + if: always() + run: | + rm -f ../private.key + continue-on-error: true \ No newline at end of file diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 68b4ad196..4acd0cbe6 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -29,22 +29,25 @@ jobs: github.event.comment.user.login == 'reecebrowne' || github.event.comment.user.login == 'DarioGii' || github.event.comment.user.login == 'EthanHealy01' || + github.event.comment.user.login == 'jbrunton96' || github.event.comment.user.login == 'ConnorYoh' ) outputs: pr_number: ${{ steps.get-pr.outputs.pr_number }} + pr_repository: ${{ steps.get-pr-info.outputs.repository }} + pr_ref: ${{ steps.get-pr-info.outputs.ref }} comment_id: ${{ github.event.comment.id }} disable_security: ${{ steps.check-security-flag.outputs.disable_security }} enable_pro: ${{ steps.check-pro-flag.outputs.enable_pro }} enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }} 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 PR - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup GitHub App Bot if: github.actor != 'dependabot[bot]' @@ -57,13 +60,36 @@ jobs: - name: Get PR data id: get-pr - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: script: | const prNumber = context.payload.issue.number; console.log(`PR Number: ${prNumber}`); core.setOutput('pr_number', prNumber); + - name: Get PR repository and ref + id: get-pr-info + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.issue.number; + + const { data: pr } = await github.rest.pulls.get({ + owner, + repo, + pull_number: prNumber, + }); + + // For forks, use the full repository name, for internal PRs use the current repo + const repository = pr.head.repo.fork ? pr.head.repo.full_name : `${owner}/${repo}`; + + console.log(`PR Repository: ${repository}`); + console.log(`PR Branch: ${pr.head.ref}`); + + core.setOutput('repository', repository); + core.setOutput('ref', pr.head.ref); + - name: Check for security/login flag id: check-security-flag env: @@ -98,7 +124,7 @@ jobs: - name: Add 'in_progress' reaction to comment id: add-eyes-reaction - 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: | @@ -127,12 +153,12 @@ 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 PR - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup GitHub App Bot if: github.actor != 'dependabot[bot]' @@ -144,13 +170,14 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - name: Checkout PR - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: - ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge + repository: ${{ needs.check-comment.outputs.pr_repository }} + ref: ${{ needs.check-comment.outputs.pr_ref }} token: ${{ steps.setup-bot.outputs.token }} - name: Set up JDK - uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: java-version: "17" distribution: "temurin" @@ -170,7 +197,7 @@ jobs: uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - name: Login to Docker Hub - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_API }} @@ -270,7 +297,7 @@ jobs: - name: Add success reaction to comment if: success() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: github-token: ${{ steps.setup-bot.outputs.token }} script: | @@ -305,7 +332,7 @@ jobs: - name: Add failure reaction to comment if: failure() - 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: | @@ -325,7 +352,7 @@ jobs: - name: Post deployment URL to PR if: success() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: github-token: ${{ steps.setup-bot.outputs.token }} script: | @@ -355,149 +382,3 @@ jobs: rm -f ../private.key docker-compose.yml echo "Cleanup complete." continue-on-error: true - - handle-label-commands: - if: ${{ github.event.issue.pull_request != null }} - runs-on: ubuntu-latest - steps: - - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 - with: - egress-policy: audit - - - name: Check out the repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - - name: Setup GitHub App Bot - id: setup-bot - uses: ./.github/actions/setup-bot - with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - - name: Apply label commands - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - github-token: ${{ steps.setup-bot.outputs.token }} - script: | - const fs = require('fs'); - const path = require('path'); - - const { comment, issue } = context.payload; - const commentBody = comment?.body ?? ''; - if (!commentBody.includes('::label::')) { - core.info('No label commands detected in comment.'); - return; - } - - const configPath = path.join(process.env.GITHUB_WORKSPACE, '.github', 'config', 'repo_devs.json'); - const repoDevsConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')); - const label_changer = (repoDevsConfig.label_changer || []).map((login) => login.toLowerCase()); - - const commenter = (comment?.user?.login || '').toLowerCase(); - if (!label_changer.includes(commenter)) { - core.info(`User ${commenter} is not authorized to manage labels.`); - return; - } - - const labelsConfigPath = path.join(process.env.GITHUB_WORKSPACE, '.github', 'labels.yml'); - const labelsFile = fs.readFileSync(labelsConfigPath, 'utf8'); - - const labelNameMap = new Map(); - for (const match of labelsFile.matchAll(/-\s+name:\s*(?:"([^"]+)"|'([^']+)'|([^\n]+))/g)) { - const labelName = (match[1] ?? match[2] ?? match[3] ?? '').trim(); - - if (!labelName) { - continue; - } - const normalized = labelName.toLowerCase(); - if (!labelNameMap.has(normalized)) { - labelNameMap.set(normalized, labelName); - } - } - - if (!labelNameMap.size) { - core.warning('No labels could be read from .github/labels.yml; aborting label commands.'); - return; - } - - let allowedLabelNames = new Set(labelNameMap.values()); - - const labelsToAdd = new Set(); - const labelsToRemove = new Set(); - const commandRegex = /^(\w+)::(label)::"([^"]+)"/gim; - let match; - while ((match = commandRegex.exec(commentBody)) !== null) { - core.info(`Found label command: ${match[0]} (action: ${match[1]}, label: ${match[2]}, labelName: ${match[3]})`); - const action = match[1].toLowerCase(); - const labelName = match[3].trim(); - - if (!labelName) { - continue; - } - - const normalized = labelName.toLowerCase(); - const resolvedLabelName = labelNameMap.get(normalized); - if (action === 'add') { - if (!resolvedLabelName) { - core.warning(`Label "${labelName}" is not defined in .github/labels.yml and cannot be added.`); - continue; - } - if (!allowedLabelNames.has(resolvedLabelName)) { - core.warning(`Label "${resolvedLabelName}" is not allowed for add commands and will be skipped.`); - continue; - } - labelsToAdd.add(resolvedLabelName); - } else if (action === 'rm') { - const labelToRemove = resolvedLabelName ?? labelName; - if (!resolvedLabelName) { - core.warning(`Label "${labelName}" is not defined in .github/labels.yml; attempting to remove as provided.`); - } - labelsToRemove.add(labelToRemove); - } - } - - const addLabels = Array.from(labelsToAdd); - const removeLabels = Array.from(labelsToRemove); - - if (!addLabels.length && !removeLabels.length) { - core.info('No valid label commands found after parsing.'); - return; - } - - const issueParams = { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - }; - - if (addLabels.length) { - core.info(`Adding labels: ${addLabels.join(', ')}`); - await github.rest.issues.addLabels({ - ...issueParams, - labels: addLabels, - }); - } - - for (const labelName of removeLabels) { - core.info(`Removing label: ${labelName}`); - try { - await github.rest.issues.removeLabel({ - ...issueParams, - name: labelName, - }); - } catch (error) { - if (error.status === 404) { - core.warning(`Label "${labelName}" was not present on the pull request.`); - } else { - throw error; - } - } - } - - await github.rest.issues.deleteComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: comment.id, - }); - core.info('Processed label commands and deleted the comment.'); diff --git a/.github/workflows/PR-Demo-cleanup.yml b/.github/workflows/PR-Demo-cleanup.yml index 47f1e8ed9..67625c0a5 100644 --- a/.github/workflows/PR-Demo-cleanup.yml +++ b/.github/workflows/PR-Demo-cleanup.yml @@ -21,12 +21,12 @@ 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 PR - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup GitHub App Bot if: github.actor != 'dependabot[bot]' @@ -39,7 +39,7 @@ jobs: - name: Remove 'pr-deployed' label if present id: remove-label-comment - 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: | diff --git a/.github/workflows/ai_pr_title_review.yml b/.github/workflows/ai_pr_title_review.yml index 77668d69a..59a69ae5f 100644 --- a/.github/workflows/ai_pr_title_review.yml +++ b/.github/workflows/ai_pr_title_review.yml @@ -19,11 +19,11 @@ 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 - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 @@ -158,7 +158,7 @@ jobs: - name: Post comment on PR if needed if: steps.actor.outputs.is_repo_dev == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 continue-on-error: true with: github-token: ${{ steps.setup-bot.outputs.token }} diff --git a/.github/workflows/auto-labelerV2.yml b/.github/workflows/auto-labelerV2.yml index e30f99f07..fae92940f 100644 --- a/.github/workflows/auto-labelerV2.yml +++ b/.github/workflows/auto-labelerV2.yml @@ -2,9 +2,6 @@ name: "Auto Pull Request Labeler V2" on: pull_request_target: types: [opened, synchronize] - branches: - - main - - V2 permissions: contents: read @@ -16,11 +13,11 @@ jobs: pull-requests: write 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 - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup GitHub App Bot id: setup-bot diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f45316472..b38abe5dc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,11 +1,9 @@ name: Build and Test Workflow on: - workflow_dispatch: - # push: - # branches: ["main"] pull_request: - branches: ["main"] + branches: ["main", "V2", "V2-gha"] + workflow_dispatch: # cancel in-progress jobs if a new job is triggered # This is useful to avoid running multiple builds for the same branch if a new commit is pushed @@ -27,60 +25,52 @@ jobs: name: detect what files changed runs-on: ubuntu-latest timeout-minutes: 3 - # Map a step output to a job output outputs: build: ${{ steps.changes.outputs.build }} app: ${{ steps.changes.outputs.app }} project: ${{ steps.changes.outputs.project }} openapi: ${{ steps.changes.outputs.openapi }} steps: - - 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" + filters: .github/config/.files.yaml + build: runs-on: ubuntu-latest - permissions: actions: read security-events: write - strategy: fail-fast: false matrix: jdk-version: [17, 21] 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 }} - run: ./gradlew clean build + run: ./gradlew clean build -PnoSpotless env: DISABLE_ADDITIONAL_FEATURES: ${{ matrix.spring-security }} - - name: Check Test Reports Exist - id: check-reports if: always() run: | declare -a dirs=( @@ -91,117 +81,109 @@ jobs: "app/proprietary/build/reports/tests/" "app/proprietary/build/test-results/" ) - missing_reports=() for dir in "${dirs[@]}"; do if [ ! -d "$dir" ]; then - missing_reports+=("$dir") + echo "Missing $dir" + exit 1 fi done - if [ ${#missing_reports[@]} -gt 0 ]; then - echo "ERROR: The following required test report directories are missing:" - printf '%s\n' "${missing_reports[@]}" - exit 1 - fi - echo "All required test report directories are present" - - name: Upload Test Reports if: always() - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@v4.6.2 with: name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }} path: | - app/core/build/reports/jacoco/test - app/core/build/reports/tests/ - app/core/build/test-results/ - app/core/build/reports/problems/ - app/common/build/reports/tests/ - app/common/build/test-results/ - app/common/build/reports/jacoco/test - app/common/build/reports/problems/ - app/proprietary/build/reports/tests/ - app/proprietary/build/test-results/ - app/proprietary/build/reports/jacoco/test - app/proprietary/build/reports/problems/ + app/**/build/reports/tests/ + app/**/build/test-results/ + app/**/build/reports/problems/ build/reports/problems/ retention-days: 3 if-no-files-found: warn - - name: Add coverage to PR with spring security ${{ matrix.spring-security }} and JDK ${{ matrix.jdk-version }} - id: jacoco - uses: madrapps/jacoco-report@50d3aff4548aa991e6753342d9ba291084e63848 # v1.7.2 - with: - paths: | - ${{ github.workspace }}/**/build/reports/jacoco/test/jacocoTestReport.xml - token: ${{ secrets.GITHUB_TOKEN }} - min-coverage-overall: 10 - min-coverage-changed-files: 0 - comment-type: summary - check-generateOpenApiDocs: if: needs.files-changed.outputs.openapi == 'true' - needs: [files-changed, build] + needs: [files-changed] 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" - - - name: Setup Gradle - 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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@v4.6.2 with: name: openapi-docs path: ./SwaggerDoc.json + frontend-validation: + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@v2.12.2 + with: + egress-policy: audit + - name: Checkout repository + uses: actions/checkout@v4.2.2 + - name: Set up Node.js + uses: actions/setup-node@v4.1.0 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + - name: Install frontend dependencies + run: cd frontend && npm ci + - name: Lint frontend + run: cd frontend && npm run lint + - name: Build frontend + run: cd frontend && npm run build + - name: Run frontend tests + run: cd frontend && npm run test -- --run + - name: Upload frontend build artifacts + uses: actions/upload-artifact@v4.6.2 + with: + name: frontend-build + path: frontend/dist/ + retention-days: 3 + check-licence: if: needs.files-changed.outputs.build == 'true' needs: [files-changed, build] 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" - - - name: Check licenses for compatibility + - name: check the licenses for compatibility run: ./gradlew clean checkLicense - env: - DISABLE_ADDITIONAL_FEATURES: false - STIRLING_PDF_DESKTOP_UI: true - - - name: FAILED - Check licenses for compatibility + - name: FAILED - check the licenses for compatibility if: failure() - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@v4.6.2 with: name: dependencies-without-allowed-license.json - path: | - build/reports/dependency-license/dependencies-without-allowed-license.json + path: build/reports/dependency-license/dependencies-without-allowed-license.json retention-days: 3 docker-compose-tests: @@ -225,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" @@ -247,7 +229,7 @@ jobs: 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 @@ -274,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 @@ -310,7 +292,7 @@ jobs: with: builder: ${{ steps.buildx.outputs.name }} context: . - file: ./${{ matrix.docker-rev }} + file: ./docker/backend/${{ matrix.docker-rev }} push: false cache-from: type=gha cache-to: type=gha,mode=max @@ -320,7 +302,7 @@ jobs: - name: Upload Reports if: always() - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: reports-docker-${{ matrix.docker-rev }} path: | diff --git a/.github/workflows/check_properties.yml b/.github/workflows/check_properties.yml index fd25ebaf9..8633d2d62 100644 --- a/.github/workflows/check_properties.yml +++ b/.github/workflows/check_properties.yml @@ -5,8 +5,6 @@ on: types: [opened, synchronize, reopened] paths: - "app/core/src/main/resources/messages_*.properties" - branches: - - main # 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 @@ -32,12 +30,12 @@ jobs: pull-requests: write # Allow writing to pull requests 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 main branch first - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup GitHub App Bot id: setup-bot @@ -48,7 +46,7 @@ jobs: - name: Get PR data id: get-pr-data - 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: | @@ -92,7 +90,7 @@ jobs: - name: Determine reference file test id: determine-file - 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: | @@ -236,7 +234,7 @@ jobs: - name: Post comment on PR if: env.SCRIPT_OUTPUT != '' - 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: | diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 49aa24fd9..8d938011d 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,11 +17,11 @@ 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@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: "Dependency Review" - uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1 + uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1 diff --git a/.github/workflows/deploy-on-v2-commit.yml b/.github/workflows/deploy-on-v2-commit.yml new file mode 100644 index 000000000..f2f90ccfa --- /dev/null +++ b/.github/workflows/deploy-on-v2-commit.yml @@ -0,0 +1,189 @@ +name: Auto V2 Deploy on Push + +on: + push: + branches: + - V2 + - deploy-on-v2-commit + +permissions: + contents: read + +jobs: + deploy-v2-on-push: + runs-on: ubuntu-latest + concurrency: + group: deploy-v2-push-V2 + cancel-in-progress: true + + steps: + - name: Harden Runner + uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Get commit hashes for frontend and backend + id: commit-hashes + run: | + # Get last commit that touched the frontend folder, docker/frontend, or docker/compose + FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "") + if [ -z "$FRONTEND_HASH" ]; then + FRONTEND_HASH="no-frontend-changes" + fi + + # Get last commit that touched backend code, docker/backend, or docker/compose + BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "") + if [ -z "$BACKEND_HASH" ]; then + BACKEND_HASH="no-backend-changes" + fi + + echo "Frontend hash: $FRONTEND_HASH" + echo "Backend hash: $BACKEND_HASH" + + echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT + echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT + + # Short hashes for tags + if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then + echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT + else + echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT + fi + + if [ "$BACKEND_HASH" = "no-backend-changes" ]; then + echo "backend_short=no-backend" >> $GITHUB_OUTPUT + else + echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT + fi + + - name: Check if frontend image exists + id: check-frontend + run: | + if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then + echo "exists=true" >> $GITHUB_OUTPUT + echo "Frontend image already exists, skipping build" + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "Frontend image needs to be built" + fi + + - name: Check if backend image exists + id: check-backend + run: | + if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then + echo "exists=true" >> $GITHUB_OUTPUT + echo "Backend image already exists, skipping build" + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "Backend image needs to be built" + fi + + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_API }} + + - name: Build and push frontend image + if: steps.check-frontend.outputs.exists == 'false' + uses: docker/build-push-action@v6 + with: + context: . + file: ./docker/frontend/Dockerfile + push: true + tags: | + ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} + ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest + build-args: VERSION_TAG=v2-alpha + platforms: linux/amd64 + + - name: Build and push backend image + if: steps.check-backend.outputs.exists == 'false' + uses: docker/build-push-action@v6 + with: + context: . + file: ./docker/backend/Dockerfile + push: true + tags: | + ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} + ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-latest + build-args: VERSION_TAG=v2-alpha + platforms: linux/amd64 + + + - name: Set up SSH + run: | + mkdir -p ~/.ssh/ + echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key + chmod 600 ../private.key + + + - name: Deploy to VPS on port 3000 + run: | + export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml + + cat > $UNIQUE_NAME << EOF + version: '3.3' + services: + backend: + container_name: stirling-v2-backend + image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} + ports: + - "13000:8080" + volumes: + - /stirling/V2/data:/usr/share/tessdata:rw + - /stirling/V2/config:/configs:rw + - /stirling/V2/logs:/logs:rw + environment: + DISABLE_ADDITIONAL_FEATURES: "true" + SECURITY_ENABLELOGIN: "false" + SYSTEM_DEFAULTLOCALE: en-GB + UI_APPNAME: "Stirling-PDF V2" + UI_HOMEDESCRIPTION: "V2 Frontend/Backend Split" + UI_APPNAMENAVBAR: "V2 Deployment" + SYSTEM_MAXFILESIZE: "100" + METRICS_ENABLED: "true" + SYSTEM_GOOGLEVISIBILITY: "false" + SWAGGER_SERVER_URL: "https://demo.stirlingpdf.cloud" + baseUrl: "https://demo.stirlingpdf.cloud" + restart: on-failure:5 + + frontend: + container_name: stirling-v2-frontend + image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} + ports: + - "3000:80" + environment: + VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:13000" + depends_on: + - backend + restart: on-failure:5 + EOF + + # Copy to remote with unique name + scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/$UNIQUE_NAME + + # SSH and rename/move atomically to avoid interference + ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH + mkdir -p /stirling/V2/{data,config,logs} + mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml + cd /stirling/V2 + docker-compose down || true + docker-compose pull + docker-compose up -d + docker system prune -af --volumes || true + docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true + ENDSSH + + - name: Cleanup temporary files + if: always() + run: | + rm -f ../private.key + diff --git a/.github/workflows/frontend-licenses-update.yml b/.github/workflows/frontend-licenses-update.yml new file mode 100644 index 000000000..33f295640 --- /dev/null +++ b/.github/workflows/frontend-licenses-update.yml @@ -0,0 +1,282 @@ +name: Frontend License Report Workflow + +on: + push: + branches: + - V2 + paths: + - "frontend/package.json" + - "frontend/package-lock.json" + - "frontend/scripts/generate-licenses.js" + pull_request: + branches: + - V2 + paths: + - ".github/workflows/frontend-licenses-update.yml" + - "frontend/package.json" + - "frontend/package-lock.json" + - "frontend/scripts/generate-licenses.js" + +permissions: + contents: read + +jobs: + generate-frontend-license-report: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + repository-projects: write # Required for enabling automerge + steps: + - name: Harden Runner + uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + with: + egress-policy: audit + + - name: Checkout PR head (default) + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup GitHub App Bot + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) + id: setup-bot + uses: ./.github/actions/setup-bot + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + - name: Checkout BASE branch (safe script) + if: github.event_name == 'pull_request' + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: base + fetch-depth: 1 + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + working-directory: frontend + env: + NPM_CONFIG_IGNORE_SCRIPTS: "true" + run: npm ci --ignore-scripts --audit=false --fund=false + + - name: Generate frontend license report (internal PR) + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false + working-directory: frontend + env: + PR_IS_FORK: "false" + run: npm run generate-licenses + + - name: Generate frontend license report (fork PRs, pinned) + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true + env: + NPM_CONFIG_IGNORE_SCRIPTS: "true" + working-directory: frontend + run: | + mkdir -p src/assets + npx --yes license-report --only=prod --output=json > src/assets/3rdPartyLicenses.json + + - name: Postprocess with project script (BASE version) + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true + env: + PR_IS_FORK: "true" + run: | + node base/frontend/scripts/generate-licenses.js \ + --input frontend/src/assets/3rdPartyLicenses.json + + - name: Copy postprocessed artifacts back (fork PRs) + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true + run: | + mkdir -p frontend/src/assets + if [ -f "base/frontend/src/assets/3rdPartyLicenses.json" ]; then + cp base/frontend/src/assets/3rdPartyLicenses.json frontend/src/assets/3rdPartyLicenses.json + fi + if [ -f "base/frontend/src/assets/license-warnings.json" ]; then + cp base/frontend/src/assets/license-warnings.json frontend/src/assets/license-warnings.json + fi + + - name: Check for license warnings + run: | + if [ -f "frontend/src/assets/license-warnings.json" ]; then + echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV + else + echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV + fi + + # PR Event: Check licenses and comment on PR + - name: Delete previous license check comments + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + github-token: ${{ steps.setup-bot.outputs.token }} + script: | + const { owner, repo } = context.repo; + const prNumber = context.issue.number; + + // Get all comments on the PR + const { data: comments } = await github.rest.issues.listComments({ + owner, + repo, + issue_number: prNumber, + per_page: 100 + }); + + // Filter for license check comments + const licenseComments = comments.filter(comment => + comment.body.includes('## ✅ Frontend License Check Passed') || + comment.body.includes('## ❌ Frontend License Check Failed') + ); + + // Delete old license check comments + for (const comment of licenseComments) { + console.log(`Deleting old license check comment: ${comment.id}`); + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: comment.id + }); + } + + - name: Summarize results (fork PRs) + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true + run: | + { + echo "## Frontend License Check" + echo "" + if [ "${LICENSE_WARNINGS_EXIST}" = "true" ]; then + echo "❌ **Failed** – incompatible or unknown licenses found." + if [ -f "frontend/src/assets/license-warnings.json" ]; then + echo "" + echo "### Warnings" + jq -r '.warnings[] | "- \(.message)"' frontend/src/assets/license-warnings.json || true + fi + else + echo "✅ **Passed** – no license warnings detected." + fi + echo "" + echo "_Note: This is a fork PR. PR comments are disabled; use this summary._" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Comment on PR - License Check Results + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + github-token: ${{ steps.setup-bot.outputs.token }} + script: | + const { owner, repo } = context.repo; + const prNumber = context.issue.number; + const hasWarnings = process.env.LICENSE_WARNINGS_EXIST === 'true'; + + let commentBody; + + if (hasWarnings) { + // Read warnings file to get specific issues + const fs = require('fs'); + let warningDetails = ''; + try { + const warnings = JSON.parse(fs.readFileSync('frontend/src/assets/license-warnings.json', 'utf8')); + warningDetails = warnings.warnings.map(w => `- ${w.message}`).join('\n'); + } catch (e) { + warningDetails = 'Unable to read warning details'; + } + + commentBody = `## ❌ Frontend License Check Failed + + The frontend license check has detected compatibility warnings that require review: + + ${warningDetails} + + **Action Required:** Please review these licenses to ensure they are acceptable for your use case before merging. + + _This check will fail the PR until license issues are resolved._`; + } else { + commentBody = `## ✅ Frontend License Check Passed + + All frontend licenses have been validated and no compatibility warnings were detected. + + The frontend license report has been updated successfully.`; + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: commentBody + }); + + - name: Fail workflow if license warnings exist (PR only) + if: github.event_name == 'pull_request' && env.LICENSE_WARNINGS_EXIST == 'true' + run: | + echo "❌ License warnings detected. Failing the workflow." + exit 1 + + # Push Event: Commit license files and create PR + - name: Commit changes (Push only) + if: github.event_name == 'push' + run: | + git add frontend/src/assets/3rdPartyLicenses.json + # Note: Do NOT commit license-warnings.json - it's only for PR review + git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV + + - name: Prepare PR body (Push only) + if: github.event_name == 'push' + run: | + PR_BODY="Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot] + + This PR updates the frontend license report based on changes to package.json dependencies." + + if [ "${{ env.LICENSE_WARNINGS_EXIST }}" = "true" ]; then + PR_BODY="$PR_BODY + + ## ⚠️ License Compatibility Warnings + + The following licenses may require review for corporate compatibility: + + $(cat frontend/src/assets/license-warnings.json | jq -r '.warnings[].message') + + Please review these licenses to ensure they are acceptable for your use case." + fi + + echo "PR_BODY<> $GITHUB_ENV + echo "$PR_BODY" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + - name: Create Pull Request (Push only) + id: cpr + if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true' + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + with: + token: ${{ steps.setup-bot.outputs.token }} + commit-message: "Update Frontend 3rd Party Licenses" + committer: ${{ steps.setup-bot.outputs.committer }} + author: ${{ steps.setup-bot.outputs.committer }} + signoff: true + branch: update-frontend-3rd-party-licenses + base: V2 + title: "Update Frontend 3rd Party Licenses" + body: ${{ env.PR_BODY }} + labels: Licenses,github-actions,frontend + draft: false + delete-branch: true + sign-commits: true + + - name: Enable Pull Request Automerge (Push only) + if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'false' + run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}" + env: + GH_TOKEN: ${{ steps.setup-bot.outputs.token }} + + - name: Add review required label (Push only) + if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'true' + run: gh pr edit "${{ steps.cpr.outputs.pull-request-number }}" --add-label "license-review-required" + env: + GH_TOKEN: ${{ steps.setup-bot.outputs.token }} diff --git a/.github/workflows/licenses-update.yml b/.github/workflows/licenses-update.yml index 613a8219b..1f920e2da 100644 --- a/.github/workflows/licenses-update.yml +++ b/.github/workflows/licenses-update.yml @@ -31,12 +31,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@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit - name: Check out code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 @@ -48,23 +48,20 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - 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: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0 + uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 - name: Check licenses for compatibility run: ./gradlew clean checkLicense - env: - DISABLE_ADDITIONAL_FEATURES: false - STIRLING_PDF_DESKTOP_UI: true - name: Upload artifact on failure if: failure() - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: dependencies-without-allowed-license.json path: build/reports/dependency-license/dependencies-without-allowed-license.json diff --git a/.github/workflows/manage-label.yml b/.github/workflows/manage-label.yml index d480249f2..3f25fbaf1 100644 --- a/.github/workflows/manage-label.yml +++ b/.github/workflows/manage-label.yml @@ -15,12 +15,12 @@ jobs: issues: write 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: Check out the repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Run Labeler uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 # v5.3.0 diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 02087c613..e043fd094 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -21,14 +21,14 @@ jobs: versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }} 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 - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK - uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: distribution: 'temurin' java-version: '21' @@ -60,19 +60,19 @@ jobs: file_suffix: "" 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 - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK 21 - uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: java-version: "21" distribution: "temurin" - - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0 + - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 with: gradle-version: 8.14 @@ -89,7 +89,7 @@ jobs: mv ./app/core/build/libs/stirling-pdf-${{ needs.read_versions.outputs.version }}.jar ./binaries/Stirling-PDF${{ matrix.file_suffix }}.jar - name: Upload build artifacts - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: retention-days: 1 if-no-files-found: error @@ -110,12 +110,12 @@ jobs: file_suffix: "" 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: Download build artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: stirling-${{ matrix.file_suffix }}binaries @@ -123,7 +123,7 @@ jobs: run: ls -R - name: Upload signed artifacts - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: retention-days: 1 if-no-files-found: error @@ -148,19 +148,19 @@ jobs: contents: write 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 - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK 21 - uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: java-version: "21" distribution: "temurin" - - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0 + - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 with: gradle-version: 8.14 @@ -216,7 +216,7 @@ jobs: run: ls -R ./binaries - name: Upload build artifacts - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: retention-days: 1 if-no-files-found: error @@ -238,12 +238,12 @@ 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: Download build artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: ${{ matrix.platform }}binaries @@ -252,7 +252,7 @@ jobs: - name: Install Cosign if: matrix.os == 'windows-latest' - uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 - name: Generate key pair if: matrix.os == 'windows-latest' @@ -283,7 +283,7 @@ jobs: run: ls -R - name: Upload signed artifacts - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: retention-days: 1 if-no-files-found: error @@ -301,16 +301,16 @@ jobs: contents: write 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: Download signed artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 - name: Display structure of downloaded files run: ls -R - name: Upload binaries, attestations and signatures to Release and create GitHub Release - uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 + uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2 with: tag_name: v${{ needs.read_versions.outputs.version }} generate_release_notes: true diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index acd489f5b..eccf235d1 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -12,21 +12,17 @@ permissions: jobs: pre-commit: runs-on: ubuntu-latest - env: - # Prevents sdist builds → no tar extraction - PIP_ONLY_BINARY: ":all:" - PIP_DISABLE_PIP_VERSION_CHECK: "1" permissions: contents: write pull-requests: write 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 with: fetch-depth: 0 @@ -38,7 +34,7 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - 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 @@ -46,13 +42,13 @@ jobs: - name: Run Pre-Commit Hooks run: | - pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_pre_commit.txt + pip install --require-hashes -r ./.github/scripts/requirements_pre_commit.txt - run: pre-commit run --all-files -c .pre-commit-config.yaml continue-on-error: true - name: Set up JDK - uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: java-version: 17 distribution: "temurin" diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index 6f66b051f..9a583c7b9 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -30,19 +30,19 @@ jobs: id-token: write 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 - - 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" - - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0 + - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 with: gradle-version: 8.14 @@ -54,7 +54,7 @@ jobs: - name: Install cosign if: github.ref == 'refs/heads/master' - uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 with: cosign-release: "v2.4.1" @@ -67,13 +67,13 @@ jobs: run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT - name: Login to Docker Hub - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_API }} - name: Login to GitHub Container Registry - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/releaseArtifacts.yml b/.github/workflows/releaseArtifacts.yml index 0577bb96e..7839ffd64 100644 --- a/.github/workflows/releaseArtifacts.yml +++ b/.github/workflows/releaseArtifacts.yml @@ -23,19 +23,19 @@ jobs: version: ${{ steps.versionNumber.outputs.versionNumber }} 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 - - 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" - - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0 + - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 with: gradle-version: 8.14 @@ -63,7 +63,7 @@ jobs: ls -R ./build/launch4j - name: Upload build artifacts - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: binaries${{ matrix.file_suffix }} path: | @@ -83,19 +83,19 @@ jobs: file_suffix: "" 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: Download build artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: binaries${{ matrix.file_suffix }} - name: Display structure of downloaded files run: ls -R - name: Install Cosign - uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 - name: Generate key pair run: cosign generate-key-pair @@ -139,7 +139,7 @@ jobs: ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe - name: Upload signed artifacts - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: signed${{ matrix.file_suffix }} path: | @@ -161,17 +161,17 @@ jobs: file_suffix: "" 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: Download signed artifacts - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: signed${{ matrix.file_suffix }} - name: Upload binaries, attestations and signatures to Release and create GitHub Release - uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 + uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2 with: tag_name: v${{ needs.build.outputs.version }} generate_release_notes: true diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index b764dd675..a3a355845 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -34,17 +34,17 @@ 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 code" - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 with: results_file: results.sarif results_format: sarif @@ -66,7 +66,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: SARIF file path: results.sarif @@ -74,6 +74,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@0499de31b99561a6d14a36a5f662c2a54f91beee # v3.29.5 + uses: github/codeql-action/upload-sarif@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.5 with: sarif_file: results.sarif diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml new file mode 100644 index 000000000..1e0e3ec32 --- /dev/null +++ b/.github/workflows/sonarqube.yml @@ -0,0 +1,75 @@ +name: Run Sonarqube + +on: + push: + branches: + - master + pull_request_target: + branches: + - main + workflow_dispatch: + +# cancel in-progress jobs if a new job is triggered +# This is useful to avoid running multiple builds for the same branch if a new commit is pushed +# or a pull request is updated. +# It helps to save resources and time by ensuring that only the latest commit is built and tested +# This is particularly useful for long-running jobs that may take a while to complete. +# The `group` is set to a combination of the workflow name, event name, and branch name. +# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of +# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.ref }} + cancel-in-progress: true + +permissions: + pull-requests: read + actions: read + +jobs: + sonarqube: + 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 + with: + fetch-depth: 0 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 + + - name: Build and analyze with Gradle + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + DISABLE_ADDITIONAL_FEATURES: false + STIRLING_PDF_DESKTOP_UI: true + run: | + ./gradlew clean build sonar \ + -Dsonar.projectKey=Stirling-Tools_Stirling-PDF \ + -Dsonar.organization=stirling-tools \ + -Dsonar.host.url=https://sonarcloud.io \ + -Dsonar.login=${SONAR_TOKEN} \ + -Dsonar.log.level=DEBUG \ + --info + + - name: Upload Problems Report on Failure + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: gradle-problems-report + path: build/reports/problems/problems-report.html + retention-days: 7 + + - name: Upload Sonar Logs on Failure + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sonar-logs + path: | + .scannerwork/report-task.txt + build/sonar/ + retention-days: 7 diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 16f0a3088..ebb51704c 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -26,19 +26,19 @@ 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 - - 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" - - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0 + - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 - name: Generate Swagger documentation run: ./gradlew :stirling-pdf:generateOpenApiDocs diff --git a/.github/workflows/sync_files.yml b/.github/workflows/sync_files.yml index 1233ac701..d2ff7e827 100644 --- a/.github/workflows/sync_files.yml +++ b/.github/workflows/sync_files.yml @@ -30,17 +30,13 @@ permissions: jobs: sync-files: runs-on: ubuntu-latest - env: - # Prevents sdist builds → no tar extraction - PIP_ONLY_BINARY: ":all:" - PIP_DISABLE_PIP_VERSION_CHECK: "1" steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup GitHub App Bot id: setup-bot @@ -50,7 +46,7 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - 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 @@ -65,9 +61,7 @@ jobs: git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "No changes detected" - name: Install dependencies - # Wheels-only + Hash-Pinning - run: | - pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt + run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt - name: Sync README.md run: | diff --git a/.github/workflows/sync_files_v2.yml b/.github/workflows/sync_files_v2.yml new file mode 100644 index 000000000..84645c59e --- /dev/null +++ b/.github/workflows/sync_files_v2.yml @@ -0,0 +1,118 @@ +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 \ No newline at end of file diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml index cd2cedb25..209ce7435 100644 --- a/.github/workflows/testdriver.yml +++ b/.github/workflows/testdriver.yml @@ -24,21 +24,21 @@ 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@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK - uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: java-version: '17' distribution: 'temurin' - name: Setup Gradle - uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0 + uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 with: gradle-version: 8.14 @@ -57,7 +57,7 @@ jobs: echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT - name: Login to Docker Hub - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_API }} @@ -116,47 +116,28 @@ jobs: docker-compose up -d EOF - files-changed: - if: always() - name: detect what files changed - runs-on: ubuntu-latest - timeout-minutes: 3 - outputs: - frontend: ${{ steps.changes.outputs.frontend }} - steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - - name: Check for file changes - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 - id: changes - with: - filters: ".github/config/.files.yaml" - test: - if: needs.files-changed.outputs.frontend == 'true' - needs: [deploy, files-changed] + needs: deploy 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 - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up Node - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: cache: 'npm' - cache-dependency-path: frontend/package-lock.json - name: Run TestDriver.ai uses: testdriverai/action@f0d0f45fdd684db628baa843fe9313f3ca3a8aa8 #1.1.3 with: key: ${{secrets.TESTDRIVER_API_KEY}} prerun: | - cd frontend npm install npm run build npm install dashcam-chrome --save @@ -175,7 +156,7 @@ 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 @@ -186,7 +167,6 @@ jobs: sudo chmod 600 ../private.key - name: Cleanup deployment - if: always() run: | ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << EOF cd /stirling/test-${{ github.sha }} @@ -194,4 +174,3 @@ jobs: cd /stirling rm -rf test-${{ github.sha }} EOF - continue-on-error: true # Ensure cleanup runs even if previous steps fail diff --git a/.gitignore b/.gitignore index 6a5fe9b05..3d9b2a949 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ clientWebUI/ !cucumber/exampleFiles/ !cucumber/exampleFiles/example_html.zip exampleYmlFiles/stirling/ +/stirling/ /testing/file_snapshots SwaggerDoc.json @@ -196,6 +197,8 @@ id_ed25519.pub .pytest_cache .ipynb_checkpoints + + **/jcef-bundle/ # node_modules @@ -203,3 +206,10 @@ node_modules/ # weasyPrint **/LOCAL_APPDATA_FONTCONFIG_CACHE/** + +# Translation temp files +*_compact.json +*compact*.json +test_batch.json +*.backup.*.json +frontend/public/locales/*/translation.backup*.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 6ab09796f..4f627148e 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -19,5 +19,6 @@ "yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing "stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting "redhat.vscode-yaml", // YAML extension for Visual Studio Code + "dbaeumer.vscode-eslint", // ESLint extension for TypeScript linting ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 5b8f77bbc..7c231b4ef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -139,5 +139,8 @@ "app/core/src/main/java", "app/common/src/main/java", "app/proprietary/src/main/java" - ] + ], + "[typescript]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + } } diff --git a/ADDING_TOOLS.md b/ADDING_TOOLS.md new file mode 100644 index 000000000..ef1501bfc --- /dev/null +++ b/ADDING_TOOLS.md @@ -0,0 +1,300 @@ +# Adding New React Tools to Stirling PDF + +This guide covers how to add new PDF tools to the React frontend, either by migrating existing Thymeleaf templates or creating entirely new tools. + +## Overview + +When adding tools, follow this systematic approach using the established patterns and architecture. + +## 1. Create Tool Structure + +Create these files in the correct directories: +``` +frontend/src/hooks/tools/[toolName]/ + ├── use[ToolName]Parameters.ts # Parameter definitions and validation + └── use[ToolName]Operation.ts # Tool operation logic using useToolOperation + +frontend/src/components/tools/[toolName]/ + └── [ToolName]Settings.tsx # Settings UI component (if needed) + +frontend/src/tools/ + └── [ToolName].tsx # Main tool component +``` + +## 2. Implementation Pattern + +Use `useBaseTool` for simplified hook management. This is the recommended approach for all new tools: + +**Parameters Hook** (`use[ToolName]Parameters.ts`): +```typescript +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; + +export interface [ToolName]Parameters extends BaseParameters { + // Define your tool-specific parameters here + someOption: boolean; +} + +export const defaultParameters: [ToolName]Parameters = { + someOption: false, +}; + +export const use[ToolName]Parameters = (): BaseParametersHook<[ToolName]Parameters> => { + return useBaseParameters({ + defaultParameters, + endpointName: 'your-endpoint-name', + validateFn: (params) => true, // Add validation logic + }); +}; +``` + +**Operation Hook** (`use[ToolName]Operation.ts`): +```typescript +import { useTranslation } from 'react-i18next'; +import { ToolType, useToolOperation } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; + +export const build[ToolName]FormData = (parameters: [ToolName]Parameters, file: File): FormData => { + const formData = new FormData(); + formData.append('fileInput', file); + // Add parameters to formData + return formData; +}; + +export const [toolName]OperationConfig = { + toolType: ToolType.singleFile, // or ToolType.multiFile (buildFormData's file parameter will need to be updated) + buildFormData: build[ToolName]FormData, + operationType: '[toolName]', + endpoint: '/api/v1/category/endpoint-name', + filePrefix: 'processed_', // Will be overridden with translation + defaultParameters, +} as const; + +export const use[ToolName]Operation = () => { + const { t } = useTranslation(); + return useToolOperation({ + ...[toolName]OperationConfig, + filePrefix: t('[toolName].filenamePrefix', 'processed') + '_', + getErrorMessage: createStandardErrorHandler(t('[toolName].error.failed', 'Operation failed')) + }); +}; +``` + +**Main Component** (`[ToolName].tsx`): +```typescript +import { useTranslation } from "react-i18next"; +import { createToolFlow } from "../components/tools/shared/createToolFlow"; +import { use[ToolName]Parameters } from "../hooks/tools/[toolName]/use[ToolName]Parameters"; +import { use[ToolName]Operation } from "../hooks/tools/[toolName]/use[ToolName]Operation"; +import { useBaseTool } from "../hooks/tools/shared/useBaseTool"; +import { BaseToolProps, ToolComponent } from "../types/tool"; + +const [ToolName] = (props: BaseToolProps) => { + const { t } = useTranslation(); + const base = useBaseTool('[toolName]', use[ToolName]Parameters, use[ToolName]Operation, props); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + placeholder: t("[toolName].files.placeholder", "Select files to get started"), + }, + steps: [ + // Add settings steps if needed + ], + executeButton: { + text: t("[toolName].submit", "Process"), + isVisible: !base.hasResults, + loadingText: t("loading"), + onClick: base.handleExecute, + disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + }, + review: { + isVisible: base.hasResults, + operation: base.operation, + title: t("[toolName].results.title", "Results"), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + }, + }); +}; + +[ToolName].tool = () => use[ToolName]Operation; +export default [ToolName] as ToolComponent; +``` + +**Note**: Some existing tools (like AddPassword, Compress) use a legacy pattern with manual hook management. **Always use the Modern Pattern above for new tools** - it's cleaner, more maintainable, and includes automation support. + +## 3. Register Tool in System +Update these files to register your new tool: + +**Tool Registry** (`frontend/src/data/useTranslatedToolRegistry.tsx`): +1. Add imports at the top: +```typescript +import [ToolName] from "../tools/[ToolName]"; +import { [toolName]OperationConfig } from "../hooks/tools/[toolName]/use[ToolName]Operation"; +import [ToolName]Settings from "../components/tools/[toolName]/[ToolName]Settings"; +``` + +2. Add tool entry in the `allTools` object: +```typescript +[toolName]: { + icon: , + name: t("home.[toolName].title", "Tool Name"), + component: [ToolName], + description: t("home.[toolName].desc", "Tool description"), + categoryId: ToolCategoryId.STANDARD_TOOLS, // or appropriate category + subcategoryId: SubcategoryId.APPROPRIATE_SUBCATEGORY, + maxFiles: -1, // or specific number + endpoints: ["endpoint-name"], + operationConfig: [toolName]OperationConfig, + settingsComponent: [ToolName]Settings, // if settings exist +}, +``` + +## 4. Add Tooltips (Optional but Recommended) +Create user-friendly tooltips to help non-technical users understand your tool. **Use simple, clear language - avoid technical jargon:** + +**Tooltip Hook** (`frontend/src/components/tooltips/use[ToolName]Tips.ts`): +```typescript +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const use[ToolName]Tips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("[toolName].tooltip.header.title", "Tool Overview") + }, + tips: [ + { + title: t("[toolName].tooltip.description.title", "What does this tool do?"), + description: t("[toolName].tooltip.description.text", "Simple explanation in everyday language that non-technical users can understand."), + bullets: [ + t("[toolName].tooltip.description.bullet1", "Easy-to-understand benefit 1"), + t("[toolName].tooltip.description.bullet2", "Easy-to-understand benefit 2") + ] + } + // Add more tip sections as needed + ] + }; +}; +``` + +**Add tooltip to your main component:** +```typescript +import { use[ToolName]Tips } from "../components/tooltips/use[ToolName]Tips"; + +const [ToolName] = (props: BaseToolProps) => { + const tips = use[ToolName]Tips(); + + // In your steps array: + steps: [ + { + title: t("[toolName].steps.settings", "Settings"), + tooltip: tips, // Add this line + content: <[ToolName]Settings ... /> + } + ] +``` + +## 5. Add Translations +Update translation files. **Important: Only update `en-GB` files** - other languages are handled separately. + +**File to update:** `frontend/public/locales/en-GB/translation.json` + +**Required Translation Keys**: +```json +{ + "home": { + "[toolName]": { + "title": "Tool Name", + "desc": "Tool description" + } + }, + "[toolName]": { + "title": "Tool Name", + "submit": "Process", + "filenamePrefix": "processed", + "files": { + "placeholder": "Select files to get started" + }, + "steps": { + "settings": "Settings" + }, + "options": { + "title": "Tool Options", + "someOption": "Option Label", + "someOption.desc": "Option description", + "note": "General information about the tool." + }, + "results": { + "title": "Results" + }, + "error": { + "failed": "Operation failed" + }, + "tooltip": { + "header": { + "title": "Tool Overview" + }, + "description": { + "title": "What does this tool do?", + "text": "Simple explanation in everyday language", + "bullet1": "Easy-to-understand benefit 1", + "bullet2": "Easy-to-understand benefit 2" + } + } + } +} +``` + +**Translation Notes:** +- **Only update `en-GB/translation.json`** - other locale files are managed separately +- Use descriptive keys that match your component's `t()` calls +- Include tooltip translations if you created tooltip hooks +- Add `options.*` keys if your tool has settings with descriptions + +**Tooltip Writing Guidelines:** +- **Use simple, everyday language** - avoid technical terms like "converts interactive elements" +- **Focus on benefits** - explain what the user gains, not how it works internally +- **Use concrete examples** - "text boxes become regular text" vs "form fields are flattened" +- **Answer user questions** - "What does this do?", "When should I use this?", "What's this option for?" +- **Keep descriptions concise** - 1-2 sentences maximum per section +- **Use bullet points** for multiple benefits or features + +## 6. Migration from Thymeleaf +When migrating existing Thymeleaf templates: + +1. **Identify Form Parameters**: Look at the original `
` inputs to determine parameter structure +2. **Extract Translation Keys**: Find `#{key.name}` references and add them to JSON translations (For many tools these translations will already exist but some parts will be missing) +3. **Map API Endpoint**: Note the `th:action` URL for the operation hook +4. **Preserve Functionality**: Ensure all original form behaviour is replicated which is applicable to V2 react UI + +## 7. Testing Your Tool +- Verify tool appears in UI with correct icon and description +- Test with various file sizes and types +- Confirm translations work +- Check error handling +- Test undo functionality +- Verify results display correctly + +## Tool Development Patterns + +### Three Tool Patterns: + +**Pattern 1: Single-File Tools** (Individual processing) +- Backend processes one file per API call +- Set `multiFileEndpoint: false` +- Examples: Compress, Rotate + +**Pattern 2: Multi-File Tools** (Batch processing) +- Backend accepts `MultipartFile[]` arrays in single API call +- Set `multiFileEndpoint: true` +- Examples: Split, Merge, Overlay + +**Pattern 3: Complex Tools** (Custom processing) +- Tools with complex routing logic or non-standard processing +- Provide `customProcessor` for full control +- Examples: Convert, OCR diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..d111f8da3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,228 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Common Development Commands + +### Build and Test +- **Build project**: `./gradlew clean build` +- **Run locally**: `./gradlew bootRun` +- **Full test suite**: `./test.sh` (builds all Docker variants and runs comprehensive tests) +- **Code formatting**: `./gradlew spotlessApply` (runs automatically before compilation) + +### Docker Development +- **Build ultra-lite**: `docker build -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite .` +- **Build standard**: `docker build -t stirlingtools/stirling-pdf:latest -f ./Dockerfile .` +- **Build fat version**: `docker build -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .` +- **Example compose files**: Located in `exampleYmlFiles/` directory + +### Security Mode Development +Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security features during development. This is required for testing the full version locally. + +### Frontend Development +- **Frontend dev server**: `cd frontend && npm run dev` (requires backend on localhost:8080) +- **Tech Stack**: Vite + React + TypeScript + Mantine UI + TailwindCSS +- **Proxy Configuration**: Vite proxies `/api/*` calls to backend (localhost:8080) +- **Build Process**: DO NOT run build scripts manually - builds are handled by CI/CD pipelines +- **Package Installation**: DO NOT run npm install commands - package management handled separately +- **Deployment Options**: + - **Desktop App**: `npm run tauri-build` (native desktop application) + - **Web Server**: `npm run build` then serve dist/ folder + - **Development**: `npm run tauri-dev` for desktop dev mode + +#### Multi-Tool Workflow Architecture +Frontend designed for **stateful document processing**: +- Users upload PDFs once, then chain tools (split → merge → compress → view) +- File state and processing results persist across tool switches +- No file reloading between tools - performance critical for large PDFs (up to 100GB+) + +#### FileContext - Central State Management +**Location**: `src/contexts/FileContext.tsx` +- **Active files**: Currently loaded PDFs and their variants +- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName) +- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management +- **IndexedDB persistence**: File storage with thumbnail caching +- **Preview system**: Tools can preview results (e.g., Split → Viewer → back to Split) without context pollution + +**Critical**: All file operations go through FileContext. Don't bypass with direct file handling. + +#### Processing Services +- **enhancedPDFProcessingService**: Background PDF parsing and manipulation +- **thumbnailGenerationService**: Web Worker-based with main-thread fallback +- **fileStorage**: IndexedDB with LRU cache management + +#### Memory Management Strategy +**Why manual cleanup exists**: Large PDFs (up to 100GB+) through multiple tools accumulate: +- PDF.js documents that need explicit .destroy() calls +- Blob URLs from tool outputs that need revocation +- Web Workers that need termination +Without cleanup: browser crashes with memory leaks. + +#### Tool Development + +**Architecture**: Modular hook-based system with clear separation of concerns: + +- **useToolOperation** (`frontend/src/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook + - Coordinates all tool operations with consistent interface + - Integrates with FileContext for operation tracking + - Handles validation, error handling, and UI state management + +- **Supporting Hooks**: + - **useToolState**: UI state management (loading, progress, error, files) + - **useToolApiCalls**: HTTP requests and file processing + - **useToolResources**: Blob URLs, thumbnails, ZIP downloads + +- **Utilities**: + - **toolErrorHandler**: Standardized error extraction and i18n support + - **toolResponseProcessor**: API response handling (single/zip/custom) + - **toolOperationTracker**: FileContext integration utilities + +**Three Tool Patterns**: + +**Pattern 1: Single-File Tools** (Individual processing) +- Backend processes one file per API call +- Set `multiFileEndpoint: false` +- Examples: Compress, Rotate +```typescript +return useToolOperation({ + operationType: 'compress', + endpoint: '/api/v1/misc/compress-pdf', + buildFormData: (params, file: File) => { /* single file */ }, + multiFileEndpoint: false, +}); +``` + +**Pattern 2: Multi-File Tools** (Batch processing) +- Backend accepts `MultipartFile[]` arrays in single API call +- Set `multiFileEndpoint: true` +- Examples: Split, Merge, Overlay +```typescript +return useToolOperation({ + operationType: 'split', + endpoint: '/api/v1/general/split-pages', + buildFormData: (params, files: File[]) => { /* all files */ }, + multiFileEndpoint: true, + filePrefix: 'split_', +}); +``` + +**Pattern 3: Complex Tools** (Custom processing) +- Tools with complex routing logic or non-standard processing +- Provide `customProcessor` for full control +- Examples: Convert, OCR +```typescript +return useToolOperation({ + operationType: 'convert', + customProcessor: async (params, files) => { /* custom logic */ }, +}); +``` + +**Benefits**: +- **No Timeouts**: Operations run until completion (supports 100GB+ files) +- **Consistent**: All tools follow same pattern and interface +- **Maintainable**: Single responsibility hooks, easy to test and modify +- **i18n Ready**: Built-in internationalization support +- **Type Safe**: Full TypeScript support with generic interfaces +- **Memory Safe**: Automatic resource cleanup and blob URL management + +## Architecture Overview + +### Project Structure +- **Backend**: Spring Boot application with Thymeleaf templating +- **Frontend**: React-based SPA in `/frontend` directory (Thymeleaf templates fully replaced) + - **File Storage**: IndexedDB for client-side file persistence and thumbnails + - **Internationalization**: JSON-based translations (converted from backend .properties) +- **PDF Processing**: PDFBox for core PDF operations, LibreOffice for conversions, PDF.js for client-side rendering +- **Security**: Spring Security with optional authentication (controlled by `DOCKER_ENABLE_SECURITY`) +- **Configuration**: YAML-based configuration with environment variable overrides + +### Controller Architecture +- **API Controllers** (`src/main/java/.../controller/api/`): REST endpoints for PDF operations + - Organized by function: converters, security, misc, pipeline + - Follow pattern: `@RestController` + `@RequestMapping("/api/v1/...")` +- **Web Controllers** (`src/main/java/.../controller/web/`): Serve Thymeleaf templates + - Pattern: `@Controller` + return template names + +### Key Components +- **SPDFApplication.java**: Main application class with desktop UI and browser launching logic +- **ConfigInitializer**: Handles runtime configuration and settings files +- **Pipeline System**: Automated PDF processing workflows via `PipelineController` +- **Security Layer**: Authentication, authorization, and user management (when enabled) + +### Component Architecture +- **React Components**: Located in `frontend/src/components/` and `frontend/src/tools/` +- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern) +- **Internationalization**: + - Backend: `messages_*.properties` files + - Frontend: JSON files in `frontend/public/locales/` (converted from .properties) + - Conversion Script: `scripts/convert_properties_to_json.py` + +### Configuration Modes +- **Ultra-lite**: Basic PDF operations only +- **Standard**: Full feature set +- **Fat**: Pre-downloaded dependencies for air-gapped environments +- **Security Mode**: Adds authentication, user management, and enterprise features + +### Testing Strategy +- **Integration Tests**: Cucumber tests in `testing/cucumber/` +- **Docker Testing**: `test.sh` validates all Docker variants +- **Manual Testing**: No unit tests currently - relies on UI and API testing + +## Development Workflow + +1. **Local Development**: + - Backend: `./gradlew bootRun` (runs on localhost:8080) + - Frontend: `cd frontend && npm run dev` (runs on localhost:5173, proxies to backend) +2. **Docker Testing**: Use `./test.sh` before submitting PRs +3. **Code Style**: Spotless enforces Google Java Format automatically +4. **Translations**: + - Backend: Use helper scripts in `/scripts` for multi-language updates + - Frontend: Update JSON files in `frontend/public/locales/` or use conversion script +5. **Documentation**: API docs auto-generated and available at `/swagger-ui/index.html` + +## Frontend Architecture Status + +- **Core Status**: React SPA architecture complete with multi-tool workflow support +- **State Management**: FileContext handles all file operations and tool navigation +- **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+) +- **Tool Integration**: Modular hook architecture with `useToolOperation` orchestrator + - Individual hooks: `useToolState`, `useToolApiCalls`, `useToolResources` + - Utilities: `toolErrorHandler`, `toolResponseProcessor`, `toolOperationTracker` + - Pattern: Each tool creates focused operation hook, UI consumes state/actions +- **Preview System**: Tool results can be previewed without polluting file context (Split tool example) +- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing + +## Translation Rules + +- **CRITICAL**: Always update translations in `en-GB` only, never `en-US` +- Translation files are located in `frontend/public/locales/` + +## Important Notes + +- **Java Version**: Minimum JDK 17, supports and recommends JDK 21 +- **Lombok**: Used extensively - ensure IDE plugin is installed +- **Desktop Mode**: Set `STIRLING_PDF_DESKTOP_UI=true` for desktop application mode +- **File Persistence**: + - **Backend**: Designed to be stateless - files are processed in memory/temp locations only + - **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails) +- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation +- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling +- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code +- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`) +- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes +- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation) +- **Adding Tools**: See `ADDING_TOOLS.md` for complete guide to creating new PDF tools + +## Communication Style +- Be direct and to the point +- No apologies or conversational filler +- Answer questions directly without preamble +- Explain reasoning concisely when asked +- Avoid unnecessary elaboration + +## Decision Making +- Ask clarifying questions before making assumptions +- Stop and ask when uncertain about project-specific details +- Confirm approach before making structural changes +- Request guidance on preferences (cross-platform vs specific tools, etc.) +- Verify understanding of requirements before proceeding diff --git a/DeveloperGuide.md b/DeveloperGuide.md new file mode 100644 index 000000000..0728a1cdc --- /dev/null +++ b/DeveloperGuide.md @@ -0,0 +1,692 @@ +# Stirling-PDF Developer Guide + +## 1. Introduction + +Stirling-PDF is a robust, locally hosted, web-based PDF manipulation tool. **Stirling 2.0** represents a complete frontend rewrite, replacing the legacy Thymeleaf-based UI with a modern React SPA (Single Page Application). + +This guide focuses on developing for Stirling 2.0, including both the React frontend and Spring Boot backend development workflows. + +## 2. Project Overview + +**Stirling 2.0** is built using: + +**Backend:** +- Spring Boot (Java 17+, JDK 21 recommended) +- PDFBox for core PDF operations +- LibreOffice for document conversions +- qpdf for PDF optimization +- Spring Security (optional, controlled by `DOCKER_ENABLE_SECURITY`) +- Lombok for reducing boilerplate code + +**Frontend (React SPA):** +- React + TypeScript +- Vite for build tooling and development server +- Mantine UI component library +- TailwindCSS for styling +- PDF.js for client-side PDF rendering +- PDF-LIB.js for client-side PDF manipulation +- IndexedDB for client-side file storage and thumbnails +- i18next for internationalization + +**Infrastructure:** +- Docker for containerization +- Gradle for build management + +**Legacy (reference only during development):** +- Thymeleaf templates (being completely replaced in 2.0) + +## 3. Development Environment Setup + +### Prerequisites + +- Docker +- Git +- Java JDK 17 or later (JDK 21 recommended) +- Node.js 18+ and npm (required for frontend development) +- Gradle 7.0 or later (Included within the repo) + +### Setup Steps + +1. Clone the repository: + + ```bash + git clone https://github.com/Stirling-Tools/Stirling-PDF.git + cd Stirling-PDF + ``` + +2. Install Docker and JDK17 if not already installed. + +3. Install a recommended Java IDE such as Eclipse, IntelliJ, or VSCode + 1. Only VSCode + 1. Open VS Code. + 2. When prompted, install the recommended extensions. + 3. Alternatively, open the command palette (`Ctrl + Shift + P` or `Cmd + Shift + P` on macOS) and run: + + ```sh + Extensions: Show Recommended Extensions + ``` + + 4. Install the required extensions from the list. + +4. Lombok Setup +Stirling-PDF uses Lombok to reduce boilerplate code. Some IDEs, like Eclipse, don't support Lombok out of the box. To set up Lombok in your development environment: +Visit the [Lombok website](https://projectlombok.org/setup/) for installation instructions specific to your IDE. + +5. Add environment variable +For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step. +5. **Frontend Setup (Required for Stirling 2.0)** + Navigate to the frontend directory and install dependencies using npm. + +## 4. Stirling 2.0 Development Workflow + +### Frontend Development (React) +The frontend is a React SPA that runs independently during development: + +1. **Start the backend**: Run the Spring Boot application (serves API endpoints on localhost:8080) +2. **Start the frontend dev server**: Navigate to the frontend directory and run the development server (serves UI on localhost:5173) +3. **Development flow**: The Vite dev server automatically proxies API calls to the backend + +### File Storage Architecture +Stirling 2.0 uses client-side file storage: +- **IndexedDB**: Stores files locally in the browser with automatic thumbnail generation +- **PDF.js**: Handles client-side PDF rendering and processing +- **URL Parameters**: Support for deep linking and tool state persistence + +### Legacy Code Reference +The existing Thymeleaf templates remain in the codebase during development as reference material but will be completely removed for the 2.0 release. + +## 5. Project Structure + +```bash +Stirling-PDF/ +├── .github/ # GitHub-specific files (workflows, issue templates) +├── configs/ # Configuration files used by stirling at runtime (generated at runtime) +├── frontend/ # React SPA frontend (Stirling 2.0) +│ ├── src/ +│ │ ├── components/ # React components +│ │ ├── tools/ # Tool-specific React components +│ │ ├── hooks/ # Custom React hooks +│ │ ├── services/ # API and utility services +│ │ ├── types/ # TypeScript type definitions +│ │ └── utils/ # Utility functions +│ ├── public/ +│ │ └── locales/ # Internationalization files (JSON) +│ ├── package.json # Frontend dependencies +│ └── vite.config.ts # Vite configuration +├── customFiles/ # Custom static files and templates (generated at runtime used to replace existing files) +├── docs/ # Documentation files +├── exampleYmlFiles/ # Example YAML configuration files +├── images/ # Image assets +├── pipeline/ # Pipeline-related files (generated at runtime) +├── scripts/ # Utility scripts +├── src/ # Source code +│ ├── main/ +│ │ ├── java/ +│ │ │ └── stirling/ +│ │ │ └── software/ +│ │ │ └── SPDF/ +│ │ │ ├── config/ +│ │ │ ├── controller/ +│ │ │ ├── model/ +│ │ │ ├── repository/ +│ │ │ ├── service/ +│ │ │ └── utils/ +│ │ └── resources/ +│ │ ├── static/ # Legacy static assets (reference only) +│ │ │ ├── css/ +│ │ │ ├── js/ +│ │ │ └── pdfjs/ +│ │ └── templates/ # Legacy Thymeleaf templates (reference only) +│ └── test/ +├── testing/ # Cucumber and integration tests +│ └── cucumber/ # Cucumber test files +├── build.gradle # Gradle build configuration +├── Dockerfile # Main Dockerfile +├── Dockerfile.ultra-lite # Dockerfile for ultra-lite version +├── Dockerfile.fat # Dockerfile for fat version +├── docker-compose.yml # Docker Compose configuration +└── test.sh # Test script to deploy all docker versions and run cuke tests +``` + +## 6. Docker-based Development + +Stirling-PDF offers several Docker versions: + +- Full: All features included +- Ultra-Lite: Basic PDF operations only +- Fat: Includes additional libraries and fonts predownloaded + +### Example Docker Compose Files + +Stirling-PDF provides several example Docker Compose files in the `exampleYmlFiles` directory, such as: + +- `docker-compose-latest.yml`: Latest version without login and security features +- `docker-compose-latest-security.yml`: Latest version with login and security features enabled +- `docker-compose-latest-fat-security.yml`: Fat version with login and security features enabled + +These files provide pre-configured setups for different scenarios. For example, here's a snippet from `docker-compose-latest-security.yml`: + +```yaml +services: + stirling-pdf: + container_name: Stirling-PDF-Security + image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest + deploy: + resources: + limits: + memory: 4G + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -q 'Please sign in'"] + interval: 5s + timeout: 10s + retries: 16 + ports: + - "8080:8080" + volumes: + - ./stirling/latest/data:/usr/share/tessdata:rw + - ./stirling/latest/config:/configs:rw + - ./stirling/latest/logs:/logs:rw + environment: + DISABLE_ADDITIONAL_FEATURES: "false" + SECURITY_ENABLELOGIN: "true" + PUID: 1002 + PGID: 1002 + UMASK: "022" + SYSTEM_DEFAULTLOCALE: en-US + UI_APPNAME: Stirling-PDF + UI_HOMEDESCRIPTION: Demo site for Stirling-PDF Latest with Security + UI_APPNAMENAVBAR: Stirling-PDF Latest + SYSTEM_MAXFILESIZE: "100" + METRICS_ENABLED: "true" + SYSTEM_GOOGLEVISIBILITY: "true" + SHOW_SURVEY: "true" + restart: on-failure:5 +``` + +To use these example files, copy the desired file to your project root and rename it to `docker-compose.yml`, or specify the file explicitly when running Docker Compose: + +```bash +docker-compose -f exampleYmlFiles/docker-compose-latest-security.yml up +``` + +### Building Docker Images + +Stirling-PDF uses different Docker images for various configurations. The build process is controlled by environment variables and uses specific Dockerfile variants. Here's how to build the Docker images: + +1. Set the security environment variable: + + ```bash + export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds + ``` + +2. Build the project with Gradle: + + ```bash + ./gradlew clean build + ``` + +3. Build the Docker images: + + For the latest version: + + ```bash + docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest -f ./Dockerfile . + ``` + + For the ultra-lite version: + + ```bash + docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite . + ``` + + For the fat version (with login and security features enabled): + + ```bash + export DISABLE_ADDITIONAL_FEATURES=false + docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat . + ``` + +Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. however to improve build times these can often be removed depending on your usecase + +## 7. Testing + +### Comprehensive Testing Script + +Stirling-PDF provides a `test.sh` script in the root directory. This script builds all versions of Stirling-PDF, checks that each version works, and runs Cucumber tests. It's recommended to run this script before submitting a final pull request. + +To run the test script: + +```bash +./test.sh +``` + +This script performs the following actions: + +1. Builds all Docker images (full, ultra-lite, fat). +2. Runs each version to ensure it starts correctly. +3. Executes Cucumber tests against the main version and ensures feature compatibility. In the event these tests fail, your PR will not be merged. + +Note: The `test.sh` script will run automatically when you raise a PR. However, it's recommended to run it locally first to save resources and catch any issues early. + +### Full Testing with Docker + +1. Build and run the Docker container per the above instructions: + +2. Access the application at `http://localhost:8080` and manually test all features developed. + +### Frontend Development Testing (Stirling 2.0) + +For React frontend development: + +1. Start the backend: Run the Spring Boot application to serve API endpoints on localhost:8080 +2. Start the frontend dev server: Navigate to the frontend directory and run the development server on localhost:5173 +3. The Vite dev server automatically proxies API calls to the backend +4. Test React components, UI interactions, and IndexedDB file operations using browser developer tools + +### Local Testing (Java and UI Components) + +For quick iterations and development of Java backend, JavaScript, and UI components, you can run and test Stirling-PDF locally without Docker. This approach allows you to work on and verify changes to: + +- Java backend logic +- RESTful API endpoints +- JavaScript functionality +- User interface components and styling +- Thymeleaf templates + +To run Stirling-PDF locally: + +1. Compile and run the project using built-in IDE methods or by running: + + ```bash + ./gradlew bootRun + ``` + +2. Access the application at `http://localhost:8080` in your web browser. + +3. Manually test the features you're working on through the UI. + +4. For API changes, use tools like Postman or curl to test endpoints directly. + +Important notes: + +- Local testing doesn't include features that depend on external tools like qpdf, LibreOffice, or Python scripts. +- There are currently no automated unit tests. All testing is done manually through the UI or API calls. (You are welcome to add JUnits!) +- Always verify your changes in the full Docker environment before submitting pull requests, as some integrations and features will only work in the complete setup. + +## 8. Contributing + +1. Fork the repository on GitHub. +2. Create a new branch for your feature or bug fix. +3. Make your changes and commit them with clear, descriptive messages and ensure any documentation is updated related to your changes. +4. Test your changes thoroughly in the Docker environment. +5. Run the `test.sh` script to ensure all versions build correctly and pass the Cucumber tests: + + ```bash + ./test.sh + ``` + +6. Push your changes to your fork. +7. Submit a pull request to the main repository. +8. See additional [contributing guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md). + +When you raise a PR: + +- The `test.sh` script will run automatically against your PR. +- The PR checks will verify versioning and dependency updates. +- Documentation will be automatically updated for dependency changes. +- Security issues will be checked using Snyk and PixeeBot. + +Address any issues that arise from these checks before finalizing your pull request. + +## 9. API Documentation + +API documentation is available at `/swagger-ui/index.html` when running the application. You can also view the latest API documentation [here](https://app.swaggerhub.com/apis-docs/Stirling-Tools/Stirling-PDF/). + +## 10. Customization + +Stirling-PDF can be customized through environment variables or a `settings.yml` file. Key customization options include: + +- Application name and branding +- Security settings +- UI customization +- Endpoint management + +When using Docker, pass environment variables using the `-e` flag or in your `docker-compose.yml` file. + +Example: + +```bash +docker run -p 8080:8080 -e APP_NAME="My PDF Tool" stirling-pdf:full +``` + +Refer to the main README for a full list of customization options. + +## 11. Language Translations + +For managing language translations that affect multiple files, Stirling-PDF provides a helper script: + +```bash +/scripts/replace_translation_line.sh +``` + +This script helps you make consistent replacements across language files. + +When contributing translations: + +1. Use the helper script for multi-file changes. +2. Ensure all language files are updated consistently. +3. The PR checks will verify consistency in language file updates. + +Remember to test your changes thoroughly to ensure they don't break any existing functionality. + +## Code examples + +### React Component Development (Stirling 2.0) + +For Stirling 2.0, new features are built as React components instead of Thymeleaf templates: + +#### Creating a New Tool Component + +1. **Create the React Component:** + ```typescript + // frontend/src/tools/NewTool.tsx + import { useState } from 'react'; + import { Button, FileInput, Container } from '@mantine/core'; + + interface NewToolProps { + params: Record; + updateParams: (updates: Record) => void; + } + + export default function NewTool({ params, updateParams }: NewToolProps) { + const [files, setFiles] = useState([]); + + const handleProcess = async () => { + // Process files using API or client-side logic + }; + + return ( + + + + + ); + } + ``` + +2. **Add API Integration:** + ```typescript + // Use existing API endpoints or create new ones + const response = await fetch('/api/v1/new-tool', { + method: 'POST', + body: formData + }); + ``` + +3. **Register in Tool Picker:** + Update the tool picker component to include the new tool with proper routing and URL parameter support. + +### Legacy Reference: Overview of Thymeleaf + +Thymeleaf is a server-side Java HTML template engine. It is used in Stirling-PDF to render dynamic web pages. Thymeleaf integrates heavily with Spring Boot. + +### Thymeleaf overview + +In Stirling-PDF, Thymeleaf is used to create HTML templates that are rendered on the server side. These templates are located in the `stirling-pdf/src/main/resources/templates` directory. Thymeleaf templates use a combination of HTML and special Thymeleaf attributes to dynamically generate content. + +Some examples of this are: + +```html + +``` +or +```html + +``` + +Where it uses the `th:block`, `th:` indicating it's a special Thymeleaf element to be used server-side in generating the HTML, and block being the actual element type. +In this case, we are inserting the `navbar` entry within the `fragments/navbar.html` fragment into the `th:block` element. + +They can be more complex, such as: + +```html + +``` + +Which is the same as above but passes the parameters title and header into the fragment `common.html` to be used in its HTML generation. + +Thymeleaf can also be used to loop through objects or pass things from the Java side into the HTML side. + +```java + @GetMapping + public String newFeaturePage(Model model) { + model.addAttribute("exampleData", exampleData); + return "new-feature"; + } +``` + +In the above example, if exampleData is a list of plain java objects of class Person and within it, you had id, name, age, etc. You can reference it like so + +```html + + + + + + + + + +``` + +This would generate n entries of tr for each person in exampleData + +### Adding a New Feature to the Backend (API) + +1. **Create a New Controller:** + - Create a new Java class in the `stirling-pdf/src/main/java/stirling/software/SPDF/controller/api` directory. + - Annotate the class with `@RestController` and `@RequestMapping` to define the API endpoint. + - Ensure to add API documentation annotations like `@Tag(name = "General", description = "General APIs")` and `@Operation(summary = "Crops a PDF document", description = "This operation takes an input PDF file and crops it according to the given coordinates. Input:PDF Output:PDF Type:SISO")`. + + ```java + package stirling.software.SPDF.controller.api; + + import org.springframework.web.bind.annotation.GetMapping; + import org.springframework.web.bind.annotation.RequestMapping; + import org.springframework.web.bind.annotation.RestController; + import io.swagger.v3.oas.annotations.Operation; + import io.swagger.v3.oas.annotations.tags.Tag; + + @RestController + @RequestMapping("/api/v1/new-feature") + @Tag(name = "General", description = "General APIs") + public class NewFeatureController { + + @GetMapping + @Operation(summary = "New Feature", description = "This is a new feature endpoint.") + public String newFeature() { + return "NewFeatureResponse"; // This refers to the NewFeatureResponse.html template presenting the user with the generated html from that file when they navigate to /api/v1/new-feature + } + } + ``` + +2. **Define the Service Layer:** (Not required but often useful) + - Create a new service class in the `stirling-pdf/src/main/java/stirling/software/SPDF/service` directory. + - Implement the business logic for the new feature. + + ```java + package stirling.software.SPDF.service; + + import org.springframework.stereotype.Service; + + @Service + public class NewFeatureService { + + public String getNewFeatureData() { + // Implement business logic here + return "New Feature Data"; + } + } + ``` + +2b. **Integrate the Service with the Controller:** + +- Autowire the service class in the controller and use it to handle the API request. + + ```java + package stirling.software.SPDF.controller.api; + + import org.springframework.beans.factory.annotation.Autowired; + import org.springframework.web.bind.annotation.GetMapping; + import org.springframework.web.bind.annotation.RequestMapping; + import org.springframework.web.bind.annotation.RestController; + import stirling.software.SPDF.service.NewFeatureService; + import io.swagger.v3.oas.annotations.Operation; + import io.swagger.v3.oas.annotations.tags.Tag; + + @RestController + @RequestMapping("/api/v1/new-feature") + @Tag(name = "General", description = "General APIs") + public class NewFeatureController { + + @Autowired + private NewFeatureService newFeatureService; + + @GetMapping + @Operation(summary = "New Feature", description = "This is a new feature endpoint.") + public String newFeature() { + return newFeatureService.getNewFeatureData(); + } + } + ``` + +### Adding a New Feature to the Frontend (UI) + +1. **Create a New Thymeleaf Template:** + - Create a new HTML file in the `stirling-pdf/src/main/resources/templates` directory. + - Use Thymeleaf attributes to dynamically generate content. + - Use `extract-page.html` as a base example for the HTML template, which is useful to ensure importing of the general layout, navbar, and footer. + + ```html + + + + + + + +
+
+ +

+
+
+
+
+ upload + +
+ +
+ +
+ + +
+ + + +
+
+
+
+ +
+ + + ``` + +2. **Create a New Controller for the UI:** + - Create a new Java class in the `stirling-pdf/src/main/java/stirling/software/SPDF/controller/ui` directory. + - Annotate the class with `@Controller` and `@RequestMapping` to define the UI endpoint. + + ```java + package stirling.software.SPDF.controller.ui; + + import org.springframework.beans.factory.annotation.Autowired; + import org.springframework.stereotype.Controller; + import org.springframework.ui.Model; + import org.springframework.web.bind.annotation.GetMapping; + import org.springframework.web.bind.annotation.RequestMapping; + import stirling.software.SPDF.service.NewFeatureService; + + @Controller + @RequestMapping("/new-feature") + public class NewFeatureUIController { + + @Autowired + private NewFeatureService newFeatureService; + + @GetMapping + public String newFeaturePage(Model model) { + model.addAttribute("newFeatureData", newFeatureService.getNewFeatureData()); + return "new-feature"; + } + } + ``` + +3. **Update the Navigation Bar:** + - Add a link to the new feature page in the navigation bar. + - Update the `stirling-pdf/src/main/resources/templates/fragments/navbar.html` file. + + ```html + + ``` + +## Adding New Translations to Existing Language Files in Stirling-PDF + +When adding a new feature or modifying existing ones in Stirling-PDF, you'll need to add new translation entries to the existing language files. Here's a step-by-step guide: + +### 1. Locate Existing Language Files + +Find the existing `messages.properties` files in the `stirling-pdf/src/main/resources` directory. You'll see files like: + +- `messages.properties` (default, usually English) +- `messages_en_GB.properties` +- `messages_fr_FR.properties` +- `messages_de_DE.properties` +- etc. + +### 2. Add New Translation Entries + +Open each of these files and add your new translation entries. For example, if you're adding a new feature called "PDF Splitter", +Use descriptive, hierarchical keys (e.g., `feature.element.description`) +you might add: + +```properties +pdfSplitter.title=PDF Splitter +pdfSplitter.description=Split your PDF into multiple documents +pdfSplitter.button.split=Split PDF +pdfSplitter.input.pages=Enter page numbers to split +``` + +Add these entries to the default GB language file and any others you wish, translating the values as appropriate for each language. + +### 3. Use Translations in Thymeleaf Templates + +In your Thymeleaf templates, use the `#{key}` syntax to reference the new translations: + +```html +

PDF Splitter

+

Split your PDF into multiple documents

+ + +``` + +Remember, never hard-code text in your templates or Java code. Always use translation keys to ensure proper localization. diff --git a/Dockerfile.dev b/Dockerfile.dev deleted file mode 100644 index 517e94b95..000000000 --- a/Dockerfile.dev +++ /dev/null @@ -1,61 +0,0 @@ -# dockerfile.dev - -# Basisimage: Gradle mit JDK 17 (Debian-basiert) -FROM gradle:8.14-jdk17 - -# Als Root-Benutzer arbeiten, um benötigte Pakete zu installieren -USER root - -# Set GRADLE_HOME und füge Gradle zum PATH hinzu -ENV GRADLE_HOME=/opt/gradle -ENV PATH="$GRADLE_HOME/bin:$PATH" - -# Update und Installation zusätzlicher Pakete (Debian/Ubuntu-basiert) -RUN apt-get update && apt-get install -y \ - sudo \ - libreoffice \ - poppler-utils \ - qpdf \ -# settings.yml | tessdataDir: /usr/share/tesseract-ocr/5/tessdata - tesseract-ocr \ - tesseract-ocr-eng \ - ffmpeg \ - fonts-terminus fonts-dejavu fonts-font-awesome fonts-noto fonts-noto-core fonts-noto-cjk fonts-noto-extra fonts-liberation fonts-linuxlibertine fonts-urw-base35 \ - python3-uno \ - python3-venv \ -# ss -tln - iproute2 \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Setze die Environment Variable für setuptools -ENV SETUPTOOLS_USE_DISTUTILS=local \ - STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \ - TMPDIR=/tmp/stirling-pdf \ - TEMP=/tmp/stirling-pdf \ - TMP=/tmp/stirling-pdf - -# Installation der benötigten Python-Pakete -COPY .github/scripts/requirements_dev.txt /tmp/requirements_dev.txt -RUN python3 -m venv --system-site-packages /opt/venv \ - && . /opt/venv/bin/activate \ - && pip install --no-cache-dir --require-hashes -r /tmp/requirements_dev.txt - -# Füge den venv-Pfad zur globalen PATH-Variable hinzu, damit die Tools verfügbar sind -ENV PATH="/opt/venv/bin:$PATH" - -COPY . /workspace - -RUN mkdir -p /tmp/stirling-pdf \ - && fc-cache -f -v \ - && adduser --disabled-password --gecos '' devuser \ - && chown -R devuser:devuser /home/devuser /workspace /tmp/stirling-pdf -RUN echo "devuser ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/devuser \ - && chmod 0440 /etc/sudoers.d/devuser - -# Setze das Arbeitsverzeichnis (wird später per Bind-Mount überschrieben) -WORKDIR /workspace - -RUN chmod +x /workspace/.devcontainer/git-init.sh /workspace/.devcontainer/init-setup.sh - -# Wechsel zum Nicht‑Root Benutzer -USER devuser diff --git a/app/common/build.gradle b/app/common/build.gradle index 4dcf57cc8..0254e60d7 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -29,7 +29,7 @@ spotless { dependencies { api 'org.springframework.boot:spring-boot-starter-web' api 'org.springframework.boot:spring-boot-starter-aop' - api 'org.springframework.boot:spring-boot-starter-thymeleaf' + // api 'org.springframework.boot:spring-boot-starter-thymeleaf' // Deprecated - UI moved to React frontend api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1' api 'com.fathzer:javaluator:3.0.6' api 'com.posthog.java:posthog:1.2.0' diff --git a/app/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java b/app/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java index 0581d9a29..766d605b2 100644 --- a/app/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java +++ b/app/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java @@ -7,6 +7,8 @@ import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; +import io.swagger.v3.oas.annotations.parameters.RequestBody; + /** * Shortcut for a POST endpoint that is executed through the Stirling "auto‑job" framework. * @@ -30,6 +32,7 @@ import org.springframework.web.bind.annotation.RequestMethod; @Retention(RetentionPolicy.RUNTIME) @Documented @RequestMapping(method = RequestMethod.POST) +@RequestBody(required = true) public @interface AutoJobPostMapping { /** Alias for {@link RequestMapping#value} – the path mapping of the endpoint. */ diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/AccountSecurityApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/AccountSecurityApi.java new file mode 100644 index 000000000..34680febf --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/annotations/api/AccountSecurityApi.java @@ -0,0 +1,48 @@ +package stirling.software.common.annotations.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Combined annotation for Account Security API controllers. + * Includes @RestController, @RequestMapping("/api/v1/account"), and OpenAPI @Tag. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@RestController +@RequestMapping("/api/v1/account") +@Tag( + name = "Account Security", + description = + """ + Account security and protection services for user safety and organizational compliance. + + This endpoint group provides account security capabilities for organizations requiring + enhanced protection against unauthorized access, security threats, and compliance violations. + + Common use cases: + • Corporate security policy compliance and SOX, HIPAA, GDPR requirements + • Fraud prevention, identity theft protection, and account compromise recovery + • Multi-factor authentication implementation and insider threat mitigation + • Account recovery and emergency access procedures + + Business applications: + • Enterprise risk management, security governance, and customer trust protection + • Legal liability reduction and insurance requirement fulfillment + • Audit preparation, compliance reporting, and business continuity management + + Operational scenarios: + • Security incident response, forensic investigation, and user training + • Emergency account lockdown, suspicious activity monitoring, and compliance documentation + + Target users: Security administrators, compliance officers, and organizations + prioritizing account security and regulatory compliance. + """) +public @interface AccountSecurityApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/AdminApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/AdminApi.java new file mode 100644 index 000000000..3469d1a20 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/annotations/api/AdminApi.java @@ -0,0 +1,48 @@ +package stirling.software.common.annotations.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Combined annotation for Admin Settings API controllers. + * Includes @RestController, @RequestMapping("/api/v1/admin/settings"), and OpenAPI @Tag. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@RestController +@RequestMapping("/api/v1/admin/settings") +@Tag( + name = "Admin Settings", + description = + """ + System administration and configuration management for enterprise deployments. + + This endpoint group provides administrative control for organizations deploying + Stirling PDF in production environments with multi-user scenarios. + + Common use cases: + • Enterprise deployment configuration and multi-tenant environment management + • Security policy enforcement, compliance monitoring, and capacity planning + • Operational maintenance, troubleshooting, and enterprise infrastructure integration + • Disaster recovery and business continuity preparation + + Business applications: + • Corporate IT governance, policy enforcement, and compliance reporting + • Cost optimization, SLA monitoring, and vendor management oversight + • Risk management and security incident response + + Operational scenarios: + • 24/7 production monitoring, scheduled maintenance, and system updates + • Emergency response, change management, and performance optimization + + Target users: IT administrators, system engineers, and operations teams + responsible for enterprise-grade document processing infrastructure. + """) +public @interface AdminApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/AdminServerCertificateApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/AdminServerCertificateApi.java new file mode 100644 index 000000000..34bbe5f42 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/annotations/api/AdminServerCertificateApi.java @@ -0,0 +1,46 @@ +package stirling.software.common.annotations.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Combined annotation for Admin Server Certificate API controllers. + * Includes @RestController, @RequestMapping("/api/v1/admin/server-certificate"), and OpenAPI @Tag. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@RestController +@RequestMapping("/api/v1/admin/server-certificate") +@Tag( + name = "Admin - Server Certificate", + description = + """ + Server certificate management for secure enterprise deployments and encrypted communications. + + This endpoint group provides certificate lifecycle management for organizations + requiring secure communications in document processing infrastructure. + + Common use cases: + • Corporate security compliance and encrypted communications for healthcare/finance + • Customer data protection, internal audits, and multi-environment standardization + • Third-party security assessments and disaster recovery security measures + + Business applications: + • Enterprise security governance, client trust protection, and secure B2B exchange + • Legal requirement fulfillment, liability reduction, and M&A security preparation + + Operational scenarios: + • Certificate renewal, emergency replacement, and security incident response + • Multi-site deployment coordination and cloud migration preparation + + Target users: Security administrators, compliance officers, and IT infrastructure + teams requiring enterprise-grade security for document processing systems. + """) +public @interface AdminServerCertificateApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/AnalysisApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/AnalysisApi.java new file mode 100644 index 000000000..31de4c8d2 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/annotations/api/AnalysisApi.java @@ -0,0 +1,46 @@ +package stirling.software.common.annotations.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Combined annotation for Analysis API controllers. + * Includes @RestController, @RequestMapping("/api/v1/analysis"), and OpenAPI @Tag. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@RestController +@RequestMapping("/api/v1/analysis") +@Tag( + name = "Analysis", + description = + """ + Document analysis and information extraction services for content intelligence and insights. + + This endpoint group provides analytical capabilities to understand document structure, + extract information, and generate insights from PDF content for automated processing. + + Common use cases: + • Document inventory management and content audit for compliance verification + • Quality assurance workflows and business intelligence analytics + • Migration planning, accessibility evaluation, and document forensics + + Business applications: + • Legal discovery, financial document review, and healthcare records analysis + • Academic research, government processing, and publishing optimization + + Operational scenarios: + • Large-scale profiling, migration assessment, and performance optimization + • Automated quality control and content strategy development + + Target users: Data analysts, QA teams, administrators, and business intelligence + professionals requiring detailed document insights. + """) +public @interface AnalysisApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/ConfigApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/ConfigApi.java new file mode 100644 index 000000000..85175adc4 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/annotations/api/ConfigApi.java @@ -0,0 +1,46 @@ +package stirling.software.common.annotations.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Combined annotation for Configuration API controllers. + * Includes @RestController, @RequestMapping("/api/v1/config"), and OpenAPI @Tag. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@RestController +@RequestMapping("/api/v1/config") +@Tag( + name = "Config", + description = + """ + System configuration management for deployment optimization and operational control. + + This endpoint group provides system configuration capabilities for organizations + deploying and operating Stirling PDF in various environments. + + Common use cases: + • Environment-specific deployment and performance tuning for varying workloads + • Resource optimization, cost management, and infrastructure integration + • Compliance configuration, disaster recovery, and multi-environment standardization + + Business applications: + • Operational cost optimization, SLA compliance, and risk management + • Vendor integration, change management, and capacity planning + + Operational scenarios: + • System deployment, performance troubleshooting, and emergency changes + • Planned maintenance and multi-site deployment coordination + + Target users: System administrators, DevOps engineers, and IT operations teams + responsible for deployment configuration and system optimization. + """) +public @interface ConfigApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/ConvertApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/ConvertApi.java new file mode 100644 index 000000000..9d9d85bd7 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/annotations/api/ConvertApi.java @@ -0,0 +1,46 @@ +package stirling.software.common.annotations.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Combined annotation for Convert API controllers. + * Includes @RestController, @RequestMapping("/api/v1/convert"), and OpenAPI @Tag. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@RestController +@RequestMapping("/api/v1/convert") +@Tag( + name = "Convert", + description = + """ + Document format transformation services for cross-platform compatibility and workflow integration. + + This endpoint group enables transformation between various formats, supporting + diverse business workflows and system integrations for mixed document ecosystems. + + Common use cases: + • Legacy system integration, document migration, and cross-platform sharing + • Archive standardization, publishing preparation, and content adaptation + • Accessibility compliance and mobile-friendly document preparation + + Business applications: + • Enterprise content management, digital publishing, and educational platforms + • Legal document processing, healthcare interoperability, and government standardization + + Integration scenarios: + • API-driven pipelines, automated workflow preparation, and batch conversions + • Real-time format adaptation for user requests + + Target users: System integrators, content managers, digital archivists, and + organizations requiring flexible document format interoperability. + """) +public @interface ConvertApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/DatabaseApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/DatabaseApi.java new file mode 100644 index 000000000..23c1ff41f --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/annotations/api/DatabaseApi.java @@ -0,0 +1,46 @@ +package stirling.software.common.annotations.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Combined annotation for Database Management API controllers. + * Includes @RestController, @RequestMapping("/api/v1/database"), and OpenAPI @Tag. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@RestController +@RequestMapping("/api/v1/database") +@Tag( + name = "Database", + description = + """ + Database operations for data protection and business continuity management. + + This endpoint group provides essential database operations for organizations requiring + reliable data protection and recovery capabilities. + + Common use cases: + • Regular data backup, disaster recovery, and business continuity planning + • System migration, compliance management, and development environment support + • Operational troubleshooting and scheduled maintenance operations + + Business applications: + • Risk management, regulatory compliance, and operational resilience + • Data governance, change management, and quality assurance support + + Operational scenarios: + • Routine backup, emergency recovery, and system maintenance preparation + • Data migration projects and performance monitoring + + Target users: Operations teams, system administrators, and organizations requiring + reliable data protection and operational database management. + """) +public @interface DatabaseApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/DatabaseManagementApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/DatabaseManagementApi.java new file mode 100644 index 000000000..7da55c3bc --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/annotations/api/DatabaseManagementApi.java @@ -0,0 +1,46 @@ +package stirling.software.common.annotations.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Combined annotation for Database Management API controllers. + * Includes @RestController, @RequestMapping("/api/v1/admin/database"), and OpenAPI @Tag. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@RestController +@RequestMapping("/api/v1/admin/database") +@Tag( + name = "Database Management", + description = + """ + Enterprise database administration for production data management and business continuity. + + This endpoint group provides database administration capabilities for organizations + operating Stirling PDF in production environments. + + Common use cases: + • Business continuity, disaster recovery, and regulatory compliance requirements + • Performance optimization, data security, and system migration projects + • Audit preparation, compliance reporting, and cost optimization + + Business applications: + • Enterprise risk management, regulatory compliance, and SLA monitoring + • Data retention policies, security incident response, and vendor oversight + + Operational scenarios: + • Scheduled maintenance, emergency recovery, and capacity planning + • Performance troubleshooting and multi-environment deployment coordination + + Target users: Database administrators, IT operations teams, and enterprise + administrators responsible for production data management and system reliability. + """) +public @interface DatabaseManagementApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/FilterApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/FilterApi.java new file mode 100644 index 000000000..2c925609e --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/annotations/api/FilterApi.java @@ -0,0 +1,46 @@ +package stirling.software.common.annotations.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Combined annotation for Filter API controllers. + * Includes @RestController, @RequestMapping("/api/v1/filter"), and OpenAPI @Tag. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@RestController +@RequestMapping("/api/v1/filter") +@Tag( + name = "Filter", + description = + """ + Document content filtering and search operations for information discovery and organization. + + This endpoint group enables intelligent content discovery and organization within + document collections for content-based processing and information extraction. + + Common use cases: + • Legal discovery, research organization, and compliance auditing + • Content moderation, academic research, and business intelligence + • Quality assurance and content validation workflows + + Business applications: + • Contract analysis, financial review, and healthcare records organization + • Government processing, educational curation, and IP protection + + Workflow scenarios: + • Large-scale processing, automated classification, and information extraction + • Document preparation for further processing or analysis + + Target users: Legal professionals, researchers, compliance officers, and + organizations requiring intelligent document content discovery and organization. + """) +public @interface FilterApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/GeneralApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/GeneralApi.java new file mode 100644 index 000000000..535f47773 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/annotations/api/GeneralApi.java @@ -0,0 +1,42 @@ +package stirling.software.common.annotations.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Combined annotation for General PDF processing API controllers. + * Includes @RestController, @RequestMapping("/api/v1/general"), and OpenAPI @Tag. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@RestController +@RequestMapping("/api/v1/general") +@Tag( + name = "General", + description = + """ + Core PDF processing operations for fundamental document manipulation workflows. + + This endpoint group provides essential PDF functionality that forms the foundation + of most document processing workflows across various industries. + + Common use cases: + • Document preparation for archival systems and content organization + • File preparation for distribution, accessibility compliance, and batch processing + • Document consolidation for reporting and legal compliance workflows + + Typical applications: + • Content management, publishing workflows, and educational content distribution + • Business process automation and archive management + + Target users: Content managers, document processors, and organizations requiring + reliable foundational PDF manipulation capabilities. + """) +public @interface GeneralApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/InfoApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/InfoApi.java new file mode 100644 index 000000000..9fb5022ce --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/annotations/api/InfoApi.java @@ -0,0 +1,46 @@ +package stirling.software.common.annotations.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Combined annotation for Info API controllers. + * Includes @RestController, @RequestMapping("/api/v1/info"), and OpenAPI @Tag. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@RestController +@RequestMapping("/api/v1/info") +@Tag( + name = "Info", + description = + """ + System information and operational insights for monitoring and performance management. + + This endpoint group provides system information and operational metrics for organizations + operating Stirling PDF in production environments. + + Common use cases: + • System health monitoring, performance optimization, and capacity planning + • Troubleshooting, compliance monitoring, and SLA reporting + • Cost optimization, usage analysis, and security monitoring + + Business applications: + • Operational cost management, business continuity monitoring, and vendor management + • Compliance reporting, strategic planning, and customer service tracking + + Operational scenarios: + • 24/7 monitoring, scheduled maintenance, and emergency response coordination + • System upgrade planning and capacity scaling decisions + + Target users: Operations teams, system administrators, and management teams requiring + operational insights and system performance visibility. + """) +public @interface InfoApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/MiscApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/MiscApi.java new file mode 100644 index 000000000..ee9cf62bf --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/annotations/api/MiscApi.java @@ -0,0 +1,46 @@ +package stirling.software.common.annotations.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Combined annotation for Miscellaneous API controllers. + * Includes @RestController, @RequestMapping("/api/v1/misc"), and OpenAPI @Tag. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@RestController +@RequestMapping("/api/v1/misc") +@Tag( + name = "Misc", + description = + """ + Specialized utilities and supplementary tools for enhanced document processing workflows. + + This endpoint group provides utility operations that support core document processing + tasks and address specific workflow needs in real-world scenarios. + + Common use cases: + • Document optimization for bandwidth-limited environments and storage cost management + • Document repair, content extraction, and validation for quality assurance + • Accessibility improvement and custom processing for specialized needs + + Business applications: + • Web publishing optimization, email attachment management, and archive efficiency + • Mobile compatibility, print production, and legacy document recovery + + Operational scenarios: + • Batch processing, quality control, and performance optimization + • Troubleshooting and recovery of problematic documents + + Target users: System administrators, document specialists, and organizations requiring + specialized document processing and optimization tools. + """) +public @interface MiscApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/PipelineApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/PipelineApi.java new file mode 100644 index 000000000..38407b6a0 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/annotations/api/PipelineApi.java @@ -0,0 +1,46 @@ +package stirling.software.common.annotations.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Combined annotation for Pipeline API controllers. + * Includes @RestController, @RequestMapping("/api/v1/pipeline"), and OpenAPI @Tag. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@RestController +@RequestMapping("/api/v1/pipeline") +@Tag( + name = "Pipeline", + description = + """ + Automated document processing workflows for complex multi-stage business operations. + + This endpoint group enables organizations to create sophisticated document processing + workflows that combine multiple operations into streamlined, repeatable processes. + + Common use cases: + • Invoice processing, legal document review, and healthcare records standardization + • Government processing, educational content preparation, and publishing automation + • Contract lifecycle management and approval processes + + Business applications: + • Automated compliance reporting, large-scale migration, and quality assurance + • Archive preparation, content delivery, and document approval workflows + + Operational scenarios: + • Scheduled batch processing and event-driven document processing + • Multi-department coordination and business system integration + + Target users: Business process managers, IT automation specialists, and organizations + requiring consistent, repeatable document processing workflows. + """) +public @interface PipelineApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/ProprietaryUiDataApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/ProprietaryUiDataApi.java new file mode 100644 index 000000000..06f03ccd4 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/annotations/api/ProprietaryUiDataApi.java @@ -0,0 +1,47 @@ +package stirling.software.common.annotations.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Combined annotation for Proprietary UI Data API controllers. + * Includes @RestController, @RequestMapping("/api/v1/proprietary/ui-data"), and OpenAPI @Tag. Note: + * Controllers using this annotation should also add @EnterpriseEndpoint. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@RestController +@RequestMapping("/api/v1/proprietary/ui-data") +@Tag( + name = "Proprietary UI Data", + description = + """ + Enterprise user interface data services for commercial deployments and advanced business features. + + This endpoint group provides enhanced data services for commercial and enterprise features, + supporting advanced business workflows and professional-grade functionality. + + Common use cases: + • Enterprise-grade dashboards, multi-tenant deployment, and business intelligence + • Organizational hierarchy management and commercial feature licensing + • Professional support integration and advanced workflow automation + + Business applications: + • ERP integration, CRM development, and executive reporting dashboards + • Multi-subsidiary management, professional service delivery, and compliance interfaces + + Operational scenarios: + • Large-scale deployment management and white-label solution development + • Advanced system integration and commercial feature rollout + + Target users: Enterprise administrators, business analysts, and organizations utilizing + commercial features and advanced business capabilities. + """) +public @interface ProprietaryUiDataApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/SecurityApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/SecurityApi.java new file mode 100644 index 000000000..fe9756d3f --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/annotations/api/SecurityApi.java @@ -0,0 +1,46 @@ +package stirling.software.common.annotations.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Combined annotation for Security API controllers. + * Includes @RestController, @RequestMapping("/api/v1/security"), and OpenAPI @Tag. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@RestController +@RequestMapping("/api/v1/security") +@Tag( + name = "Security", + description = + """ + Document security and protection services for confidential and sensitive content. + + This endpoint group provides essential security operations for organizations handling + sensitive documents and materials requiring controlled access. + + Common use cases: + • Legal confidentiality, healthcare privacy (HIPAA), and financial regulatory compliance + • Government classified handling, corporate IP protection, and educational privacy (FERPA) + • Contract security for business transactions + + Business applications: + • Document authentication, confidential sharing, and secure archiving + • Content watermarking, access control, and privacy protection through redaction + + Industry scenarios: + • Legal discovery, medical records exchange, financial audit documentation + • Enterprise policy enforcement and data governance + + Target users: Legal professionals, healthcare administrators, compliance officers, + government agencies, and enterprises handling sensitive content. + """) +public @interface SecurityApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/SettingsApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/SettingsApi.java new file mode 100644 index 000000000..614419aaa --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/annotations/api/SettingsApi.java @@ -0,0 +1,46 @@ +package stirling.software.common.annotations.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Combined annotation for Settings API controllers. + * Includes @RestController, @RequestMapping("/api/v1/settings"), and OpenAPI @Tag. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@RestController +@RequestMapping("/api/v1/settings") +@Tag( + name = "Settings", + description = + """ + User preferences and application customization for personalized workflow optimization. + + This endpoint group provides preference management capabilities for users and + organizations to customize their document processing experience. + + Common use cases: + • Workflow optimization, accessibility compliance, and corporate branding + • Multi-language support, user personalization, and business system integration + • Organizational policy compliance + + Business applications: + • Corporate branding, productivity optimization, and accessibility compliance + • Change management facilitation and training efficiency improvement + + Operational scenarios: + • User onboarding, department-specific customization, and system migration + • Multi-tenant customization and project-based configuration adjustments + + Target users: End users, department managers, and organizations focused on optimizing + user experience and workflow efficiency through personalization. + """) +public @interface SettingsApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/TeamApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/TeamApi.java new file mode 100644 index 000000000..07e87a70e --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/annotations/api/TeamApi.java @@ -0,0 +1,46 @@ +package stirling.software.common.annotations.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Combined annotation for Team Management API controllers. + * Includes @RestController, @RequestMapping("/api/v1/team"), and OpenAPI @Tag. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@RestController +@RequestMapping("/api/v1/team") +@Tag( + name = "Team", + description = + """ + Team management and collaboration services for organized document processing workflows. + + This endpoint group enables organizations to structure collaborative document processing + activities through team-based organization and resource management. + + Common use cases: + • Department-based processing, project collaboration, and cross-functional coordination + • Client-specific team isolation, temporary project teams, and training coordination + • Compliance team coordination for regulatory processing + + Business applications: + • Matrix organization support, client service delivery, and cost center allocation + • Scalable collaboration, knowledge management, and team-based quality assurance + + Operational scenarios: + • Large-scale processing coordination and temporary team formation + • M&A integration, remote collaboration, and knowledge transfer management + + Target users: Team leaders, project managers, and organizations requiring structured + collaborative environments for document processing activities. + """) +public @interface TeamApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/UiDataApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/UiDataApi.java new file mode 100644 index 000000000..920946aed --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/annotations/api/UiDataApi.java @@ -0,0 +1,46 @@ +package stirling.software.common.annotations.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Combined annotation for UI Data API controllers. + * Includes @RestController, @RequestMapping("/api/v1/ui-data"), and OpenAPI @Tag. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@RestController +@RequestMapping("/api/v1/ui-data") +@Tag( + name = "UI Data", + description = + """ + User interface data services for dynamic frontend applications and user experience customization. + + This endpoint group provides data services for frontend applications to render personalized + interfaces and deliver optimized experiences based on system configuration. + + Common use cases: + • Dynamic UI customization, multi-language support, and feature configuration + • Real-time status delivery, corporate branding, and mobile optimization + • Progressive web application (PWA) configuration + + Business applications: + • Brand customization, user experience optimization, and accessibility compliance + • Multi-tenant customization, training support, and performance optimization + + Operational scenarios: + • Frontend deployment, UI A/B testing, and system integration + • Mobile synchronization and offline capability enhancement + + Target users: Frontend developers, UI/UX designers, and organizations requiring + customizable user interfaces and optimized user experiences. + """) +public @interface UiDataApi {} diff --git a/app/common/src/main/java/stirling/software/common/annotations/api/UserApi.java b/app/common/src/main/java/stirling/software/common/annotations/api/UserApi.java new file mode 100644 index 000000000..d1bf070b6 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/annotations/api/UserApi.java @@ -0,0 +1,46 @@ +package stirling.software.common.annotations.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Combined annotation for User Management API controllers. + * Includes @RestController, @RequestMapping("/api/v1/user"), and OpenAPI @Tag. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@RestController +@RequestMapping("/api/v1/user") +@Tag( + name = "User", + description = + """ + User management and authentication services for multi-user and enterprise environments. + + This endpoint group provides user lifecycle management capabilities for organizations + deploying Stirling PDF in multi-user scenarios. + + Common use cases: + • Employee onboarding/offboarding and corporate access control + • Department-based permissions, regulatory compliance, and SSO integration + • Multi-tenant deployment and guest user access management + + Business applications: + • Enterprise IAM integration, security governance, and cost allocation + • Compliance reporting, workflow management, and partner collaboration + + Operational scenarios: + • Large-scale provisioning, automated HR integration, and emergency access + • User migration and self-service profile maintenance + + Target users: IT administrators, HR departments, and organizations requiring + structured user management and enterprise identity integration. + """) +public @interface UserApi {} diff --git a/app/common/src/main/java/stirling/software/common/configuration/FileFallbackTemplateResolver.java b/app/common/src/main/java/stirling/software/common/configuration/FileFallbackTemplateResolver.java index 320d9aaac..9c5e0a7ae 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/FileFallbackTemplateResolver.java +++ b/app/common/src/main/java/stirling/software/common/configuration/FileFallbackTemplateResolver.java @@ -1,19 +1,8 @@ package stirling.software.common.configuration; -import java.io.IOException; -import java.io.InputStream; -import java.util.Map; +/* Commented out entire FileFallbackTemplateResolver class - Thymeleaf dependency removed + * This class will be removed when frontend migration to React is complete -import org.springframework.core.io.Resource; -import org.springframework.core.io.ResourceLoader; -import org.thymeleaf.IEngineConfiguration; -import org.thymeleaf.templateresolver.AbstractConfigurableTemplateResolver; -import org.thymeleaf.templateresource.FileTemplateResource; -import org.thymeleaf.templateresource.ITemplateResource; - -import lombok.extern.slf4j.Slf4j; - -import stirling.software.common.model.InputStreamTemplateResource; @Slf4j public class FileFallbackTemplateResolver extends AbstractConfigurableTemplateResolver { @@ -57,3 +46,4 @@ public class FileFallbackTemplateResolver extends AbstractConfigurableTemplateRe return null; } } +*/ diff --git a/app/common/src/main/java/stirling/software/common/model/InputStreamTemplateResource.java b/app/common/src/main/java/stirling/software/common/model/InputStreamTemplateResource.java index 688f26d34..bc31f1d89 100644 --- a/app/common/src/main/java/stirling/software/common/model/InputStreamTemplateResource.java +++ b/app/common/src/main/java/stirling/software/common/model/InputStreamTemplateResource.java @@ -1,11 +1,8 @@ package stirling.software.common.model; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.Reader; +/* Commented out entire InputStreamTemplateResource class - Thymeleaf dependency removed + * This class will be removed when frontend migration to React is complete -import org.thymeleaf.templateresource.ITemplateResource; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -42,3 +39,4 @@ public class InputStreamTemplateResource implements ITemplateResource { return inputStream != null; } } +*/ diff --git a/app/common/src/main/java/stirling/software/common/service/ServerCertificateServiceInterface.java b/app/common/src/main/java/stirling/software/common/service/ServerCertificateServiceInterface.java new file mode 100644 index 000000000..3d7c5d90b --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/service/ServerCertificateServiceInterface.java @@ -0,0 +1,42 @@ +package stirling.software.common.service; + +import java.io.InputStream; +import java.security.KeyStore; +import java.security.cert.X509Certificate; +import java.util.Date; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +public interface ServerCertificateServiceInterface { + + boolean isEnabled(); + + boolean hasServerCertificate(); + + void initializeServerCertificate(); + + KeyStore getServerKeyStore() throws Exception; + + String getServerCertificatePassword(); + + X509Certificate getServerCertificate() throws Exception; + + byte[] getServerCertificatePublicKey() throws Exception; + + void uploadServerCertificate(InputStream p12Stream, String password) throws Exception; + + void deleteServerCertificate() throws Exception; + + ServerCertificateInfo getServerCertificateInfo() throws Exception; + + @Getter + @AllArgsConstructor + class ServerCertificateInfo { + private final boolean exists; + private final String subject; + private final String issuer; + private final Date validFrom; + private final Date validTo; + } +} diff --git a/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java index 2955368a1..f2f03815c 100644 --- a/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java +++ b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java @@ -118,22 +118,6 @@ class ApplicationPropertiesLogicTest { assertTrue(ex.getMessage().toLowerCase(Locale.ROOT).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(); diff --git a/app/core/src/main/java/stirling/software/SPDF/UI/WebBrowser.java b/app/core/src/main/java/stirling/software/SPDF/UI/WebBrowser.java deleted file mode 100644 index b884888fe..000000000 --- a/app/core/src/main/java/stirling/software/SPDF/UI/WebBrowser.java +++ /dev/null @@ -1,7 +0,0 @@ -package stirling.software.SPDF.UI; - -public interface WebBrowser { - void initWebUI(String url); - - void cleanup(); -} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index 65bcd420d..b35810fb4 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -234,6 +234,7 @@ public class EndpointConfiguration { addEndpointToGroup("PageOps", "pdf-organizer"); addEndpointToGroup("PageOps", "rotate-pdf"); addEndpointToGroup("PageOps", "multi-page-layout"); + addEndpointToGroup("PageOps", "booklet-imposition"); addEndpointToGroup("PageOps", "scale-pages"); addEndpointToGroup("PageOps", "crop"); addEndpointToGroup("PageOps", "extract-page"); @@ -366,6 +367,7 @@ public class EndpointConfiguration { addEndpointToGroup("Java", "cert-sign"); addEndpointToGroup("Java", "remove-cert-sign"); addEndpointToGroup("Java", "multi-page-layout"); + addEndpointToGroup("Java", "booklet-imposition"); addEndpointToGroup("Java", "scale-pages"); addEndpointToGroup("Java", "add-page-numbers"); addEndpointToGroup("Java", "auto-rename"); diff --git a/app/core/src/main/java/stirling/software/SPDF/config/GlobalErrorResponseCustomizer.java b/app/core/src/main/java/stirling/software/SPDF/config/GlobalErrorResponseCustomizer.java new file mode 100644 index 000000000..d0b02fc46 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/GlobalErrorResponseCustomizer.java @@ -0,0 +1,188 @@ +package stirling.software.SPDF.config; + +import org.springdoc.core.customizers.GlobalOpenApiCustomizer; +import org.springframework.stereotype.Component; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.responses.ApiResponse; + +/** + * Global OpenAPI customizer that adds standard error responses (400, 413, 422, 500) to all API + * operations under /api/v1/** paths. + */ +@Component +public class GlobalErrorResponseCustomizer implements GlobalOpenApiCustomizer { + + @Override + public void customise(OpenAPI openApi) { + if (openApi.getPaths() == null) { + return; + } + + openApi.getPaths() + .forEach( + (path, pathItem) -> { + if (path.startsWith("/api/v1/")) { + addErrorResponsesToPathItem(pathItem); + } + }); + } + + private void addErrorResponsesToPathItem(PathItem pathItem) { + if (pathItem.getPost() != null) { + addStandardErrorResponses(pathItem.getPost()); + } + if (pathItem.getPut() != null) { + addStandardErrorResponses(pathItem.getPut()); + } + if (pathItem.getPatch() != null) { + addStandardErrorResponses(pathItem.getPatch()); + } + if (pathItem.getDelete() != null) { + addStandardErrorResponses(pathItem.getDelete()); + } + if (pathItem.getGet() != null) { + addStandardErrorResponses(pathItem.getGet()); + } + } + + private void addStandardErrorResponses(Operation operation) { + if (operation.getResponses() == null) { + return; + } + + // Only add error responses if they don't already exist + if (!operation.getResponses().containsKey("400")) { + operation.getResponses().addApiResponse("400", create400Response()); + } + if (!operation.getResponses().containsKey("413")) { + operation.getResponses().addApiResponse("413", create413Response()); + } + if (!operation.getResponses().containsKey("422")) { + operation.getResponses().addApiResponse("422", create422Response()); + } + if (!operation.getResponses().containsKey("500")) { + operation.getResponses().addApiResponse("500", create500Response()); + } + } + + private ApiResponse create400Response() { + return new ApiResponse() + .description( + "Bad request - Invalid input parameters, unsupported format, or corrupted file") + .content( + new Content() + .addMediaType( + "application/json", + new MediaType() + .schema( + createErrorSchema( + 400, + "Invalid input parameters or corrupted file", + "/api/v1/example/endpoint")) + .example( + createErrorExample( + 400, + "Invalid input parameters or corrupted file", + "/api/v1/example/endpoint")))); + } + + private ApiResponse create413Response() { + return new ApiResponse() + .description("Payload too large - File exceeds maximum allowed size") + .content( + new Content() + .addMediaType( + "application/json", + new MediaType() + .schema( + createErrorSchema( + 413, + "File size exceeds maximum allowed limit", + "/api/v1/example/endpoint")) + .example( + createErrorExample( + 413, + "File size exceeds maximum allowed limit", + "/api/v1/example/endpoint")))); + } + + private ApiResponse create422Response() { + return new ApiResponse() + .description("Unprocessable entity - File is valid but cannot be processed") + .content( + new Content() + .addMediaType( + "application/json", + new MediaType() + .schema( + createErrorSchema( + 422, + "File is valid but cannot be processed", + "/api/v1/example/endpoint")) + .example( + createErrorExample( + 422, + "File is valid but cannot be processed", + "/api/v1/example/endpoint")))); + } + + private ApiResponse create500Response() { + return new ApiResponse() + .description("Internal server error - Unexpected error during processing") + .content( + new Content() + .addMediaType( + "application/json", + new MediaType() + .schema( + createErrorSchema( + 500, + "Unexpected error during processing", + "/api/v1/example/endpoint")) + .example( + createErrorExample( + 500, + "Unexpected error during processing", + "/api/v1/example/endpoint")))); + } + + private Schema createErrorSchema(int status, String message, String path) { + return new Schema<>() + .type("object") + .addProperty("status", new Schema<>().type("integer").example(status)) + .addProperty("error", new Schema<>().type("string").example(getErrorType(status))) + .addProperty("message", new Schema<>().type("string").example(message)) + .addProperty( + "timestamp", + new Schema<>() + .type("string") + .format("date-time") + .example("2024-01-15T10:30:00Z")) + .addProperty("path", new Schema<>().type("string").example(path)); + } + + private Object createErrorExample(int status, String message, String path) { + return java.util.Map.of( + "status", status, + "error", getErrorType(status), + "message", message, + "timestamp", "2024-01-15T10:30:00Z", + "path", path); + } + + private String getErrorType(int status) { + return switch (status) { + case 400 -> "Bad Request"; + case 413 -> "Payload Too Large"; + case 422 -> "Unprocessable Entity"; + case 500 -> "Internal Server Error"; + default -> "Error"; + }; + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java index 78d2a3d2b..a25287c4d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java @@ -1,5 +1,8 @@ package stirling.software.SPDF.config; +import java.util.List; + +import org.springdoc.core.customizers.OpenApiCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -8,8 +11,13 @@ import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Contact; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.media.ComposedSchema; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; import lombok.RequiredArgsConstructor; @@ -41,8 +49,7 @@ public class OpenApiConfig { new License() .name("MIT") .url( - "https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/LICENSE") - .identifier("MIT")) + "https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/LICENSE")) .termsOfService("https://www.stirlingpdf.com/terms") .contact( new Contact() @@ -50,18 +57,93 @@ public class OpenApiConfig { .url("https://www.stirlingpdf.com") .email("contact@stirlingpdf.com")) .description(DEFAULT_DESCRIPTION); + + OpenAPI openAPI = new OpenAPI().info(info).openapi("3.0.3"); + + // Add server configuration from environment variable + String swaggerServerUrl = System.getenv("SWAGGER_SERVER_URL"); + if (swaggerServerUrl != null && !swaggerServerUrl.trim().isEmpty()) { + Server server = new Server().url(swaggerServerUrl).description("API Server"); + openAPI.addServersItem(server); + } + + // Add ErrorResponse schema to components + Schema errorResponseSchema = + new Schema<>() + .type("object") + .addProperty( + "timestamp", + new Schema<>() + .type("string") + .format("date-time") + .description("Error timestamp")) + .addProperty( + "status", + new Schema<>().type("integer").description("HTTP status code")) + .addProperty( + "error", new Schema<>().type("string").description("Error type")) + .addProperty( + "message", + new Schema<>().type("string").description("Error message")) + .addProperty( + "path", new Schema<>().type("string").description("Request path")) + .description("Standard error response format"); + + Components components = new Components().addSchemas("ErrorResponse", errorResponseSchema); + if (!applicationProperties.getSecurity().getEnableLogin()) { - return new OpenAPI().components(new Components()).info(info); + return openAPI.components(components); } else { SecurityScheme apiKeyScheme = new SecurityScheme() .type(SecurityScheme.Type.APIKEY) .in(SecurityScheme.In.HEADER) .name("X-API-KEY"); - return new OpenAPI() - .components(new Components().addSecuritySchemes("apiKey", apiKeyScheme)) - .info(info) + components.addSecuritySchemes("apiKey", apiKeyScheme); + return openAPI.components(components) .addSecurityItem(new SecurityRequirement().addList("apiKey")); } } + + @Bean + OpenApiCustomizer pdfFileOneOfCustomizer() { + return openApi -> { + var components = openApi.getComponents(); + var schemas = components.getSchemas(); + + // Define the two shapes + var upload = + new ObjectSchema() + .name("PDFFileUpload") + .description("Upload a PDF file") + .addProperty("fileInput", new StringSchema().format("binary")) + .addRequiredItem("fileInput"); + + var ref = + new ObjectSchema() + .name("PDFFileRef") + .description("Reference a server-side file") + .addProperty( + "fileId", + new StringSchema() + .example("a1b2c3d4-5678-90ab-cdef-ghijklmnopqr")) + .addRequiredItem("fileId"); + + schemas.put("PDFFileUpload", upload); + schemas.put("PDFFileRef", ref); + + // Create the oneOf schema + var pdfFileOneOf = + new ComposedSchema() + .oneOf( + List.of( + new Schema<>() + .$ref("#/components/schemas/PDFFileUpload"), + new Schema<>().$ref("#/components/schemas/PDFFileRef"))) + .description("Either upload a file or provide a server-side file ID"); + + // Replace PDFFile schema + schemas.put("PDFFile", pdfFileOneOf); + }; + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/config/SpringDocConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/SpringDocConfig.java new file mode 100644 index 000000000..cdde38d48 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/SpringDocConfig.java @@ -0,0 +1,83 @@ +package stirling.software.SPDF.config; + +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SpringDocConfig { + + @Bean + public GroupedOpenApi pdfProcessingApi( + @Qualifier("pdfFileOneOfCustomizer") OpenApiCustomizer pdfFileOneOfCustomizer) { + return GroupedOpenApi.builder() + .group("file-processing") + .displayName("File Processing") + .pathsToMatch("/api/v1/**") + .pathsToExclude( + "/api/v1/admin/**", + "/api/v1/user/**", + "/api/v1/settings/**", + "/api/v1/team/**", + "/api/v1/ui-data/**", + "/api/v1/proprietary/ui-data/**", + "/api/v1/info/**", + "/api/v1/general/job/**", + "/api/v1/general/files/**") + .addOpenApiCustomizer(pdfFileOneOfCustomizer) + .addOpenApiCustomizer( + openApi -> { + openApi.info( + openApi.getInfo() + .title("Stirling PDF - Processing API") + .description( + "API documentation for PDF processing operations including conversion, manipulation, security, and utilities.")); + }) + .build(); + } + + @Bean + public GroupedOpenApi adminApi() { + return GroupedOpenApi.builder() + .group("management") + .displayName("Management") + .pathsToMatch( + "/api/v1/admin/**", + "/api/v1/user/**", + "/api/v1/settings/**", + "/api/v1/team/**") + .addOpenApiCustomizer( + openApi -> { + openApi.info( + openApi.getInfo() + .title("Stirling PDF - Admin API") + .description( + "API documentation for administrative functions, user management, and system configuration.")); + }) + .build(); + } + + @Bean + public GroupedOpenApi systemApi() { + return GroupedOpenApi.builder() + .group("system") + .displayName("System & UI API") + .pathsToMatch( + "/api/v1/ui-data/**", + "/api/v1/proprietary/ui-data/**", + "/api/v1/info/**", + "/api/v1/general/job/**", + "/api/v1/general/files/**") + .addOpenApiCustomizer( + openApi -> { + openApi.info( + openApi.getInfo() + .title("Stirling PDF - System API") + .description( + "API documentation for system information, UI data, and utility endpoints.")); + }) + .build(); + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index c3e204b3c..4dba70300 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -1,19 +1,20 @@ 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.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import lombok.RequiredArgsConstructor; -import stirling.software.common.configuration.InstallationPathConfig; +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) { @@ -21,11 +22,34 @@ public class WebMvcConfig implements WebMvcConfigurer { } @Override - public void addResourceHandlers(ResourceHandlerRegistry registry) { - // Handler for external static resources - registry.addResourceHandler("/**") - .addResourceLocations( - "file:" + InstallationPathConfig.getStaticPath(), "classpath:/static/"); - // .setCachePeriod(0); // Optional: disable caching + 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 + // registry.addResourceHandler("/**") + // .addResourceLocations( + // "file:" + InstallationPathConfig.getStaticPath(), "classpath:/static/"); + // // .setCachePeriod(0); // Optional: disable caching + // } } diff --git a/app/core/src/main/java/stirling/software/SPDF/config/swagger/CsvConversionResponse.java b/app/core/src/main/java/stirling/software/SPDF/config/swagger/CsvConversionResponse.java new file mode 100644 index 000000000..042d09d8b --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/swagger/CsvConversionResponse.java @@ -0,0 +1,58 @@ +package stirling.software.SPDF.config.swagger; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +/** + * API response annotation for PDF to CSV conversions. Use for endpoints that convert PDF tables to + * CSV format. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "PDF tables extracted successfully to CSV format", + content = { + @Content( + mediaType = "text/csv", + schema = + @Schema( + type = "string", + format = "binary", + description = + "CSV file containing extracted table data")), + @Content( + mediaType = "application/zip", + schema = + @Schema( + type = "string", + format = "binary", + description = + "ZIP archive containing multiple CSV files when multiple tables are extracted")) + }), + @ApiResponse( + responseCode = "400", + description = "Invalid PDF file or no tables found for extraction", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "204", description = "No tables found in the PDF"), + @ApiResponse( + responseCode = "500", + description = "Internal server error during table extraction", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))) + }) +public @interface CsvConversionResponse {} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/swagger/ErrorResponse.java b/app/core/src/main/java/stirling/software/SPDF/config/swagger/ErrorResponse.java new file mode 100644 index 000000000..6d125535b --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/swagger/ErrorResponse.java @@ -0,0 +1,30 @@ +package stirling.software.SPDF.config.swagger; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@Schema(description = "Standard error response") +public class ErrorResponse { + + @Schema(description = "HTTP status code", example = "400") + private int status; + + @Schema( + description = "Error message describing what went wrong", + example = "Invalid PDF file or corrupted data") + private String message; + + @Schema(description = "Timestamp when the error occurred", example = "2024-01-15T10:30:00Z") + private String timestamp; + + @Schema( + description = "Request path where the error occurred", + example = "/api/v1/{endpoint-path}") + private String path; +} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/swagger/FilterResponse.java b/app/core/src/main/java/stirling/software/SPDF/config/swagger/FilterResponse.java new file mode 100644 index 000000000..72834a8e4 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/swagger/FilterResponse.java @@ -0,0 +1,67 @@ +package stirling.software.SPDF.config.swagger; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +/** + * API response annotation for filter operations that conditionally return the original file. Use + * for operations like text filters, page count filters, size filters, etc. Returns the original PDF + * if condition is met, otherwise returns no content (204). + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "Filter condition met - returns the original PDF file", + content = + @Content( + mediaType = "application/pdf", + schema = + @Schema( + type = "string", + format = "binary", + description = "The original PDF file"))), + @ApiResponse( + responseCode = "204", + description = "Filter condition not met - no content returned", + content = @Content()), + @ApiResponse( + responseCode = "400", + description = "Bad request - Invalid filter parameters or corrupted PDF", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "413", + description = "Payload too large - File exceeds maximum allowed size", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "422", + description = + "Unprocessable entity - PDF is valid but cannot be analyzed for filtering", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "Internal server error - Unexpected error during PDF analysis", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))) + }) +public @interface FilterResponse {} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/swagger/HtmlConversionResponse.java b/app/core/src/main/java/stirling/software/SPDF/config/swagger/HtmlConversionResponse.java new file mode 100644 index 000000000..69b43aa81 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/swagger/HtmlConversionResponse.java @@ -0,0 +1,33 @@ +package stirling.software.SPDF.config.swagger; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +/** + * API response annotation for PDF to HTML conversions. Use for endpoints that convert PDF to HTML + * format. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "PDF converted successfully to HTML format", + content = + @Content( + mediaType = "text/html", + schema = + @Schema( + type = "string", + format = "binary", + description = "HTML file converted from PDF"))) + }) +public @interface HtmlConversionResponse {} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/swagger/JavaScriptResponse.java b/app/core/src/main/java/stirling/software/SPDF/config/swagger/JavaScriptResponse.java new file mode 100644 index 000000000..cec6857c8 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/swagger/JavaScriptResponse.java @@ -0,0 +1,34 @@ +package stirling.software.SPDF.config.swagger; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +/** + * API response annotation for JavaScript extraction from PDFs. Use for endpoints that extract + * JavaScript code from PDF documents. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "JavaScript extracted successfully from PDF", + content = + @Content( + mediaType = "text/plain", + schema = + @Schema( + type = "string", + format = "binary", + description = + "JavaScript code extracted from PDF"))) + }) +public @interface JavaScriptResponse {} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/swagger/JsonDataResponse.java b/app/core/src/main/java/stirling/software/SPDF/config/swagger/JsonDataResponse.java new file mode 100644 index 000000000..0aa6d1fcf --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/swagger/JsonDataResponse.java @@ -0,0 +1,47 @@ +package stirling.software.SPDF.config.swagger; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +/** + * API response annotation for operations that return JSON data or analysis results. Use for + * analysis operations, metadata extraction, info operations, etc. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "Analysis or data extraction completed successfully", + content = + @Content( + mediaType = "application/json", + schema = + @Schema( + type = "object", + description = + "JSON object containing the requested data or analysis results"))), + @ApiResponse( + responseCode = "400", + description = "Invalid PDF file or request parameters", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "Internal server error during processing", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))) + }) +public @interface JsonDataResponse {} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/swagger/MarkdownConversionResponse.java b/app/core/src/main/java/stirling/software/SPDF/config/swagger/MarkdownConversionResponse.java new file mode 100644 index 000000000..744c7878a --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/swagger/MarkdownConversionResponse.java @@ -0,0 +1,34 @@ +package stirling.software.SPDF.config.swagger; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +/** + * API response annotation for PDF to Markdown conversions. Use for endpoints that convert PDF to + * Markdown format. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "PDF converted successfully to Markdown format", + content = + @Content( + mediaType = "text/markdown", + schema = + @Schema( + type = "string", + format = "binary", + description = + "Markdown file converted from PDF"))) + }) +public @interface MarkdownConversionResponse {} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/swagger/MultiFileResponse.java b/app/core/src/main/java/stirling/software/SPDF/config/swagger/MultiFileResponse.java new file mode 100644 index 000000000..c8f232c63 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/swagger/MultiFileResponse.java @@ -0,0 +1,71 @@ +package stirling.software.SPDF.config.swagger; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +/** + * API response annotation for operations that may return multiple files or a ZIP archive. Use for + * operations like PDF to images, split PDF, or multiple file conversions. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = + "Files processed successfully. Returns single file or ZIP archive containing multiple files.", + content = { + @Content( + mediaType = "application/pdf", + schema = + @Schema( + type = "string", + format = "binary", + description = "Single PDF file result")), + @Content( + mediaType = "application/zip", + schema = + @Schema( + type = "string", + format = "binary", + description = + "ZIP archive containing multiple output files")), + @Content( + mediaType = "image/png", + schema = + @Schema( + type = "string", + format = "binary", + description = "Single image file (PNG)")), + @Content( + mediaType = "image/jpeg", + schema = + @Schema( + type = "string", + format = "binary", + description = "Single image file (JPEG)")) + }), + @ApiResponse( + responseCode = "400", + description = "Invalid PDF file or request parameters", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "Internal server error during processing", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))) + }) +public @interface MultiFileResponse {} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/swagger/PowerPointConversionResponse.java b/app/core/src/main/java/stirling/software/SPDF/config/swagger/PowerPointConversionResponse.java new file mode 100644 index 000000000..f7e676537 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/swagger/PowerPointConversionResponse.java @@ -0,0 +1,66 @@ +package stirling.software.SPDF.config.swagger; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +/** + * API response annotation for PDF to PowerPoint presentation conversions. Use for endpoints that + * convert PDF to PPTX format. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "PDF converted successfully to PowerPoint presentation", + content = + @Content( + mediaType = + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + schema = + @Schema( + type = "string", + format = "binary", + description = + "Microsoft PowerPoint presentation (PPTX)"))), + @ApiResponse( + responseCode = "400", + description = + "Bad request - Invalid input parameters, unsupported format, or corrupted PDF", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "413", + description = "Payload too large - File exceeds maximum allowed size", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "422", + description = + "Unprocessable entity - PDF is valid but cannot be converted to PowerPoint format", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = + "Internal server error - Unexpected error during PowerPoint conversion", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))) + }) +public @interface PowerPointConversionResponse {} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/swagger/StandardPdfResponse.java b/app/core/src/main/java/stirling/software/SPDF/config/swagger/StandardPdfResponse.java new file mode 100644 index 000000000..a4d41e8d3 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/swagger/StandardPdfResponse.java @@ -0,0 +1,47 @@ +package stirling.software.SPDF.config.swagger; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +/** + * Standard API response annotation for PDF operations that take PDF input and return PDF output. + * Use for single PDF input → single PDF output (SISO) operations like rotate, compress, etc. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "PDF processed successfully", + content = + @Content( + mediaType = "application/pdf", + schema = + @Schema( + type = "string", + format = "binary", + description = "The processed PDF file"))), + @ApiResponse( + responseCode = "400", + description = "Invalid PDF file or request parameters", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "Internal server error during processing", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))) + }) +public @interface StandardPdfResponse {} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/swagger/TextPlainConversionResponse.java b/app/core/src/main/java/stirling/software/SPDF/config/swagger/TextPlainConversionResponse.java new file mode 100644 index 000000000..b6b07b17d --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/swagger/TextPlainConversionResponse.java @@ -0,0 +1,69 @@ +package stirling.software.SPDF.config.swagger; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +/** + * API response annotation for PDF to plain text conversions. Use for endpoints that extract text + * content from PDF. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "PDF text extracted successfully", + content = { + @Content( + mediaType = "text/plain", + schema = + @Schema( + type = "string", + description = + "Plain text content extracted from PDF")), + @Content( + mediaType = "application/rtf", + schema = + @Schema( + type = "string", + format = "binary", + description = "Rich Text Format document")) + }), + @ApiResponse( + responseCode = "400", + description = "Bad request - Invalid input parameters or corrupted PDF", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "413", + description = "Payload too large - File exceeds maximum allowed size", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "422", + description = "Unprocessable entity - PDF is valid but text extraction failed", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "Internal server error - Unexpected error during text extraction", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))) + }) +public @interface TextPlainConversionResponse {} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/swagger/WordConversionResponse.java b/app/core/src/main/java/stirling/software/SPDF/config/swagger/WordConversionResponse.java new file mode 100644 index 000000000..b1c6f91ec --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/swagger/WordConversionResponse.java @@ -0,0 +1,72 @@ +package stirling.software.SPDF.config.swagger; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +/** + * API response annotation for PDF to Word document conversions. Use for endpoints that convert PDF + * to DOCX/DOC formats. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "PDF converted successfully to Word document", + content = { + @Content( + mediaType = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + schema = + @Schema( + type = "string", + format = "binary", + description = "Microsoft Word document (DOCX)")), + @Content( + mediaType = "application/msword", + schema = + @Schema( + type = "string", + format = "binary", + description = "Microsoft Word document (DOC)")) + }), + @ApiResponse( + responseCode = "400", + description = + "Bad request - Invalid input parameters, unsupported format, or corrupted PDF", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "413", + description = "Payload too large - File exceeds maximum allowed size", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "422", + description = + "Unprocessable entity - PDF is valid but cannot be converted to Word format", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse( + responseCode = "500", + description = "Internal server error - Unexpected error during Word conversion", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class))) + }) +public @interface WordConversionResponse {} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/swagger/XmlConversionResponse.java b/app/core/src/main/java/stirling/software/SPDF/config/swagger/XmlConversionResponse.java new file mode 100644 index 000000000..49bfd9335 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/swagger/XmlConversionResponse.java @@ -0,0 +1,33 @@ +package stirling.software.SPDF.config.swagger; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +/** + * API response annotation for PDF to XML conversions. Use for endpoints that convert PDF to XML + * format. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +@ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "PDF converted successfully to XML format", + content = + @Content( + mediaType = "application/xml", + schema = + @Schema( + type = "string", + format = "binary", + description = "XML file converted from PDF"))) + }) +public @interface XmlConversionResponse {} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/AnalysisController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/AnalysisController.java index 0cfc3cf77..cbd953541 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/AnalysisController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/AnalysisController.java @@ -12,25 +12,26 @@ import org.apache.pdfbox.pdmodel.encryption.PDEncryption; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation; import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.ModelAttribute; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import stirling.software.SPDF.config.swagger.JsonDataResponse; +import stirling.software.common.annotations.AutoJobPostMapping; +import stirling.software.common.annotations.api.AnalysisApi; import stirling.software.common.model.api.PDFFile; import stirling.software.common.service.CustomPDFDocumentFactory; -@RestController -@RequestMapping("/api/v1/analysis") -@Tag(name = "Analysis", description = "Analysis APIs") +@AnalysisApi @RequiredArgsConstructor public class AnalysisController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(value = "/page-count", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @AutoJobPostMapping(value = "/page-count", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @JsonDataResponse @Operation( summary = "Get PDF page count", description = "Returns total number of pages in PDF. Input:PDF Output:JSON Type:SISO") @@ -40,7 +41,8 @@ public class AnalysisController { } } - @PostMapping(value = "/basic-info", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @AutoJobPostMapping(value = "/basic-info", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @JsonDataResponse @Operation( summary = "Get basic PDF information", description = "Returns page count, version, file size. Input:PDF Output:JSON Type:SISO") @@ -54,7 +56,10 @@ public class AnalysisController { } } - @PostMapping(value = "/document-properties", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @AutoJobPostMapping( + value = "/document-properties", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @JsonDataResponse @Operation( summary = "Get PDF document properties", description = "Returns title, author, subject, etc. Input:PDF Output:JSON Type:SISO") @@ -77,7 +82,8 @@ public class AnalysisController { } } - @PostMapping(value = "/page-dimensions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @AutoJobPostMapping(value = "/page-dimensions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @JsonDataResponse @Operation( summary = "Get page dimensions for all pages", description = "Returns width and height of each page. Input:PDF Output:JSON Type:SISO") @@ -97,7 +103,8 @@ public class AnalysisController { } } - @PostMapping(value = "/form-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @AutoJobPostMapping(value = "/form-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @JsonDataResponse @Operation( summary = "Get form field information", description = @@ -120,7 +127,10 @@ public class AnalysisController { } } - @PostMapping(value = "/annotation-info", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @AutoJobPostMapping( + value = "/annotation-info", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @JsonDataResponse @Operation( summary = "Get annotation information", description = "Returns count and types of annotations. Input:PDF Output:JSON Type:SISO") @@ -144,7 +154,8 @@ public class AnalysisController { } } - @PostMapping(value = "/font-info", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @AutoJobPostMapping(value = "/font-info", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @JsonDataResponse @Operation( summary = "Get font information", description = @@ -166,7 +177,8 @@ public class AnalysisController { } } - @PostMapping(value = "/security-info", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @AutoJobPostMapping(value = "/security-info", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @JsonDataResponse @Operation( summary = "Get security information", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/BookletImpositionController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/BookletImpositionController.java new file mode 100644 index 000000000..d2e278429 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/BookletImpositionController.java @@ -0,0 +1,324 @@ +package stirling.software.SPDF.controller.api; + +import java.awt.*; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.multipdf.LayerUtility; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; +import org.apache.pdfbox.util.Matrix; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import io.github.pixee.security.Filenames; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; + +import stirling.software.SPDF.model.api.general.BookletImpositionRequest; +import stirling.software.common.annotations.AutoJobPostMapping; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.WebResponseUtils; + +@RestController +@RequestMapping("/api/v1/general") +@Tag(name = "General", description = "General APIs") +@RequiredArgsConstructor +public class BookletImpositionController { + + private final CustomPDFDocumentFactory pdfDocumentFactory; + + @AutoJobPostMapping(value = "/booklet-imposition", consumes = "multipart/form-data") + @Operation( + summary = "Create a booklet with proper page imposition", + description = + "This operation combines page reordering for booklet printing with multi-page layout. " + + "It rearranges pages in the correct order for booklet printing and places multiple pages " + + "on each sheet for proper folding and binding. Input:PDF Output:PDF Type:SISO") + public ResponseEntity createBookletImposition( + @ModelAttribute BookletImpositionRequest request) throws IOException { + + MultipartFile file = request.getFileInput(); + int pagesPerSheet = request.getPagesPerSheet(); + boolean addBorder = Boolean.TRUE.equals(request.getAddBorder()); + String spineLocation = + request.getSpineLocation() != null ? request.getSpineLocation() : "LEFT"; + boolean addGutter = Boolean.TRUE.equals(request.getAddGutter()); + float gutterSize = request.getGutterSize(); + boolean doubleSided = Boolean.TRUE.equals(request.getDoubleSided()); + String duplexPass = request.getDuplexPass() != null ? request.getDuplexPass() : "BOTH"; + boolean flipOnShortEdge = Boolean.TRUE.equals(request.getFlipOnShortEdge()); + + // Validate pages per sheet for booklet - only 2-up landscape is proper booklet + if (pagesPerSheet != 2) { + throw new IllegalArgumentException( + "Booklet printing uses 2 pages per side (landscape). For 4-up, use the N-up feature."); + } + + PDDocument sourceDocument = pdfDocumentFactory.load(file); + int totalPages = sourceDocument.getNumberOfPages(); + + // Create proper booklet with signature-based page ordering + PDDocument newDocument = + createSaddleBooklet( + sourceDocument, + totalPages, + addBorder, + spineLocation, + addGutter, + gutterSize, + doubleSided, + duplexPass, + flipOnShortEdge); + + sourceDocument.close(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + newDocument.save(baos); + newDocument.close(); + + byte[] result = baos.toByteArray(); + return WebResponseUtils.bytesToWebResponse( + result, + Filenames.toSimpleFileName(file.getOriginalFilename()).replaceFirst("[.][^.]+$", "") + + "_booklet.pdf"); + } + + private static int padToMultipleOf4(int n) { + return (n + 3) / 4 * 4; + } + + private static class Side { + final int left, right; + final boolean isBack; + + Side(int left, int right, boolean isBack) { + this.left = left; + this.right = right; + this.isBack = isBack; + } + } + + private static List saddleStitchSides( + int totalPagesOriginal, + boolean doubleSided, + String duplexPass, + boolean flipOnShortEdge) { + int N = padToMultipleOf4(totalPagesOriginal); + List out = new ArrayList<>(); + int sheets = N / 4; + + for (int s = 0; s < sheets; s++) { + int a = N - 1 - (s * 2); // left, front + int b = (s * 2); // right, front + int c = (s * 2) + 1; // left, back + int d = N - 2 - (s * 2); // right, back + + // clamp to -1 (blank) if >= totalPagesOriginal + a = (a < totalPagesOriginal) ? a : -1; + b = (b < totalPagesOriginal) ? b : -1; + c = (c < totalPagesOriginal) ? c : -1; + d = (d < totalPagesOriginal) ? d : -1; + + // Handle duplex pass selection + boolean includeFront = "BOTH".equals(duplexPass) || "FIRST".equals(duplexPass); + boolean includeBack = "BOTH".equals(duplexPass) || "SECOND".equals(duplexPass); + + if (includeFront) { + out.add(new Side(a, b, false)); // front side + } + + if (includeBack) { + // For short-edge duplex, swap back-side left/right + // Note: flipOnShortEdge is ignored in manual duplex mode since users physically + // flip the stack + if (doubleSided && flipOnShortEdge) { + out.add(new Side(d, c, true)); // swapped back side (automatic duplex only) + } else { + out.add(new Side(c, d, true)); // normal back side + } + } + } + return out; + } + + private PDDocument createSaddleBooklet( + PDDocument src, + int totalPages, + boolean addBorder, + String spineLocation, + boolean addGutter, + float gutterSize, + boolean doubleSided, + String duplexPass, + boolean flipOnShortEdge) + throws IOException { + + PDDocument dst = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(src); + + // Derive paper size from source document's first page CropBox + PDRectangle srcBox = src.getPage(0).getCropBox(); + PDRectangle portraitPaper = new PDRectangle(srcBox.getWidth(), srcBox.getHeight()); + // Force landscape for booklet (Acrobat booklet uses landscape paper to fold to portrait) + PDRectangle pageSize = new PDRectangle(portraitPaper.getHeight(), portraitPaper.getWidth()); + + // Validate and clamp gutter size + if (gutterSize < 0) gutterSize = 0; + if (gutterSize >= pageSize.getWidth() / 2f) gutterSize = pageSize.getWidth() / 2f - 1f; + + List sides = saddleStitchSides(totalPages, doubleSided, duplexPass, flipOnShortEdge); + + for (Side side : sides) { + PDPage out = new PDPage(pageSize); + dst.addPage(out); + + float cellW = pageSize.getWidth() / 2f; + float cellH = pageSize.getHeight(); + + // For RIGHT spine (RTL), swap left/right placements + boolean rtl = "RIGHT".equalsIgnoreCase(spineLocation); + int leftCol = rtl ? 1 : 0; + int rightCol = rtl ? 0 : 1; + + // Apply gutter margins with centered gap option + float g = addGutter ? gutterSize : 0f; + float leftCellX = leftCol * cellW + (g / 2f); + float rightCellX = rightCol * cellW - (g / 2f); + float leftCellW = cellW - (g / 2f); + float rightCellW = cellW - (g / 2f); + + // Create LayerUtility once per page for efficiency + LayerUtility layerUtility = new LayerUtility(dst); + + try (PDPageContentStream cs = + new PDPageContentStream( + dst, out, PDPageContentStream.AppendMode.APPEND, true, true)) { + + if (addBorder) { + cs.setLineWidth(1.5f); + cs.setStrokingColor(Color.BLACK); + } + + // draw left cell + drawCell( + src, + dst, + cs, + layerUtility, + side.left, + leftCellX, + 0f, + leftCellW, + cellH, + addBorder); + // draw right cell + drawCell( + src, + dst, + cs, + layerUtility, + side.right, + rightCellX, + 0f, + rightCellW, + cellH, + addBorder); + } + } + return dst; + } + + private void drawCell( + PDDocument src, + PDDocument dst, + PDPageContentStream cs, + LayerUtility layerUtility, + int pageIndex, + float cellX, + float cellY, + float cellW, + float cellH, + boolean addBorder) + throws IOException { + + if (pageIndex < 0) { + // Draw border for blank cell if needed + if (addBorder) { + cs.addRect(cellX, cellY, cellW, cellH); + cs.stroke(); + } + return; + } + + PDPage srcPage = src.getPage(pageIndex); + PDRectangle r = srcPage.getCropBox(); // Use CropBox instead of MediaBox + int rot = (srcPage.getRotation() + 360) % 360; + + // Calculate scale factors, accounting for rotation + float sx = cellW / r.getWidth(); + float sy = cellH / r.getHeight(); + float s = Math.min(sx, sy); + + // If rotated 90/270 degrees, swap dimensions for fitting + if (rot == 90 || rot == 270) { + sx = cellW / r.getHeight(); + sy = cellH / r.getWidth(); + s = Math.min(sx, sy); + } + + float drawnW = (rot == 90 || rot == 270) ? r.getHeight() * s : r.getWidth() * s; + float drawnH = (rot == 90 || rot == 270) ? r.getWidth() * s : r.getHeight() * s; + + // Center in cell, accounting for CropBox offset + float tx = cellX + (cellW - drawnW) / 2f - r.getLowerLeftX() * s; + float ty = cellY + (cellH - drawnH) / 2f - r.getLowerLeftY() * s; + + cs.saveGraphicsState(); + cs.transform(Matrix.getTranslateInstance(tx, ty)); + cs.transform(Matrix.getScaleInstance(s, s)); + + // Apply rotation if needed (rotate about origin), then translate to keep in cell + switch (rot) { + case 90: + cs.transform(Matrix.getRotateInstance(Math.PI / 2, 0, 0)); + // After 90° CCW, the content spans x in [-r.getHeight(), 0] and y in [0, + // r.getWidth()] + cs.transform(Matrix.getTranslateInstance(0, -r.getWidth())); + break; + case 180: + cs.transform(Matrix.getRotateInstance(Math.PI, 0, 0)); + cs.transform(Matrix.getTranslateInstance(-r.getWidth(), -r.getHeight())); + break; + case 270: + cs.transform(Matrix.getRotateInstance(3 * Math.PI / 2, 0, 0)); + // After 270° CCW, the content spans x in [0, r.getHeight()] and y in + // [-r.getWidth(), 0] + cs.transform(Matrix.getTranslateInstance(-r.getHeight(), 0)); + break; + default: + // 0°: no-op + } + + // Reuse LayerUtility passed from caller + PDFormXObject form = layerUtility.importPageAsForm(src, pageIndex); + cs.drawForm(form); + + cs.restoreGraphicsState(); + + // Draw border on top of form to ensure visibility + if (addBorder) { + cs.addRect(cellX, cellY, cellW, cellH); + cs.stroke(); + } + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java index 8ca9604ce..c07f5cb8c 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/CropController.java @@ -16,32 +16,30 @@ import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.api.general.CropPdfForm; +import stirling.software.SPDF.config.swagger.StandardPdfResponse; +import stirling.software.common.annotations.AutoJobPostMapping; +import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.WebResponseUtils; -@RestController -@RequestMapping("/api/v1/general") -@Tag(name = "General", description = "General APIs") +@GeneralApi @RequiredArgsConstructor @Slf4j public class CropController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @AutoJobPostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @StandardPdfResponse @Operation( summary = "Crops a PDF document", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java index dee51b75a..53aa0fe35 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java @@ -14,33 +14,33 @@ import org.apache.pdfbox.util.Matrix; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest; +import stirling.software.SPDF.config.swagger.StandardPdfResponse; +import stirling.software.common.annotations.AutoJobPostMapping; +import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.FormUtils; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.WebResponseUtils; -@RestController -@RequestMapping("/api/v1/general") -@Tag(name = "General", description = "General APIs") +@GeneralApi @RequiredArgsConstructor @Slf4j public class MultiPageLayoutController { private final CustomPDFDocumentFactory pdfDocumentFactory; - @PostMapping(value = "/multi-page-layout", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @AutoJobPostMapping( + value = "/multi-page-layout", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @StandardPdfResponse @Operation( summary = "Merge multiple pages of a PDF document into a single page", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java index 0e9cd96dc..3c2e6f33a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java @@ -5,25 +5,21 @@ import java.util.Map; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; import io.swagger.v3.oas.annotations.Hidden; -import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.common.annotations.AutoJobPostMapping; +import stirling.software.common.annotations.api.SettingsApi; import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.util.GeneralUtils; -@Controller -@Tag(name = "Settings", description = "Settings APIs") -@RequestMapping("/api/v1/settings") +@SettingsApi @RequiredArgsConstructor @Hidden public class SettingsController { @@ -31,7 +27,7 @@ public class SettingsController { private final ApplicationProperties applicationProperties; private final EndpointConfiguration endpointConfiguration; - @PostMapping("/update-enable-analytics") + @AutoJobPostMapping("/update-enable-analytics") @Hidden public ResponseEntity updateApiKey(@RequestBody Boolean enabled) throws IOException { if (applicationProperties.getSystem().getEnableAnalytics() != null) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java new file mode 100644 index 000000000..ad2fead01 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java @@ -0,0 +1,296 @@ +package stirling.software.SPDF.controller.api; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.stream.Stream; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.swagger.v3.oas.annotations.Operation; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.model.Dependency; +import stirling.software.SPDF.model.SignatureFile; +import stirling.software.SPDF.service.SignatureService; +import stirling.software.common.annotations.api.UiDataApi; +import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.UserServiceInterface; +import stirling.software.common.util.ExceptionUtils; +import stirling.software.common.util.GeneralUtils; + +@Slf4j +@UiDataApi +public class UIDataController { + + private final ApplicationProperties applicationProperties; + private final SignatureService signatureService; + private final UserServiceInterface userService; + private final ResourceLoader resourceLoader; + private final RuntimePathConfig runtimePathConfig; + + public UIDataController( + ApplicationProperties applicationProperties, + SignatureService signatureService, + @Autowired(required = false) UserServiceInterface userService, + ResourceLoader resourceLoader, + RuntimePathConfig runtimePathConfig) { + this.applicationProperties = applicationProperties; + this.signatureService = signatureService; + this.userService = userService; + this.resourceLoader = resourceLoader; + this.runtimePathConfig = runtimePathConfig; + } + + @GetMapping("/home") + @Operation(summary = "Get home page data") + public ResponseEntity getHomeData() { + String showSurvey = System.getenv("SHOW_SURVEY"); + boolean showSurveyValue = showSurvey == null || "true".equalsIgnoreCase(showSurvey); + + HomeData data = new HomeData(); + data.setShowSurveyFromDocker(showSurveyValue); + + return ResponseEntity.ok(data); + } + + @GetMapping("/licenses") + @Operation(summary = "Get third-party licenses data") + public ResponseEntity getLicensesData() { + LicensesData data = new LicensesData(); + Resource resource = new ClassPathResource("static/3rdPartyLicenses.json"); + + try { + InputStream is = resource.getInputStream(); + String json = new String(is.readAllBytes(), StandardCharsets.UTF_8); + ObjectMapper mapper = new ObjectMapper(); + Map> licenseData = + mapper.readValue(json, new TypeReference<>() {}); + data.setDependencies(licenseData.get("dependencies")); + } catch (IOException e) { + log.error("Failed to load licenses data", e); + data.setDependencies(Collections.emptyList()); + } + + return ResponseEntity.ok(data); + } + + @GetMapping("/pipeline") + @Operation(summary = "Get pipeline configuration data") + public ResponseEntity getPipelineData() { + PipelineData data = new PipelineData(); + List pipelineConfigs = new ArrayList<>(); + List> pipelineConfigsWithNames = new ArrayList<>(); + + if (new java.io.File(runtimePathConfig.getPipelineDefaultWebUiConfigs()).exists()) { + try (Stream paths = + Files.walk(Paths.get(runtimePathConfig.getPipelineDefaultWebUiConfigs()))) { + List jsonFiles = + paths.filter(Files::isRegularFile) + .filter(p -> p.toString().endsWith(".json")) + .toList(); + + for (Path jsonFile : jsonFiles) { + String content = Files.readString(jsonFile, StandardCharsets.UTF_8); + pipelineConfigs.add(content); + } + + for (String config : pipelineConfigs) { + Map jsonContent = + new ObjectMapper() + .readValue(config, new TypeReference>() {}); + String name = (String) jsonContent.get("name"); + if (name == null || name.length() < 1) { + String filename = + jsonFiles + .get(pipelineConfigs.indexOf(config)) + .getFileName() + .toString(); + name = filename.substring(0, filename.lastIndexOf('.')); + } + Map configWithName = new HashMap<>(); + configWithName.put("json", config); + configWithName.put("name", name); + pipelineConfigsWithNames.add(configWithName); + } + } catch (IOException e) { + log.error("Failed to load pipeline configs", e); + } + } + + if (pipelineConfigsWithNames.isEmpty()) { + Map configWithName = new HashMap<>(); + configWithName.put("json", ""); + configWithName.put("name", "No preloaded configs found"); + pipelineConfigsWithNames.add(configWithName); + } + + data.setPipelineConfigsWithNames(pipelineConfigsWithNames); + data.setPipelineConfigs(pipelineConfigs); + + return ResponseEntity.ok(data); + } + + @GetMapping("/sign") + @Operation(summary = "Get signature form data") + public ResponseEntity getSignData() { + String username = ""; + if (userService != null) { + username = userService.getCurrentUsername(); + } + + List signatures = signatureService.getAvailableSignatures(username); + List fonts = getFontNames(); + + SignData data = new SignData(); + data.setSignatures(signatures); + data.setFonts(fonts); + + return ResponseEntity.ok(data); + } + + @GetMapping("/ocr-pdf") + @Operation(summary = "Get OCR PDF data") + public ResponseEntity getOcrPdfData() { + List languages = getAvailableTesseractLanguages(); + + OcrData data = new OcrData(); + data.setLanguages(languages); + + return ResponseEntity.ok(data); + } + + private List getAvailableTesseractLanguages() { + String tessdataDir = applicationProperties.getSystem().getTessdataDir(); + java.io.File[] files = new java.io.File(tessdataDir).listFiles(); + if (files == null) { + return Collections.emptyList(); + } + return Arrays.stream(files) + .filter(file -> file.getName().endsWith(".traineddata")) + .map(file -> file.getName().replace(".traineddata", "")) + .filter(lang -> !"osd".equalsIgnoreCase(lang)) + .sorted() + .toList(); + } + + private List getFontNames() { + List fontNames = new ArrayList<>(); + fontNames.addAll(getFontNamesFromLocation("classpath:static/fonts/*.woff2")); + fontNames.addAll( + getFontNamesFromLocation( + "file:" + + InstallationPathConfig.getStaticPath() + + "fonts" + + java.io.File.separator + + "*")); + return fontNames; + } + + private List getFontNamesFromLocation(String locationPattern) { + try { + Resource[] resources = + GeneralUtils.getResourcesFromLocationPattern(locationPattern, resourceLoader); + return Arrays.stream(resources) + .map( + resource -> { + try { + String filename = resource.getFilename(); + if (filename != null) { + int lastDotIndex = filename.lastIndexOf('.'); + if (lastDotIndex != -1) { + String name = filename.substring(0, lastDotIndex); + String extension = filename.substring(lastDotIndex + 1); + return new FontResource(name, extension); + } + } + return null; + } catch (Exception e) { + throw ExceptionUtils.createRuntimeException( + "error.fontLoadingFailed", + "Error processing font file", + e); + } + }) + .filter(Objects::nonNull) + .toList(); + } catch (Exception e) { + throw ExceptionUtils.createRuntimeException( + "error.fontDirectoryReadFailed", "Failed to read font directory", e); + } + } + + // Data classes + @Data + public static class HomeData { + private boolean showSurveyFromDocker; + } + + @Data + public static class LicensesData { + private List dependencies; + } + + @Data + public static class PipelineData { + private List> pipelineConfigsWithNames; + private List pipelineConfigs; + } + + @Data + public static class SignData { + private List signatures; + private List fonts; + } + + @Data + public static class OcrData { + private List languages; + } + + @Data + public static class FontResource { + private String name; + private String extension; + private String type; + + public FontResource(String name, String extension) { + this.name = name; + this.extension = extension; + this.type = getFormatFromExtension(extension); + } + + private static String getFormatFromExtension(String extension) { + switch (extension) { + case "ttf": + return "truetype"; + case "woff": + return "woff"; + case "woff2": + return "woff2"; + case "eot": + return "embedded-opentype"; + case "svg": + return "svg"; + default: + return ""; + } + } + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToHtml.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToHtml.java index 76414ca57..f6406ff25 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToHtml.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToHtml.java @@ -3,33 +3,31 @@ package stirling.software.SPDF.controller.api.converters; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import stirling.software.SPDF.config.swagger.HtmlConversionResponse; +import stirling.software.common.annotations.AutoJobPostMapping; +import stirling.software.common.annotations.api.ConvertApi; import stirling.software.common.model.api.PDFFile; import stirling.software.common.util.PDFToFile; import stirling.software.common.util.TempFileManager; -@RestController -@Tag(name = "Convert", description = "Convert APIs") -@RequestMapping("/api/v1/convert") +@ConvertApi @RequiredArgsConstructor public class ConvertPDFToHtml { private final TempFileManager tempFileManager; - @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/html") + @AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/html") @Operation( summary = "Convert PDF to HTML", description = "This endpoint converts a PDF file to HTML format. Input:PDF Output:HTML Type:SISO") + @HtmlConversionResponse public ResponseEntity processPdfToHTML(@ModelAttribute PDFFile file) throws Exception { MultipartFile inputFile = file.getFileInput(); PDFToFile pdfToFile = new PDFToFile(tempFileManager); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java new file mode 100644 index 000000000..072471e5c --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -0,0 +1,137 @@ +package stirling.software.SPDF.controller.api.misc; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.context.ApplicationContext; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import io.swagger.v3.oas.annotations.Hidden; + +import stirling.software.SPDF.config.EndpointConfiguration; +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; + +@ConfigApi +@Hidden +public class ConfigController { + + private final ApplicationProperties applicationProperties; + private final ApplicationContext applicationContext; + private final EndpointConfiguration endpointConfiguration; + private final ServerCertificateServiceInterface serverCertificateService; + + public ConfigController( + ApplicationProperties applicationProperties, + ApplicationContext applicationContext, + EndpointConfiguration endpointConfiguration, + @org.springframework.beans.factory.annotation.Autowired(required = false) + ServerCertificateServiceInterface serverCertificateService) { + this.applicationProperties = applicationProperties; + this.applicationContext = applicationContext; + this.endpointConfiguration = endpointConfiguration; + this.serverCertificateService = serverCertificateService; + } + + @GetMapping("/app-config") + public ResponseEntity> getAppConfig() { + Map configData = new HashMap<>(); + + try { + // Get AppConfig bean + AppConfig appConfig = applicationContext.getBean(AppConfig.class); + + // Extract key configuration values from AppConfig + configData.put("baseUrl", appConfig.getBaseUrl()); + configData.put("contextPath", appConfig.getContextPath()); + 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()); + + // System settings + configData.put( + "enableAlphaFunctionality", + applicationProperties.getSystem().getEnableAlphaFunctionality()); + configData.put( + "enableAnalytics", applicationProperties.getSystem().getEnableAnalytics()); + + // Premium/Enterprise settings + configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled()); + + // Server certificate settings + configData.put( + "serverCertificateEnabled", + serverCertificateService != null && serverCertificateService.isEnabled()); + + // Legal settings + configData.put( + "termsAndConditions", applicationProperties.getLegal().getTermsAndConditions()); + configData.put("privacyPolicy", applicationProperties.getLegal().getPrivacyPolicy()); + configData.put("cookiePolicy", applicationProperties.getLegal().getCookiePolicy()); + configData.put("impressum", applicationProperties.getLegal().getImpressum()); + configData.put( + "accessibilityStatement", + applicationProperties.getLegal().getAccessibilityStatement()); + + // Try to get EEAppConfig values if available + try { + if (applicationContext.containsBean("runningProOrHigher")) { + configData.put( + "runningProOrHigher", + applicationContext.getBean("runningProOrHigher", Boolean.class)); + } + if (applicationContext.containsBean("runningEE")) { + configData.put( + "runningEE", applicationContext.getBean("runningEE", Boolean.class)); + } + if (applicationContext.containsBean("license")) { + configData.put("license", applicationContext.getBean("license", String.class)); + } + if (applicationContext.containsBean("SSOAutoLogin")) { + configData.put( + "SSOAutoLogin", + applicationContext.getBean("SSOAutoLogin", Boolean.class)); + } + } catch (Exception e) { + // EE features not available, continue without them + } + + return ResponseEntity.ok(configData); + + } catch (Exception e) { + // Return basic config if there are any issues + configData.put("error", "Unable to retrieve full configuration"); + return ResponseEntity.ok(configData); + } + } + + @GetMapping("/endpoint-enabled") + public ResponseEntity isEndpointEnabled( + @RequestParam(name = "endpoint") String endpoint) { + boolean enabled = endpointConfiguration.isEndpointEnabled(endpoint); + return ResponseEntity.ok(enabled); + } + + @GetMapping("/endpoints-enabled") + public ResponseEntity> areEndpointsEnabled( + @RequestParam(name = "endpoints") String endpoints) { + Map result = new HashMap<>(); + String[] endpointArray = endpoints.split(","); + for (String endpoint : endpointArray) { + String trimmedEndpoint = endpoint.trim(); + result.put(trimmedEndpoint, endpointConfiguration.isEndpointEnabled(trimmedEndpoint)); + } + return ResponseEntity.ok(result); + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java index c04840c59..65e4ee1d0 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java @@ -12,32 +12,30 @@ import org.springframework.core.io.Resource; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; -import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.SPDF.config.swagger.MultiFileResponse; import stirling.software.SPDF.model.PipelineConfig; import stirling.software.SPDF.model.PipelineOperation; import stirling.software.SPDF.model.PipelineResult; import stirling.software.SPDF.model.api.HandleDataRequest; +import stirling.software.common.annotations.AutoJobPostMapping; +import stirling.software.common.annotations.api.PipelineApi; import stirling.software.common.service.PostHogService; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.WebResponseUtils; -@RestController -@RequestMapping("/api/v1/pipeline") +@PipelineApi @Slf4j -@Tag(name = "Pipeline", description = "Pipeline APIs") @RequiredArgsConstructor public class PipelineController { @@ -47,7 +45,14 @@ public class PipelineController { private final PostHogService postHogService; - @PostMapping(value = "/handleData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @AutoJobPostMapping(value = "/handleData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @MultiFileResponse + @Operation( + summary = "Execute automated PDF processing pipeline", + description = + "This endpoint processes multiple PDF files through a configurable pipeline of operations. " + + "Users provide files and a JSON configuration defining the sequence of operations to perform. " + + "Input:PDF Output:PDF/ZIP Type:MIMO") public ResponseEntity handleData(@ModelAttribute HandleDataRequest request) throws JsonMappingException, JsonProcessingException { MultipartFile[] files = request.getFileInput(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ConverterWebController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ConverterWebController.java index db6c62bc4..ba78269fd 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ConverterWebController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ConverterWebController.java @@ -15,11 +15,12 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.util.ApplicationContextProvider; import stirling.software.common.util.CheckProgramInstall; -@Controller +// @Controller // Disabled - Backend-only mode, no Thymeleaf UI @Tag(name = "Convert", description = "Convert APIs") public class ConverterWebController { - @GetMapping("/img-to-pdf") + @Deprecated + // @GetMapping("/img-to-pdf") @Hidden public String convertImgToPdfForm(Model model) { model.addAttribute("currentPage", "img-to-pdf"); @@ -65,28 +66,32 @@ public class ConverterWebController { return "convert/html-to-pdf"; } - @GetMapping("/markdown-to-pdf") + @Deprecated + // @GetMapping("/markdown-to-pdf") @Hidden public String convertMarkdownToPdfForm(Model model) { model.addAttribute("currentPage", "markdown-to-pdf"); return "convert/markdown-to-pdf"; } - @GetMapping("/pdf-to-markdown") + @Deprecated + // @GetMapping("/pdf-to-markdown") @Hidden public String convertPdfToMarkdownForm(Model model) { model.addAttribute("currentPage", "pdf-to-markdown"); return "convert/pdf-to-markdown"; } - @GetMapping("/url-to-pdf") + @Deprecated + // @GetMapping("/url-to-pdf") @Hidden public String convertURLToPdfForm(Model model) { model.addAttribute("currentPage", "url-to-pdf"); return "convert/url-to-pdf"; } - @GetMapping("/file-to-pdf") + @Deprecated + // @GetMapping("/file-to-pdf") @Hidden public String convertToPdfForm(Model model) { model.addAttribute("currentPage", "file-to-pdf"); @@ -95,7 +100,8 @@ public class ConverterWebController { // PDF TO...... - @GetMapping("/pdf-to-img") + @Deprecated + // @GetMapping("/pdf-to-img") @Hidden public String pdfToimgForm(Model model) { boolean isPython = CheckProgramInstall.isPythonAvailable(); @@ -111,7 +117,8 @@ public class ConverterWebController { return "convert/pdf-to-img"; } - @GetMapping("/pdf-to-html") + @Deprecated + // @GetMapping("/pdf-to-html") @Hidden public ModelAndView pdfToHTML() { ModelAndView modelAndView = new ModelAndView("convert/pdf-to-html"); @@ -119,7 +126,8 @@ public class ConverterWebController { return modelAndView; } - @GetMapping("/pdf-to-presentation") + @Deprecated + // @GetMapping("/pdf-to-presentation") @Hidden public ModelAndView pdfToPresentation() { ModelAndView modelAndView = new ModelAndView("convert/pdf-to-presentation"); @@ -127,7 +135,8 @@ public class ConverterWebController { return modelAndView; } - @GetMapping("/pdf-to-text") + @Deprecated + // @GetMapping("/pdf-to-text") @Hidden public ModelAndView pdfToText() { ModelAndView modelAndView = new ModelAndView("convert/pdf-to-text"); @@ -135,7 +144,8 @@ public class ConverterWebController { return modelAndView; } - @GetMapping("/pdf-to-word") + @Deprecated + // @GetMapping("/pdf-to-word") @Hidden public ModelAndView pdfToWord() { ModelAndView modelAndView = new ModelAndView("convert/pdf-to-word"); @@ -143,7 +153,8 @@ public class ConverterWebController { return modelAndView; } - @GetMapping("/pdf-to-xml") + @Deprecated + // @GetMapping("/pdf-to-xml") @Hidden public ModelAndView pdfToXML() { ModelAndView modelAndView = new ModelAndView("convert/pdf-to-xml"); @@ -151,7 +162,8 @@ public class ConverterWebController { return modelAndView; } - @GetMapping("/pdf-to-csv") + @Deprecated + // @GetMapping("/pdf-to-csv") @Hidden public ModelAndView pdfToCSV() { ModelAndView modelAndView = new ModelAndView("convert/pdf-to-csv"); @@ -159,7 +171,8 @@ public class ConverterWebController { return modelAndView; } - @GetMapping("/pdf-to-pdfa") + @Deprecated + // @GetMapping("/pdf-to-pdfa") @Hidden public String pdfToPdfAForm(Model model) { model.addAttribute("currentPage", "pdf-to-pdfa"); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/GeneralWebController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/GeneralWebController.java index 2aab753ef..d2a2e5a17 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/GeneralWebController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/GeneralWebController.java @@ -12,9 +12,7 @@ import java.util.stream.Stream; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; -import org.springframework.stereotype.Controller; import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -34,7 +32,7 @@ import stirling.software.common.service.UserServiceInterface; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; -@Controller +// @Controller // Disabled - Backend-only mode, no Thymeleaf UI @Tag(name = "General", description = "General APIs") @Slf4j public class GeneralWebController { @@ -55,7 +53,8 @@ public class GeneralWebController { this.runtimePathConfig = runtimePathConfig; } - @GetMapping("/pipeline") + @Deprecated + // @GetMapping("/pipeline") @Hidden public String pipelineForm(Model model) { model.addAttribute("currentPage", "pipeline"); @@ -105,91 +104,104 @@ public class GeneralWebController { return "pipeline"; } - @GetMapping("/merge-pdfs") + @Deprecated + // @GetMapping("/merge-pdfs") @Hidden public String mergePdfForm(Model model) { model.addAttribute("currentPage", "merge-pdfs"); return "merge-pdfs"; } - @GetMapping("/split-pdf-by-sections") + @Deprecated + // @GetMapping("/split-pdf-by-sections") @Hidden public String splitPdfBySections(Model model) { model.addAttribute("currentPage", "split-pdf-by-sections"); return "split-pdf-by-sections"; } - @GetMapping("/split-pdf-by-chapters") + @Deprecated + // @GetMapping("/split-pdf-by-chapters") @Hidden public String splitPdfByChapters(Model model) { model.addAttribute("currentPage", "split-pdf-by-chapters"); return "split-pdf-by-chapters"; } - @GetMapping("/view-pdf") + @Deprecated + // @GetMapping("/view-pdf") @Hidden public String ViewPdfForm2(Model model) { model.addAttribute("currentPage", "view-pdf"); return "view-pdf"; } - @GetMapping("/edit-table-of-contents") + @Deprecated + // @GetMapping("/edit-table-of-contents") @Hidden public String editTableOfContents(Model model) { model.addAttribute("currentPage", "edit-table-of-contents"); return "edit-table-of-contents"; } - @GetMapping("/multi-tool") + @Deprecated + // @GetMapping("/multi-tool") @Hidden public String multiToolForm(Model model) { model.addAttribute("currentPage", "multi-tool"); return "multi-tool"; } - @GetMapping("/remove-pages") + @Deprecated + // @GetMapping("/remove-pages") @Hidden public String pageDeleter(Model model) { model.addAttribute("currentPage", "remove-pages"); return "remove-pages"; } - @GetMapping("/pdf-organizer") + @Deprecated + // @GetMapping("/pdf-organizer") @Hidden public String pageOrganizer(Model model) { model.addAttribute("currentPage", "pdf-organizer"); return "pdf-organizer"; } - @GetMapping("/extract-page") + @Deprecated + // @GetMapping("/extract-page") @Hidden public String extractPages(Model model) { model.addAttribute("currentPage", "extract-page"); return "extract-page"; } - @GetMapping("/pdf-to-single-page") + @Deprecated + // @GetMapping("/pdf-to-single-page") @Hidden public String pdfToSinglePage(Model model) { model.addAttribute("currentPage", "pdf-to-single-page"); return "pdf-to-single-page"; } - @GetMapping("/rotate-pdf") + @Deprecated + // @GetMapping("/rotate-pdf") @Hidden public String rotatePdfForm(Model model) { model.addAttribute("currentPage", "rotate-pdf"); return "rotate-pdf"; } - @GetMapping("/split-pdfs") + @Deprecated + // @GetMapping("/split-pdfs") @Hidden public String splitPdfForm(Model model) { model.addAttribute("currentPage", "split-pdfs"); return "split-pdfs"; } - @GetMapping("/sign") + @Deprecated + // @GetMapping("/sign") @Hidden public String signForm(Model model) { String username = ""; @@ -204,28 +216,32 @@ public class GeneralWebController { return "sign"; } - @GetMapping("/multi-page-layout") + @Deprecated + // @GetMapping("/multi-page-layout") @Hidden public String multiPageLayoutForm(Model model) { model.addAttribute("currentPage", "multi-page-layout"); return "multi-page-layout"; } - @GetMapping("/scale-pages") + @Deprecated + // @GetMapping("/scale-pages") @Hidden public String scalePagesFrom(Model model) { model.addAttribute("currentPage", "scale-pages"); return "scale-pages"; } - @GetMapping("/split-by-size-or-count") + @Deprecated + // @GetMapping("/split-by-size-or-count") @Hidden public String splitBySizeOrCount(Model model) { model.addAttribute("currentPage", "split-by-size-or-count"); return "split-by-size-or-count"; } - @GetMapping("/overlay-pdf") + @Deprecated + // @GetMapping("/overlay-pdf") @Hidden public String overlayPdf(Model model) { model.addAttribute("currentPage", "overlay-pdf"); @@ -293,21 +309,24 @@ public class GeneralWebController { }; } - @GetMapping("/crop") + @Deprecated + // @GetMapping("/crop") @Hidden public String cropForm(Model model) { model.addAttribute("currentPage", "crop"); return "crop"; } - @GetMapping("/auto-split-pdf") + @Deprecated + // @GetMapping("/auto-split-pdf") @Hidden public String autoSPlitPDFForm(Model model) { model.addAttribute("currentPage", "auto-split-pdf"); return "auto-split-pdf"; } - @GetMapping("/remove-image-pdf") + @Deprecated + // @GetMapping("/remove-image-pdf") @Hidden public String removeImagePdfForm(Model model) { model.addAttribute("currentPage", "remove-image-pdf"); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/HomeWebController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/HomeWebController.java index 2b36f95af..e9b085dd9 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/HomeWebController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/HomeWebController.java @@ -9,7 +9,6 @@ import java.util.Map; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; -import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ResponseBody; @@ -26,20 +25,22 @@ import stirling.software.SPDF.model.Dependency; import stirling.software.common.model.ApplicationProperties; @Slf4j -@Controller +// @Controller // Disabled - Backend-only mode, no Thymeleaf UI @RequiredArgsConstructor public class HomeWebController { private final ApplicationProperties applicationProperties; - @GetMapping("/about") + @Deprecated + // @GetMapping("/about") @Hidden public String gameForm(Model model) { model.addAttribute("currentPage", "about"); return "about"; } - @GetMapping("/licenses") + @Deprecated + // @GetMapping("/licenses") @Hidden public String licensesForm(Model model) { model.addAttribute("currentPage", "licenses"); @@ -56,12 +57,14 @@ public class HomeWebController { return "licenses"; } - @GetMapping("/releases") + @Deprecated + // @GetMapping("/releases") public String getReleaseNotes(Model model) { return "releases"; } - @GetMapping("/") + @Deprecated + // @GetMapping("/") public String home(Model model) { model.addAttribute("currentPage", "home"); String showSurvey = System.getenv("SHOW_SURVEY"); @@ -70,12 +73,14 @@ public class HomeWebController { return "home"; } - @GetMapping("/home") + @Deprecated + // @GetMapping("/home") public String root(Model model) { return "redirect:/"; } - @GetMapping("/home-legacy") + @Deprecated + // @GetMapping("/home-legacy") public String redirectHomeLegacy() { return "redirect:/"; } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java index d0a61a815..ca6b4aa27 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java @@ -7,15 +7,12 @@ import java.util.*; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.PostConstruct; @@ -26,11 +23,10 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.EndpointInspector; import stirling.software.SPDF.config.StartupApplicationListener; +import stirling.software.common.annotations.api.InfoApi; import stirling.software.common.model.ApplicationProperties; -@RestController -@RequestMapping("/api/v1/info") -@Tag(name = "Info", description = "Info APIs") +@InfoApi @Slf4j @RequiredArgsConstructor public class MetricsController { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/OtherWebController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/OtherWebController.java index 09dd46cec..da7d4d5de 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/OtherWebController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/OtherWebController.java @@ -5,41 +5,40 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; -import org.springframework.stereotype.Controller; import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.servlet.ModelAndView; import io.swagger.v3.oas.annotations.Hidden; -import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.util.CheckProgramInstall; -@Controller -@Tag(name = "Misc", description = "Miscellaneous APIs") +// @Controller // Disabled - Backend-only mode, no Thymeleaf UI @RequiredArgsConstructor public class OtherWebController { private final ApplicationProperties applicationProperties; - @GetMapping("/compress-pdf") + @Deprecated + // @GetMapping("/compress-pdf") @Hidden public String compressPdfForm(Model model) { model.addAttribute("currentPage", "compress-pdf"); return "misc/compress-pdf"; } - @GetMapping("/replace-and-invert-color-pdf") + @Deprecated + // @GetMapping("/replace-and-invert-color-pdf") @Hidden public String replaceAndInvertColorPdfForm(Model model) { model.addAttribute("currentPage", "replace-invert-color-pdf"); return "misc/replace-color"; } - @GetMapping("/extract-image-scans") + @Deprecated + // @GetMapping("/extract-image-scans") @Hidden public ModelAndView extractImageScansForm() { ModelAndView modelAndView = new ModelAndView("misc/extract-image-scans"); @@ -49,70 +48,80 @@ public class OtherWebController { return modelAndView; } - @GetMapping("/show-javascript") + @Deprecated + // @GetMapping("/show-javascript") @Hidden public String extractJavascriptForm(Model model) { model.addAttribute("currentPage", "show-javascript"); return "misc/show-javascript"; } - @GetMapping("/stamp") + @Deprecated + // @GetMapping("/stamp") @Hidden public String stampForm(Model model) { model.addAttribute("currentPage", "stamp"); return "misc/stamp"; } - @GetMapping("/add-page-numbers") + @Deprecated + // @GetMapping("/add-page-numbers") @Hidden public String addPageNumbersForm(Model model) { model.addAttribute("currentPage", "add-page-numbers"); return "misc/add-page-numbers"; } - @GetMapping("/scanner-effect") + @Deprecated + // @GetMapping("/scanner-effect") @Hidden public String scannerEffectForm(Model model) { model.addAttribute("currentPage", "scanner-effect"); return "misc/scanner-effect"; } - @GetMapping("/extract-images") + @Deprecated + // @GetMapping("/extract-images") @Hidden public String extractImagesForm(Model model) { model.addAttribute("currentPage", "extract-images"); return "misc/extract-images"; } - @GetMapping("/flatten") + @Deprecated + // @GetMapping("/flatten") @Hidden public String flattenForm(Model model) { model.addAttribute("currentPage", "flatten"); return "misc/flatten"; } - @GetMapping("/change-metadata") + @Deprecated + // @GetMapping("/change-metadata") @Hidden public String addWatermarkForm(Model model) { model.addAttribute("currentPage", "change-metadata"); return "misc/change-metadata"; } - @GetMapping("/unlock-pdf-forms") + @Deprecated + // @GetMapping("/unlock-pdf-forms") @Hidden public String unlockPDFForms(Model model) { model.addAttribute("currentPage", "unlock-pdf-forms"); return "misc/unlock-pdf-forms"; } - @GetMapping("/compare") + @Deprecated + // @GetMapping("/compare") @Hidden public String compareForm(Model model) { model.addAttribute("currentPage", "compare"); return "misc/compare"; } - @GetMapping("/print-file") + @Deprecated + // @GetMapping("/print-file") @Hidden public String printFileForm(Model model) { model.addAttribute("currentPage", "print-file"); @@ -133,7 +142,8 @@ public class OtherWebController { .toList(); } - @GetMapping("/ocr-pdf") + @Deprecated + // @GetMapping("/ocr-pdf") @Hidden public ModelAndView ocrPdfPage() { ModelAndView modelAndView = new ModelAndView("misc/ocr-pdf"); @@ -143,56 +153,64 @@ public class OtherWebController { return modelAndView; } - @GetMapping("/add-image") + @Deprecated + // @GetMapping("/add-image") @Hidden public String overlayImage(Model model) { model.addAttribute("currentPage", "add-image"); return "misc/add-image"; } - @GetMapping("/adjust-contrast") + @Deprecated + // @GetMapping("/adjust-contrast") @Hidden public String contrast(Model model) { model.addAttribute("currentPage", "adjust-contrast"); return "misc/adjust-contrast"; } - @GetMapping("/repair") + @Deprecated + // @GetMapping("/repair") @Hidden public String repairForm(Model model) { model.addAttribute("currentPage", "repair"); return "misc/repair"; } - @GetMapping("/remove-blanks") + @Deprecated + // @GetMapping("/remove-blanks") @Hidden public String removeBlanksForm(Model model) { model.addAttribute("currentPage", "remove-blanks"); return "misc/remove-blanks"; } - @GetMapping("/remove-annotations") + @Deprecated + // @GetMapping("/remove-annotations") @Hidden public String removeAnnotationsForm(Model model) { model.addAttribute("currentPage", "remove-annotations"); return "misc/remove-annotations"; } - @GetMapping("/auto-crop") + @Deprecated + // @GetMapping("/auto-crop") @Hidden public String autoCropForm(Model model) { model.addAttribute("currentPage", "auto-crop"); return "misc/auto-crop"; } - @GetMapping("/auto-rename") + @Deprecated + // @GetMapping("/auto-rename") @Hidden public String autoRenameForm(Model model) { model.addAttribute("currentPage", "auto-rename"); return "misc/auto-rename"; } - @GetMapping("/add-attachments") + @Deprecated + // @GetMapping("/add-attachments") @Hidden public String attachmentsForm(Model model) { model.addAttribute("currentPage", "add-attachments"); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java new file mode 100644 index 000000000..09e40b133 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java @@ -0,0 +1,17 @@ +package stirling.software.SPDF.controller.web; + +import org.springframework.web.bind.annotation.GetMapping; + +// @Controller // Disabled - Backend-only mode, no Thymeleaf UI +public class ReactRoutingController { + + @GetMapping("/{path:^(?!api|static|robots\\.txt|favicon\\.ico)[^\\.]*$}") + public String forwardRootPaths() { + return "forward:/index.html"; + } + + @GetMapping("/{path:^(?!api|static)[^\\.]*}/{subpath:^(?!.*\\.).*$}") + public String forwardNestedPaths() { + return "forward:/index.html"; + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/SecurityWebController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/SecurityWebController.java index eb7245e5a..e2a34e140 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/SecurityWebController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/SecurityWebController.java @@ -1,86 +1,95 @@ package stirling.software.SPDF.controller.web; -import org.springframework.stereotype.Controller; import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.tags.Tag; -@Controller +// @Controller // Disabled - Backend-only mode, no Thymeleaf UI @Tag(name = "Security", description = "Security APIs") public class SecurityWebController { - @GetMapping("/auto-redact") + @Deprecated + // @GetMapping("/auto-redact") @Hidden public String autoRedactForm(Model model) { model.addAttribute("currentPage", "auto-redact"); return "security/auto-redact"; } - @GetMapping("/redact") + @Deprecated + // @GetMapping("/redact") public String redactForm(Model model) { model.addAttribute("currentPage", "redact"); return "security/redact"; } - @GetMapping("/add-password") + @Deprecated + // @GetMapping("/add-password") @Hidden public String addPasswordForm(Model model) { model.addAttribute("currentPage", "add-password"); return "security/add-password"; } - @GetMapping("/change-permissions") + @Deprecated + // @GetMapping("/change-permissions") @Hidden public String permissionsForm(Model model) { model.addAttribute("currentPage", "change-permissions"); return "security/change-permissions"; } - @GetMapping("/remove-password") + @Deprecated + // @GetMapping("/remove-password") @Hidden public String removePasswordForm(Model model) { model.addAttribute("currentPage", "remove-password"); return "security/remove-password"; } - @GetMapping("/add-watermark") + @Deprecated + // @GetMapping("/add-watermark") @Hidden public String addWatermarkForm(Model model) { model.addAttribute("currentPage", "add-watermark"); return "security/add-watermark"; } - @GetMapping("/cert-sign") + @Deprecated + // @GetMapping("/cert-sign") @Hidden public String certSignForm(Model model) { model.addAttribute("currentPage", "cert-sign"); return "security/cert-sign"; } - @GetMapping("/validate-signature") + @Deprecated + // @GetMapping("/validate-signature") @Hidden public String certSignVerifyForm(Model model) { model.addAttribute("currentPage", "validate-signature"); return "security/validate-signature"; } - @GetMapping("/remove-cert-sign") + @Deprecated + // @GetMapping("/remove-cert-sign") @Hidden public String certUnSignForm(Model model) { model.addAttribute("currentPage", "remove-cert-sign"); return "security/remove-cert-sign"; } - @GetMapping("/sanitize-pdf") + @Deprecated + // @GetMapping("/sanitize-pdf") @Hidden public String sanitizeForm(Model model) { model.addAttribute("currentPage", "sanitize-pdf"); return "security/sanitize-pdf"; } - @GetMapping("/get-info-on-pdf") + @Deprecated + // @GetMapping("/get-info-on-pdf") @Hidden public String getInfo(Model model) { model.addAttribute("currentPage", "get-info-on-pdf"); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/SignatureController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/SignatureController.java index 65d34c0ef..bc95bbb2f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/SignatureController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/SignatureController.java @@ -8,7 +8,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.MediaTypeFactory; import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -16,7 +15,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import stirling.software.SPDF.service.SignatureService; import stirling.software.common.service.UserServiceInterface; -@Controller +// @Controller // Disabled - Backend-only mode, no Thymeleaf UI @RequestMapping("/api/v1/general") public class SignatureController { diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/EditTableOfContentsRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/EditTableOfContentsRequest.java index 51e3bc159..312557b5e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/EditTableOfContentsRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/EditTableOfContentsRequest.java @@ -13,8 +13,9 @@ public class EditTableOfContentsRequest extends PDFFile { @Schema( description = "Bookmark structure in JSON format", + type = "string", example = - "[{\"title\":\"Chapter 1\",\"pageNumber\":1,\"children\":[{\"title\":\"Section 1.1\",\"pageNumber\":2}]}]") + "[{\\\"title\\\":\\\"Chapter 1\\\",\\\"pageNumber\\\":1,\\\"children\\\":[{\\\"title\\\":\\\"Section 1.1\\\",\\\"pageNumber\\\":2}]}]") private String bookmarkData; @Schema( diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/HandleDataRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/HandleDataRequest.java index 7f425dc04..8799980f5 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/HandleDataRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/HandleDataRequest.java @@ -15,8 +15,11 @@ public class HandleDataRequest { private MultipartFile[] fileInput; @Schema( - description = "JSON String", - defaultValue = "{}", + description = + "Pipeline configuration in JSON format containing name and operations list", + type = "string", + example = + "{\\\"name\\\":\\\"Prepare-pdfs-for-email\\\",\\\"pipeline\\\":[{\\\"operation\\\":\\\"/api/v1/misc/repair\\\",\\\"parameters\\\":{}},{\\\"operation\\\":\\\"/api/v1/security/sanitize-pdf\\\",\\\"parameters\\\":{\\\"removeJavaScript\\\":true,\\\"removeEmbeddedFiles\\\":false}},{\\\"operation\\\":\\\"/api/v1/misc/compress-pdf\\\",\\\"parameters\\\":{\\\"optimizeLevel\\\":2}}]}", requiredMode = Schema.RequiredMode.REQUIRED) private String json; } diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/general/BookletImpositionRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/general/BookletImpositionRequest.java new file mode 100644 index 000000000..456302e55 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/general/BookletImpositionRequest.java @@ -0,0 +1,54 @@ +package stirling.software.SPDF.model.api.general; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import stirling.software.common.model.api.PDFFile; + +@Data +@EqualsAndHashCode(callSuper = true) +public class BookletImpositionRequest extends PDFFile { + + @Schema( + description = + "The number of pages per side for booklet printing (always 2 for proper booklet).", + type = "number", + defaultValue = "2", + requiredMode = Schema.RequiredMode.REQUIRED, + allowableValues = {"2"}) + private int pagesPerSheet = 2; + + @Schema(description = "Boolean for if you wish to add border around the pages") + private Boolean addBorder = false; + + @Schema( + description = "The spine location for the booklet.", + type = "string", + defaultValue = "LEFT", + allowableValues = {"LEFT", "RIGHT"}) + private String spineLocation = "LEFT"; + + @Schema(description = "Add gutter margin (inner margin for binding)") + private Boolean addGutter = false; + + @Schema( + description = "Gutter margin size in points (used when addGutter is true)", + type = "number", + defaultValue = "12") + private float gutterSize = 12f; + + @Schema(description = "Generate both front and back sides (double-sided printing)") + private Boolean doubleSided = true; + + @Schema( + description = "For manual duplex: which pass to generate", + type = "string", + defaultValue = "BOTH", + allowableValues = {"BOTH", "FIRST", "SECOND"}) + private String duplexPass = "BOTH"; + + @Schema(description = "Flip back sides for short-edge duplex printing (default is long-edge)") + private Boolean flipOnShortEdge = false; +} diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergeMultiplePagesRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergeMultiplePagesRequest.java index 6d9254023..972db7ce3 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergeMultiplePagesRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergeMultiplePagesRequest.java @@ -13,11 +13,10 @@ public class MergeMultiplePagesRequest extends PDFFile { @Schema( description = "The number of pages to fit onto a single sheet in the output PDF.", - type = "number", - defaultValue = "2", + type = "integer", requiredMode = Schema.RequiredMode.REQUIRED, allowableValues = {"2", "3", "4", "9", "16"}) - private int pagesPerSheet; + private int pagesPerSheet = 2; @Schema(description = "Boolean for if you wish to add border around the pages") private Boolean addBorder; diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergePdfsRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergePdfsRequest.java index 75f75223e..2851f018f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergePdfsRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergePdfsRequest.java @@ -39,4 +39,10 @@ public class MergePdfsRequest extends MultiplePDFFiles { requiredMode = Schema.RequiredMode.NOT_REQUIRED, defaultValue = "false") private boolean generateToc = false; + + @Schema( + description = + "JSON array of client-provided IDs for each uploaded file (same order as fileInput)", + requiredMode = Schema.RequiredMode.NOT_REQUIRED) + private String clientFileIds; } diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/general/RotatePDFRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/general/RotatePDFRequest.java index aebc2b399..030a0df42 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/general/RotatePDFRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/general/RotatePDFRequest.java @@ -14,8 +14,8 @@ public class RotatePDFRequest extends PDFFile { @Schema( description = "The angle by which to rotate the PDF file. This should be a multiple of 90.", + type = "integer", requiredMode = Schema.RequiredMode.REQUIRED, - allowableValues = {"0", "90", "180", "270"}, - defaultValue = "90") - private Integer angle; + allowableValues = {"0", "90", "180", "270"}) + private Integer angle = 90; } diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/AddPageNumbersRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/AddPageNumbersRequest.java index 18622d9d7..3e116742d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/AddPageNumbersRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/AddPageNumbersRequest.java @@ -44,10 +44,10 @@ public class AddPageNumbersRequest extends PDFWithPageNums { "Position: 1-9 representing positions on the page (1=top-left, 2=top-center," + " 3=top-right, 4=middle-left, 5=middle-center, 6=middle-right," + " 7=bottom-left, 8=bottom-center, 9=bottom-right)", + type = "integer", allowableValues = {"1", "2", "3", "4", "5", "6", "7", "8", "9"}, - defaultValue = "8", requiredMode = RequiredMode.REQUIRED) - private int position; + private int position = 8; @Schema( description = "Starting number for page numbering", diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequest.java index eafeeceaa..bf96dd217 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequest.java @@ -15,10 +15,10 @@ public class OptimizePdfRequest extends PDFFile { description = "The level of optimization to apply to the PDF file. Higher values indicate" + " greater compression but may reduce quality.", - defaultValue = "5", + type = "integer", requiredMode = Schema.RequiredMode.REQUIRED, allowableValues = {"1", "2", "3", "4", "5", "6", "7", "8", "9"}) - private Integer optimizeLevel; + private Integer optimizeLevel = 5; @Schema( description = "The expected output size, e.g. '100MB', '25KB', etc.", diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddPasswordRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddPasswordRequest.java index 666318a49..541a8717f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddPasswordRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddPasswordRequest.java @@ -27,8 +27,8 @@ public class AddPasswordRequest extends PDFFile { @Schema( description = "The length of the encryption key", + type = "integer", allowableValues = {"40", "128", "256"}, - defaultValue = "256", requiredMode = Schema.RequiredMode.REQUIRED) private int keyLength = 256; diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignatureValidationResult.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignatureValidationResult.java index b4c51f365..b45aeefc3 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignatureValidationResult.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignatureValidationResult.java @@ -6,17 +6,32 @@ 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 diff --git a/app/core/src/main/java/stirling/software/SPDF/service/CertificateValidationService.java b/app/core/src/main/java/stirling/software/SPDF/service/CertificateValidationService.java index 672ee76f9..6b2c097cc 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/CertificateValidationService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/CertificateValidationService.java @@ -1,143 +1,863 @@ 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.KeyStoreException; +import java.security.MessageDigest; import java.security.cert.*; import java.util.*; -import org.springframework.stereotype.Service; +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 io.github.pixee.security.BoundedLineReader; +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 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 { - private KeyStore trustStore; + /** + * 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 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; + } @PostConstruct private void initializeTrustStore() throws Exception { - trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); - trustStore.load(null, null); - loadMozillaCertificates(); + 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(); } - 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; + /** + * 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 intermediates, + X509Certificate customTrustAnchor, + Date validationTime) + throws GeneralSecurityException { - while ((line = BoundedLineReader.readLine(reader, 5_000_000)) != null) { - if (line.startsWith("CKA_VALUE MULTILINE_OCTAL")) { - inCert = true; - certData = new StringBuilder(); - continue; + // Build trust anchors + Set anchors = new HashSet<>(); + if (customTrustAnchor != null) { + anchors.add(new TrustAnchor(customTrustAnchor, null)); + } else { + Enumeration aliases = signingTrustAnchors.aliases(); + while (aliases.hasMoreElements()) { + Certificate c = signingTrustAnchors.getCertificate(aliases.nextElement()); + if (c instanceof X509Certificate x) { + anchors.add(new TrustAnchor(x, null)); } - 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 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 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) { + 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()); } } } - } - } - private byte[] parseOctalData(String data) { - try { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - String[] tokens = data.split("\\\\"); - for (String token : tokens) { - token = token.trim(); - if (!token.isEmpty()) { - baos.write(Integer.parseInt(token, 8)); + // 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"); + } } } - return baos.toByteArray(); } catch (Exception e) { - return null; + log.debug("Error extracting validation time: {}", e.getMessage()); } + return null; } - public boolean validateCertificateChain(X509Certificate cert) { + /** + * 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) { try { - CertPathValidator validator = CertPathValidator.getInstance("PKIX"); - CertificateFactory cf = CertificateFactory.getInstance("X.509"); - List certList = Collections.singletonList(cert); - CertPath certPath = cf.generateCertPath(certList); - - Set anchors = new HashSet<>(); - Enumeration 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 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(); + cert.checkValidity(at); return false; } catch (CertificateExpiredException | CertificateNotYetValidException e) { return true; } } - public boolean validateCertificateChainWithCustomCert( - X509Certificate cert, X509Certificate customCert) { + /** + * 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) { try { - cert.verify(customCert.getPublicKey()); + if (!cert.getSubjectX500Principal().equals(cert.getIssuerX500Principal())) { + return false; + } + cert.verify(cert.getPublicKey()); return true; } catch (Exception e) { return false; } } - public boolean validateTrustWithCustomCert(X509Certificate cert, X509Certificate customCert) { + /** + * Calculate SHA-256 fingerprint of certificate + * + * @param cert Certificate + * @return Hex string of SHA-256 hash + */ + public String sha256Fingerprint(X509Certificate cert) { try { - // Compare the issuer of the signature certificate with the custom certificate - return cert.getIssuerX500Principal().equals(customCert.getSubjectX500Principal()); + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] hash = md.digest(cert.getEncoded()); + return bytesToHex(hash); } catch (Exception e) { - return false; + return ""; } } + + 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 extractIntermediateCertificates( + Store certStore, X509Certificate signerCert) { + List intermediates = new ArrayList<>(); + try { + JcaX509CertificateConverter converter = new JcaX509CertificateConverter(); + Collection 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 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 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 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 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 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 parseLotlForTslLocations(byte[] lotlBytes) throws Exception { + DocumentBuilderFactory dbf = secureDbfWithNamespaces(); + DocumentBuilder db = dbf.newDocumentBuilder(); + Document doc = db.parse(new ByteArrayInputStream(lotlBytes)); + + List 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; + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/service/SignatureService.java b/app/core/src/main/java/stirling/software/SPDF/service/SignatureService.java index 9c28160a9..a04ff8d33 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/SignatureService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/SignatureService.java @@ -11,7 +11,7 @@ import java.util.Locale; import java.util.stream.Stream; import org.springframework.stereotype.Service; -import org.thymeleaf.util.StringUtils; +import org.springframework.util.StringUtils; import lombok.extern.slf4j.Slf4j; @@ -42,7 +42,7 @@ public class SignatureService { List signatures = new ArrayList<>(); // Get signatures from user's personal folder - if (!StringUtils.isEmptyOrWhitespace(username)) { + if (StringUtils.hasText(username)) { Path userFolder = Paths.get(SIGNATURE_BASE_PATH, username); if (Files.exists(userFolder)) { try { diff --git a/app/core/src/main/resources/templates/fragments/common.html b/app/core/src/main/resources/templates/fragments/common.html index 2883d905c..00975974e 100644 --- a/app/core/src/main/resources/templates/fragments/common.html +++ b/app/core/src/main/resources/templates/fragments/common.html @@ -462,10 +462,6 @@ -
- -
- google drive
@@ -483,16 +479,4 @@ - -
- - - - - -
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/configuration/ServerCertificateInitializer.java b/app/proprietary/src/main/java/stirling/software/proprietary/configuration/ServerCertificateInitializer.java new file mode 100644 index 000000000..6e82d1d99 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/configuration/ServerCertificateInitializer.java @@ -0,0 +1,27 @@ +package stirling.software.proprietary.configuration; + +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.service.ServerCertificateServiceInterface; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ServerCertificateInitializer { + + private final ServerCertificateServiceInterface serverCertificateService; + + @EventListener(ApplicationReadyEvent.class) + public void initializeServerCertificate() { + try { + serverCertificateService.initializeServerCertificate(); + } catch (Exception e) { + log.error("Failed to initialize server certificate", e); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java new file mode 100644 index 000000000..5f321a89e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java @@ -0,0 +1,480 @@ +package stirling.software.proprietary.controller.api; + +import static stirling.software.common.util.ProviderUtils.validateProvider; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.*; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.swagger.v3.oas.annotations.Operation; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.annotations.api.ProprietaryUiDataApi; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.Security; +import stirling.software.common.model.ApplicationProperties.Security.OAUTH2; +import stirling.software.common.model.ApplicationProperties.Security.OAUTH2.Client; +import stirling.software.common.model.ApplicationProperties.Security.SAML2; +import stirling.software.common.model.FileInfo; +import stirling.software.common.model.enumeration.Role; +import stirling.software.common.model.oauth2.GitHubProvider; +import stirling.software.common.model.oauth2.GoogleProvider; +import stirling.software.common.model.oauth2.KeycloakProvider; +import stirling.software.proprietary.audit.AuditEventType; +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.security.config.EnterpriseEndpoint; +import stirling.software.proprietary.security.database.repository.SessionRepository; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.Authority; +import stirling.software.proprietary.security.model.SessionEntity; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; +import stirling.software.proprietary.security.service.DatabaseService; +import stirling.software.proprietary.security.service.TeamService; +import stirling.software.proprietary.security.session.SessionPersistentRegistry; + +@Slf4j +@ProprietaryUiDataApi +@EnterpriseEndpoint +public class ProprietaryUIDataController { + + private final ApplicationProperties applicationProperties; + private final AuditConfigurationProperties auditConfig; + private final SessionPersistentRegistry sessionPersistentRegistry; + private final UserRepository userRepository; + private final TeamRepository teamRepository; + private final SessionRepository sessionRepository; + private final DatabaseService databaseService; + private final boolean runningEE; + private final ObjectMapper objectMapper; + + public ProprietaryUIDataController( + ApplicationProperties applicationProperties, + AuditConfigurationProperties auditConfig, + SessionPersistentRegistry sessionPersistentRegistry, + UserRepository userRepository, + TeamRepository teamRepository, + SessionRepository sessionRepository, + DatabaseService databaseService, + ObjectMapper objectMapper, + @Qualifier("runningEE") boolean runningEE) { + this.applicationProperties = applicationProperties; + this.auditConfig = auditConfig; + this.sessionPersistentRegistry = sessionPersistentRegistry; + this.userRepository = userRepository; + this.teamRepository = teamRepository; + this.sessionRepository = sessionRepository; + this.databaseService = databaseService; + this.objectMapper = objectMapper; + this.runningEE = runningEE; + } + + @GetMapping("/audit-dashboard") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Get audit dashboard data") + public ResponseEntity getAuditDashboardData() { + AuditDashboardData data = new AuditDashboardData(); + data.setAuditEnabled(auditConfig.isEnabled()); + data.setAuditLevel(auditConfig.getAuditLevel()); + data.setAuditLevelInt(auditConfig.getLevel()); + data.setRetentionDays(auditConfig.getRetentionDays()); + data.setAuditLevels(AuditLevel.values()); + data.setAuditEventTypes(AuditEventType.values()); + + return ResponseEntity.ok(data); + } + + @GetMapping("/login") + @Operation(summary = "Get login page data") + public ResponseEntity getLoginData() { + LoginData data = new LoginData(); + Map providerList = new HashMap<>(); + Security securityProps = applicationProperties.getSecurity(); + OAUTH2 oauth = securityProps.getOauth2(); + + if (oauth != null && oauth.getEnabled()) { + if (oauth.isSettingsValid()) { + String firstChar = String.valueOf(oauth.getProvider().charAt(0)); + String clientName = + oauth.getProvider().replaceFirst(firstChar, firstChar.toUpperCase()); + providerList.put("/oauth2/authorization/" + oauth.getProvider(), clientName); + } + + Client client = oauth.getClient(); + if (client != null) { + GoogleProvider google = client.getGoogle(); + if (validateProvider(google)) { + providerList.put( + "/oauth2/authorization/" + google.getName(), google.getClientName()); + } + + GitHubProvider github = client.getGithub(); + if (validateProvider(github)) { + providerList.put( + "/oauth2/authorization/" + github.getName(), github.getClientName()); + } + + KeycloakProvider keycloak = client.getKeycloak(); + if (validateProvider(keycloak)) { + providerList.put( + "/oauth2/authorization/" + keycloak.getName(), + keycloak.getClientName()); + } + } + } + + SAML2 saml2 = securityProps.getSaml2(); + if (securityProps.isSaml2Active() + && applicationProperties.getSystem().getEnableAlphaFunctionality() + && applicationProperties.getPremium().isEnabled()) { + String samlIdp = saml2.getProvider(); + String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId(); + + if (!applicationProperties.getPremium().getProFeatures().isSsoAutoLogin()) { + providerList.put(saml2AuthenticationPath, samlIdp + " (SAML 2)"); + } + } + + // Remove null entries + providerList + .entrySet() + .removeIf(entry -> entry.getKey() == null || entry.getValue() == null); + + data.setProviderList(providerList); + data.setLoginMethod(securityProps.getLoginMethod()); + data.setAltLogin(!providerList.isEmpty() && securityProps.isAltLogin()); + + return ResponseEntity.ok(data); + } + + @GetMapping("/admin-settings") + @PreAuthorize("hasRole('ROLE_ADMIN')") + @Operation(summary = "Get admin settings data") + public ResponseEntity getAdminSettingsData(Authentication authentication) { + List allUsers = userRepository.findAllWithTeam(); + Iterator iterator = allUsers.iterator(); + Map roleDetails = Role.getAllRoleDetails(); + + Map userSessions = new HashMap<>(); + Map userLastRequest = new HashMap<>(); + int activeUsers = 0; + int disabledUsers = 0; + + while (iterator.hasNext()) { + User user = iterator.next(); + if (user != null) { + boolean shouldRemove = false; + + // Check if user is an INTERNAL_API_USER + for (Authority authority : user.getAuthorities()) { + if (authority.getAuthority().equals(Role.INTERNAL_API_USER.getRoleId())) { + shouldRemove = true; + roleDetails.remove(Role.INTERNAL_API_USER.getRoleId()); + break; + } + } + + // Check if user is part of the Internal team + if (user.getTeam() != null + && user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) { + shouldRemove = true; + } + + if (shouldRemove) { + iterator.remove(); + continue; + } + + // Session status and last request time + int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval(); + boolean hasActiveSession = false; + Date lastRequest = null; + Optional latestSession = + sessionPersistentRegistry.findLatestSession(user.getUsername()); + + if (latestSession.isPresent()) { + SessionEntity sessionEntity = latestSession.get(); + Date lastAccessedTime = sessionEntity.getLastRequest(); + Instant now = Instant.now(); + Instant expirationTime = + lastAccessedTime + .toInstant() + .plus(maxInactiveInterval, ChronoUnit.SECONDS); + + if (now.isAfter(expirationTime)) { + sessionPersistentRegistry.expireSession(sessionEntity.getSessionId()); + } else { + hasActiveSession = !sessionEntity.isExpired(); + } + lastRequest = sessionEntity.getLastRequest(); + } else { + lastRequest = new Date(0); + } + + userSessions.put(user.getUsername(), hasActiveSession); + userLastRequest.put(user.getUsername(), lastRequest); + + if (hasActiveSession) activeUsers++; + if (!user.isEnabled()) disabledUsers++; + } + } + + // Sort users by active status and last request date + List sortedUsers = + allUsers.stream() + .sorted( + (u1, u2) -> { + boolean u1Active = userSessions.get(u1.getUsername()); + boolean u2Active = userSessions.get(u2.getUsername()); + if (u1Active && !u2Active) return -1; + if (!u1Active && u2Active) return 1; + + Date u1LastRequest = + userLastRequest.getOrDefault( + u1.getUsername(), new Date(0)); + Date u2LastRequest = + userLastRequest.getOrDefault( + u2.getUsername(), new Date(0)); + return u2LastRequest.compareTo(u1LastRequest); + }) + .toList(); + + List allTeams = + teamRepository.findAll().stream() + .filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) + .toList(); + + AdminSettingsData data = new AdminSettingsData(); + data.setUsers(sortedUsers); + data.setCurrentUsername(authentication.getName()); + data.setRoleDetails(roleDetails); + data.setUserSessions(userSessions); + data.setUserLastRequest(userLastRequest); + data.setTotalUsers(allUsers.size()); + data.setActiveUsers(activeUsers); + data.setDisabledUsers(disabledUsers); + data.setTeams(allTeams); + data.setMaxPaidUsers(applicationProperties.getPremium().getMaxUsers()); + + return ResponseEntity.ok(data); + } + + @GetMapping("/account") + @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") + @Operation(summary = "Get account page data") + public ResponseEntity getAccountData(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) { + return ResponseEntity.status(401).build(); + } + + Object principal = authentication.getPrincipal(); + String username = null; + boolean isOAuth2Login = false; + boolean isSaml2Login = false; + + if (principal instanceof UserDetails detailsUser) { + username = detailsUser.getUsername(); + } else if (principal instanceof OAuth2User oAuth2User) { + username = oAuth2User.getName(); + isOAuth2Login = true; + } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { + username = saml2User.name(); + isSaml2Login = true; + } + + if (username == null) { + return ResponseEntity.status(401).build(); + } + + Optional user = userRepository.findByUsernameIgnoreCaseWithSettings(username); + if (user.isEmpty()) { + return ResponseEntity.status(404).build(); + } + + String settingsJson; + try { + settingsJson = objectMapper.writeValueAsString(user.get().getSettings()); + } catch (JsonProcessingException e) { + log.error("Error converting settings map", e); + return ResponseEntity.status(500).build(); + } + + AccountData data = new AccountData(); + data.setUsername(username); + data.setRole(user.get().getRolesAsString()); + data.setSettings(settingsJson); + data.setChangeCredsFlag(user.get().isFirstLogin()); + data.setOAuth2Login(isOAuth2Login); + data.setSaml2Login(isSaml2Login); + + return ResponseEntity.ok(data); + } + + @GetMapping("/teams") + @PreAuthorize("hasRole('ROLE_ADMIN')") + @Operation(summary = "Get teams list data") + public ResponseEntity getTeamsData() { + List allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount(); + List teamsWithCounts = + allTeamsWithCounts.stream() + .filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) + .toList(); + + List teamActivities = sessionRepository.findLatestActivityByTeam(); + Map teamLastRequest = new HashMap<>(); + for (Object[] result : teamActivities) { + Long teamId = (Long) result[0]; + Date lastActivity = (Date) result[1]; + teamLastRequest.put(teamId, lastActivity); + } + + TeamsData data = new TeamsData(); + data.setTeamsWithCounts(teamsWithCounts); + data.setTeamLastRequest(teamLastRequest); + + return ResponseEntity.ok(data); + } + + @GetMapping("/teams/{id}") + @PreAuthorize("hasRole('ROLE_ADMIN')") + @Operation(summary = "Get team details data") + public ResponseEntity getTeamDetailsData(@PathVariable("id") Long id) { + Team team = + teamRepository + .findById(id) + .orElseThrow(() -> new RuntimeException("Team not found")); + + if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) { + return ResponseEntity.status(403).build(); + } + + List teamUsers = userRepository.findAllByTeamId(id); + List allUsers = userRepository.findAllWithTeam(); + List availableUsers = + allUsers.stream() + .filter( + user -> + (user.getTeam() == null + || !user.getTeam().getId().equals(id)) + && (user.getTeam() == null + || !user.getTeam() + .getName() + .equals( + TeamService + .INTERNAL_TEAM_NAME))) + .toList(); + + List userSessions = sessionRepository.findLatestSessionByTeamId(id); + Map userLastRequest = new HashMap<>(); + for (Object[] result : userSessions) { + String username = (String) result[0]; + Date lastRequest = (Date) result[1]; + userLastRequest.put(username, lastRequest); + } + + TeamDetailsData data = new TeamDetailsData(); + data.setTeam(team); + data.setTeamUsers(teamUsers); + data.setAvailableUsers(availableUsers); + data.setUserLastRequest(userLastRequest); + + return ResponseEntity.ok(data); + } + + @GetMapping("/database") + @PreAuthorize("hasRole('ROLE_ADMIN')") + @Operation(summary = "Get database management data") + public ResponseEntity getDatabaseData() { + List backupList = databaseService.getBackupList(); + String dbVersion = databaseService.getH2Version(); + boolean isVersionUnknown = "Unknown".equalsIgnoreCase(dbVersion); + + DatabaseData data = new DatabaseData(); + data.setBackupFiles(backupList); + data.setDatabaseVersion(dbVersion); + data.setVersionUnknown(isVersionUnknown); + + return ResponseEntity.ok(data); + } + + // Data classes + @Data + public static class AuditDashboardData { + private boolean auditEnabled; + private AuditLevel auditLevel; + private int auditLevelInt; + private int retentionDays; + private AuditLevel[] auditLevels; + private AuditEventType[] auditEventTypes; + } + + @Data + public static class LoginData { + private Map providerList; + private String loginMethod; + private boolean altLogin; + } + + @Data + public static class AdminSettingsData { + private List users; + private String currentUsername; + private Map roleDetails; + private Map userSessions; + private Map userLastRequest; + private int totalUsers; + private int activeUsers; + private int disabledUsers; + private List teams; + private int maxPaidUsers; + } + + @Data + public static class AccountData { + private String username; + private String role; + private String settings; + private boolean changeCredsFlag; + private boolean oAuth2Login; + private boolean saml2Login; + } + + @Data + public static class TeamsData { + private List teamsWithCounts; + private Map teamLastRequest; + } + + @Data + public static class TeamDetailsData { + private Team team; + private List teamUsers; + private List availableUsers; + private Map userLastRequest; + } + + @Data + public static class DatabaseData { + private List backupFiles; + private String databaseVersion; + private boolean versionUnknown; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java index 5157b3233..119c90955 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/Team.java @@ -4,6 +4,8 @@ import java.io.Serializable; import java.util.HashSet; import java.util.Set; +import com.fasterxml.jackson.annotation.JsonIgnore; + import jakarta.persistence.*; import lombok.*; @@ -30,6 +32,7 @@ public class Team implements Serializable { private String name; @OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonIgnore private Set users = new HashSet<>(); public void addUser(User user) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java index 25b3c5096..a1e8112d1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java @@ -13,7 +13,7 @@ public class RateLimitResetScheduler { private final IPRateLimitingFilter rateLimitingFilter; - @Scheduled(cron = "0 0 0 * * MON") // At 00:00 every Monday TODO: configurable + @Scheduled(cron = "${security.rate-limit.reset-schedule:0 0 0 * * MON}") public void resetRateLimit() { rateLimitingFilter.resetRequestCounts(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java index 215b82347..2e71b670d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java @@ -12,7 +12,6 @@ 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) @@ -55,19 +54,6 @@ 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) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index 2f4f18213..d236c657b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -13,12 +13,10 @@ import java.util.regex.Pattern; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.util.HtmlUtils; @@ -28,13 +26,13 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.annotations.api.AdminApi; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.RegexPatternUtils; @@ -42,9 +40,7 @@ import stirling.software.proprietary.security.model.api.admin.SettingValueRespon import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest; import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest; -@Controller -@Tag(name = "Admin Settings", description = "Admin-only Settings Management APIs") -@RequestMapping("/api/v1/admin/settings") +@AdminApi @RequiredArgsConstructor @PreAuthorize("hasRole('ROLE_ADMIN')") @Slf4j diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java new file mode 100644 index 000000000..0dd8ee4bf --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -0,0 +1,238 @@ +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 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 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 buildUserResponse(User user) { + Map 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 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) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/DatabaseController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/DatabaseController.java index fdb8b4412..d2974dbe8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/DatabaseController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/DatabaseController.java @@ -14,7 +14,6 @@ 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.stereotype.Controller; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.support.RedirectAttributes; @@ -22,20 +21,18 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.annotations.api.DatabaseApi; import stirling.software.proprietary.security.database.H2SQLCondition; import stirling.software.proprietary.security.service.DatabaseService; @Slf4j -@Controller -@RequestMapping("/api/v1/database") +@DatabaseApi @PreAuthorize("hasRole('ROLE_ADMIN')") @Conditional(H2SQLCondition.class) -@Tag(name = "Database", description = "Database APIs for backup, import, and management") @RequiredArgsConstructor public class DatabaseController { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/ServerCertificateController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/ServerCertificateController.java new file mode 100644 index 000000000..52d77e40c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/ServerCertificateController.java @@ -0,0 +1,144 @@ +package stirling.software.proprietary.security.controller.api; + +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.*; +import org.springframework.web.multipart.MultipartFile; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.service.ServerCertificateServiceInterface; + +@RestController +@RequestMapping("/api/v1/admin/server-certificate") +@Slf4j +@Tag( + name = "Admin - Server Certificate", + description = "Admin APIs for server certificate management") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class ServerCertificateController { + + private final ServerCertificateServiceInterface serverCertificateService; + + @GetMapping("/info") + @Operation( + summary = "Get server certificate information", + description = "Returns information about the current server certificate") + public ResponseEntity + getServerCertificateInfo() { + try { + ServerCertificateServiceInterface.ServerCertificateInfo info = + serverCertificateService.getServerCertificateInfo(); + return ResponseEntity.ok(info); + } catch (Exception e) { + log.error("Failed to get server certificate info", e); + return ResponseEntity.internalServerError().build(); + } + } + + @PostMapping("/upload") + @Operation( + summary = "Upload server certificate", + description = + "Upload a new PKCS12 certificate file to be used as the server certificate") + public ResponseEntity uploadServerCertificate( + @Parameter(description = "PKCS12 certificate file", required = true) + @RequestParam("file") + MultipartFile file, + @Parameter(description = "Certificate password", required = true) + @RequestParam("password") + String password) { + + if (file.isEmpty()) { + return ResponseEntity.badRequest().body("Certificate file cannot be empty"); + } + + if (!file.getOriginalFilename().toLowerCase().endsWith(".p12") + && !file.getOriginalFilename().toLowerCase().endsWith(".pfx")) { + return ResponseEntity.badRequest() + .body("Only PKCS12 (.p12 or .pfx) files are supported"); + } + + try { + serverCertificateService.uploadServerCertificate(file.getInputStream(), password); + return ResponseEntity.ok("Server certificate uploaded successfully"); + } catch (IllegalArgumentException e) { + log.warn("Invalid certificate upload: {}", e.getMessage()); + return ResponseEntity.badRequest().body("Invalid certificate or password."); + } catch (Exception e) { + log.error("Failed to upload server certificate", e); + return ResponseEntity.internalServerError().body("Failed to upload server certificate"); + } + } + + @DeleteMapping + @Operation( + summary = "Delete server certificate", + description = "Delete the current server certificate") + public ResponseEntity deleteServerCertificate() { + try { + serverCertificateService.deleteServerCertificate(); + return ResponseEntity.ok("Server certificate deleted successfully"); + } catch (Exception e) { + log.error("Failed to delete server certificate", e); + return ResponseEntity.internalServerError().body("Failed to delete server certificate"); + } + } + + @PostMapping("/generate") + @Operation( + summary = "Generate new server certificate", + description = "Generate a new self-signed server certificate") + public ResponseEntity generateServerCertificate() { + try { + serverCertificateService.deleteServerCertificate(); // Remove existing if any + serverCertificateService.initializeServerCertificate(); // Generate new + return ResponseEntity.ok("New server certificate generated successfully"); + } catch (Exception e) { + log.error("Failed to generate server certificate", e); + return ResponseEntity.internalServerError() + .body("Failed to generate server certificate"); + } + } + + @GetMapping("/certificate") + @Operation( + summary = "Download server certificate", + description = "Download the server certificate in DER format for validation purposes") + public ResponseEntity getServerCertificate() { + try { + if (!serverCertificateService.hasServerCertificate()) { + return ResponseEntity.notFound().build(); + } + + byte[] certificate = serverCertificateService.getServerCertificatePublicKey(); + + return ResponseEntity.ok() + .header( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"server-cert.cer\"") + .contentType(MediaType.valueOf("application/pkix-cert")) + .body(certificate); + } catch (Exception e) { + log.error("Failed to get server certificate", e); + return ResponseEntity.internalServerError().build(); + } + } + + @GetMapping("/enabled") + @Operation( + summary = "Check if server certificate feature is enabled", + description = + "Returns whether the server certificate feature is enabled in configuration") + public ResponseEntity isServerCertificateEnabled() { + return ResponseEntity.ok(serverCertificateService.isEnabled()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java index fa8588e7b..84066ec69 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java @@ -3,17 +3,15 @@ package stirling.software.proprietary.security.controller.api; import java.util.Optional; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.view.RedirectView; -import io.swagger.v3.oas.annotations.tags.Tag; - import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.annotations.api.TeamApi; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.config.PremiumEndpoint; import stirling.software.proprietary.security.database.repository.UserRepository; @@ -21,9 +19,7 @@ import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.service.TeamService; -@Controller -@RequestMapping("/api/v1/team") -@Tag(name = "Team", description = "Team Management APIs") +@TeamApi @Slf4j @RequiredArgsConstructor @PremiumEndpoint diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index f89290e93..d7ec4293a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -3,6 +3,7 @@ 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; @@ -15,14 +16,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.stereotype.Controller; -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 io.swagger.v3.oas.annotations.tags.Tag; - import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.transaction.Transactional; @@ -30,6 +27,7 @@ import jakarta.transaction.Transactional; 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.common.model.exception.UnsupportedProviderException; @@ -47,9 +45,7 @@ import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.session.SessionPersistentRegistry; -@Controller -@Tag(name = "User", description = "User APIs") -@RequestMapping("/api/v1/user") +@UserApi @Slf4j @RequiredArgsConstructor public class UserController { @@ -63,24 +59,83 @@ public class UserController { @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/register") - public String register(@ModelAttribute UsernameAndPass requestModel, Model model) + public ResponseEntity register(@RequestBody UsernameAndPass usernameAndPass) throws SQLException, UnsupportedProviderException { - if (userService.usernameExistsIgnoreCase(requestModel.getUsername())) { - model.addAttribute("error", "Username already exists"); - return "register"; - } 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().length() < 6) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Password must be at least 6 characters")); + } + Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null); - userService.saveUser( - requestModel.getUsername(), - requestModel.getPassword(), - team, - Role.USER.getRoleId(), - false); + 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) { - return "redirect:/login?messageType=invalidUsername"; + 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())); } - return "redirect:/login?registered=true"; + } + + /** + * Helper method to build user response object + * + * @param user User entity + * @return Map containing user information + */ + private Map buildUserResponse(User user) { + Map 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 appMetadata = new HashMap<>(); + appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider + userMap.put("app_metadata", appMetadata); + + return userMap; } @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/DatabaseWebController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/DatabaseWebController.java index 940c0c13f..ee33be0b9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/DatabaseWebController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/DatabaseWebController.java @@ -4,9 +4,7 @@ import java.util.List; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; -import org.springframework.stereotype.Controller; import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; import io.swagger.v3.oas.annotations.tags.Tag; @@ -17,15 +15,16 @@ import lombok.RequiredArgsConstructor; import stirling.software.common.model.FileInfo; import stirling.software.proprietary.security.service.DatabaseService; -@Controller +// @Controller // Disabled - Backend-only mode, no Thymeleaf UI @Tag(name = "Database Management", description = "Database management and security APIs") @RequiredArgsConstructor public class DatabaseWebController { private final DatabaseService databaseService; + @Deprecated @PreAuthorize("hasRole('ROLE_ADMIN')") - @GetMapping("/database") + // @GetMapping("/database") public String database(HttpServletRequest request, Model model, Authentication authentication) { String error = request.getParameter("error"); String confirmed = request.getParameter("infoMessage"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/TeamWebController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/TeamWebController.java index 59b67848e..320d69fd5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/TeamWebController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/TeamWebController.java @@ -6,9 +6,7 @@ import java.util.List; import java.util.Map; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.stereotype.Controller; import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -25,7 +23,7 @@ import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.service.TeamService; -@Controller +// @Controller // Disabled - Backend-only mode, no Thymeleaf UI @RequestMapping("/teams") @RequiredArgsConstructor @Slf4j @@ -35,7 +33,8 @@ public class TeamWebController { private final SessionRepository sessionRepository; private final UserRepository userRepository; - @GetMapping + @Deprecated + // @GetMapping @PreAuthorize("hasRole('ROLE_ADMIN')") public String listTeams(HttpServletRequest request, Model model) { // Get teams with user counts using a DTO projection @@ -86,7 +85,8 @@ public class TeamWebController { return "accounts/teams"; } - @GetMapping("/{id}") + @Deprecated + // @GetMapping("/{id}") @PreAuthorize("hasRole('ROLE_ADMIN')") public String viewTeamDetails( HttpServletRequest request, @PathVariable("id") Long id, Model model) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java index 4d74dbfd8..36fa23a6a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java @@ -22,6 +22,8 @@ public interface UserRepository extends JpaRepository { Optional findByApiKey(String apiKey); + Optional findBySsoProviderAndSsoProviderId(String ssoProvider, String ssoProviderId); + List findByAuthenticationTypeIgnoreCase(String authenticationType); @Query("SELECT u FROM User u WHERE u.team IS NULL") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index bec6f1d04..6a32511b0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -236,6 +236,10 @@ 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 + "/site.webmanifest" }; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java index 6e68233bf..ad8170ad9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java @@ -11,7 +11,6 @@ import java.util.Locale; 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; @@ -42,7 +41,7 @@ import stirling.software.proprietary.security.service.UserService; @Slf4j @Configuration -@ConditionalOnBooleanProperty("security.oauth2.enabled") +@ConditionalOnProperty(prefix = "security", name = "oauth2.enabled", havingValue = "true") public class OAuth2Configuration { public static final String REDIRECT_URI_PATH = "{baseUrl}/login/oauth2/code/"; @@ -54,6 +53,9 @@ 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 @@ -76,7 +78,7 @@ public class OAuth2Configuration { private Optional keycloakClientRegistration() { OAUTH2 oauth2 = applicationProperties.getSecurity().getOauth2(); - if (isOAuth2Enabled(oauth2) || isClientInitialised(oauth2)) { + if (isOAuth2Disabled(oauth2) || isClientInitialised(oauth2)) { return Optional.empty(); } @@ -106,7 +108,7 @@ public class OAuth2Configuration { private Optional googleClientRegistration() { OAUTH2 oAuth2 = applicationProperties.getSecurity().getOauth2(); - if (isOAuth2Enabled(oAuth2) || isClientInitialised(oAuth2)) { + if (isOAuth2Disabled(oAuth2) || isClientInitialised(oAuth2)) { return Optional.empty(); } @@ -139,12 +141,23 @@ public class OAuth2Configuration { private Optional githubClientRegistration() { OAUTH2 oAuth2 = applicationProperties.getSecurity().getOauth2(); - if (isOAuth2Enabled(oAuth2)) { + if (isOAuth2Disabled(oAuth2)) { + log.debug("OAuth2 is disabled, skipping GitHub client registration"); 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(), @@ -152,7 +165,15 @@ public class OAuth2Configuration { githubClient.getScopes(), githubClient.getUseAsUsername()); - return validateProvider(github) + boolean isValid = validateProvider(github); + log.info( + "GitHub OAuth2 provider validation: {} (clientId: {}, clientSecret: {}, scopes: {})", + isValid, + githubClient.getClientId(), + githubClient.getClientSecret() != null ? "***" : "null", + githubClient.getScopes()); + + return isValid ? Optional.of( ClientRegistration.withRegistrationId(github.getName()) .clientId(github.getClientId()) @@ -172,7 +193,7 @@ public class OAuth2Configuration { private Optional oidcClientRegistration() { OAUTH2 oauth = applicationProperties.getSecurity().getOauth2(); - if (isOAuth2Enabled(oauth) || isClientInitialised(oauth)) { + if (isOAuth2Disabled(oauth) || isClientInitialised(oauth)) { return Optional.empty(); } @@ -209,7 +230,7 @@ public class OAuth2Configuration { : Optional.empty(); } - private boolean isOAuth2Enabled(OAUTH2 oAuth2) { + private boolean isOAuth2Disabled(OAUTH2 oAuth2) { return oAuth2 == null || !oAuth2.getEnabled(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java new file mode 100644 index 000000000..a743b21fe --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java @@ -0,0 +1,252 @@ +package stirling.software.proprietary.service; + +import java.io.*; +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.*; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Date; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.service.ServerCertificateServiceInterface; + +@Service +@Slf4j +public class ServerCertificateService implements ServerCertificateServiceInterface { + + private static final String KEYSTORE_FILENAME = "server-certificate.p12"; + private static final String KEYSTORE_ALIAS = "stirling-pdf-server"; + private static final String DEFAULT_PASSWORD = "stirling-pdf-server-cert"; + + @Value("${system.serverCertificate.enabled:false}") + private boolean enabled; + + @Value("${system.serverCertificate.organizationName:Stirling-PDF}") + private String organizationName; + + @Value("${system.serverCertificate.validity:365}") + private int validityDays; + + @Value("${system.serverCertificate.regenerateOnStartup:false}") + private boolean regenerateOnStartup; + + static { + Security.addProvider(new BouncyCastleProvider()); + } + + private Path getKeystorePath() { + return Paths.get(InstallationPathConfig.getConfigPath(), KEYSTORE_FILENAME); + } + + public boolean isEnabled() { + return enabled; + } + + public boolean hasServerCertificate() { + return Files.exists(getKeystorePath()); + } + + public void initializeServerCertificate() { + if (!enabled) { + log.debug("Server certificate feature is disabled"); + return; + } + + Path keystorePath = getKeystorePath(); + + if (!Files.exists(keystorePath) || regenerateOnStartup) { + try { + generateServerCertificate(); + log.info("Generated new server certificate at: {}", keystorePath); + } catch (Exception e) { + log.error("Failed to generate server certificate", e); + } + } else { + log.info("Server certificate already exists at: {}", keystorePath); + } + } + + public KeyStore getServerKeyStore() throws Exception { + if (!enabled || !hasServerCertificate()) { + throw new IllegalStateException("Server certificate is not available"); + } + + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + try (FileInputStream fis = new FileInputStream(getKeystorePath().toFile())) { + keyStore.load(fis, DEFAULT_PASSWORD.toCharArray()); + } + return keyStore; + } + + public String getServerCertificatePassword() { + return DEFAULT_PASSWORD; + } + + public X509Certificate getServerCertificate() throws Exception { + KeyStore keyStore = getServerKeyStore(); + return (X509Certificate) keyStore.getCertificate(KEYSTORE_ALIAS); + } + + public byte[] getServerCertificatePublicKey() throws Exception { + X509Certificate cert = getServerCertificate(); + return cert.getEncoded(); + } + + public void uploadServerCertificate(InputStream p12Stream, String password) throws Exception { + // Validate the uploaded certificate + KeyStore uploadedKeyStore = KeyStore.getInstance("PKCS12"); + uploadedKeyStore.load(p12Stream, password.toCharArray()); + + // Find the first private key entry + String alias = null; + for (String a : java.util.Collections.list(uploadedKeyStore.aliases())) { + if (uploadedKeyStore.isKeyEntry(a)) { + alias = a; + break; + } + } + + if (alias == null) { + throw new IllegalArgumentException("No private key found in uploaded certificate"); + } + + // Create new keystore with our standard alias and password + KeyStore newKeyStore = KeyStore.getInstance("PKCS12"); + newKeyStore.load(null, null); + + PrivateKey privateKey = (PrivateKey) uploadedKeyStore.getKey(alias, password.toCharArray()); + Certificate[] chain = uploadedKeyStore.getCertificateChain(alias); + + newKeyStore.setKeyEntry(KEYSTORE_ALIAS, privateKey, DEFAULT_PASSWORD.toCharArray(), chain); + + // Save to server keystore location + Path keystorePath = getKeystorePath(); + Files.createDirectories(keystorePath.getParent()); + + try (FileOutputStream fos = new FileOutputStream(keystorePath.toFile())) { + newKeyStore.store(fos, DEFAULT_PASSWORD.toCharArray()); + } + + log.info("Server certificate updated from uploaded file"); + } + + public void deleteServerCertificate() throws Exception { + Path keystorePath = getKeystorePath(); + if (Files.exists(keystorePath)) { + Files.delete(keystorePath); + log.info("Server certificate deleted"); + } + } + + public ServerCertificateInfo getServerCertificateInfo() throws Exception { + if (!hasServerCertificate()) { + return new ServerCertificateInfo(false, null, null, null, null); + } + + X509Certificate cert = getServerCertificate(); + return new ServerCertificateInfo( + true, + cert.getSubjectX500Principal().getName(), + cert.getIssuerX500Principal().getName(), + cert.getNotBefore(), + cert.getNotAfter()); + } + + private void generateServerCertificate() throws Exception { + // Generate key pair + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC"); + keyPairGenerator.initialize(2048, new SecureRandom()); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + // Certificate details + X500Name subject = + new X500Name( + "CN=" + organizationName + " Server, O=" + organizationName + ", C=US"); + BigInteger serialNumber = BigInteger.valueOf(System.currentTimeMillis()); + Date notBefore = new Date(); + Date notAfter = new Date(notBefore.getTime() + ((long) validityDays * 24 * 60 * 60 * 1000)); + + // Build certificate + JcaX509v3CertificateBuilder certBuilder = + new JcaX509v3CertificateBuilder( + subject, serialNumber, notBefore, notAfter, subject, keyPair.getPublic()); + + // Add PDF-specific certificate extensions for optimal PDF signing compatibility + JcaX509ExtensionUtils extUtils = new JcaX509ExtensionUtils(); + + // 1) End-entity certificate, not a CA (critical) + certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false)); + + // 2) Key usage for PDF digital signatures (critical) + certBuilder.addExtension( + Extension.keyUsage, + true, + new KeyUsage(KeyUsage.digitalSignature | KeyUsage.nonRepudiation)); + + // 3) Extended key usage for document signing (non-critical, widely accepted) + certBuilder.addExtension( + Extension.extendedKeyUsage, + false, + new ExtendedKeyUsage(KeyPurposeId.id_kp_codeSigning)); + + // 4) Subject Key Identifier for chain building (non-critical) + certBuilder.addExtension( + Extension.subjectKeyIdentifier, + false, + extUtils.createSubjectKeyIdentifier(keyPair.getPublic())); + + // 5) Authority Key Identifier for self-signed cert (non-critical) + certBuilder.addExtension( + Extension.authorityKeyIdentifier, + false, + extUtils.createAuthorityKeyIdentifier(keyPair.getPublic())); + + // Sign certificate + ContentSigner signer = + new JcaContentSignerBuilder("SHA256WithRSA") + .setProvider("BC") + .build(keyPair.getPrivate()); + + X509CertificateHolder certHolder = certBuilder.build(signer); + X509Certificate cert = + new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder); + + // Create keystore + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(null, null); + keyStore.setKeyEntry( + KEYSTORE_ALIAS, + keyPair.getPrivate(), + DEFAULT_PASSWORD.toCharArray(), + new Certificate[] {cert}); + + // Save keystore + Path keystorePath = getKeystorePath(); + Files.createDirectories(keystorePath.getParent()); + + try (FileOutputStream fos = new FileOutputStream(keystorePath.toFile())) { + keyStore.store(fos, DEFAULT_PASSWORD.toCharArray()); + } + } +} diff --git a/build.gradle b/build.gradle index 230df7468..67dca3dbf 100644 --- a/build.gradle +++ b/build.gradle @@ -218,11 +218,18 @@ subprojects { tasks.withType(JavaCompile).configureEach { options.encoding = "UTF-8" - dependsOn "spotlessApply" + if (!project.hasProperty("noSpotless")) { + dependsOn "spotlessApply" + } } - -def allProjects = ((subprojects as Set) + project) as Set - +gradle.taskGraph.whenReady { graph -> + if (project.hasProperty("noSpotless")) { + tasks.matching { it.name.startsWith("spotless") }.configureEach { + enabled = false + } + } +} +def allProjects = ((subprojects as Set) + project) as Set