Compare commits

..
Author SHA1 Message Date
Anthony Stirling 5fb06434ea chages 2025-11-05 14:15:03 +00:00
Anthony Stirling 10c0674b7a onboarding init 2025-11-04 23:38:46 +00:00
Anthony Stirling f1fec4e0d4 merge 2025-11-04 17:12:58 +00:00
Anthony Stirling cdeebc010d Merge branch 'V2' into settingsPageEnhanced 2025-11-04 17:08:01 +00:00
Anthony Stirling dd717fc69b translations 2025-10-31 14:34:10 +00:00
Anthony Stirling 19ff2444a8 fix for path 2025-10-31 13:22:52 +00:00
Anthony Stirling 2148bc2b5c agagin 2025-10-31 13:18:11 +00:00
Anthony Stirling 8ec664d61f final 2025-10-31 13:12:55 +00:00
Anthony Stirling a544092c36 cleanups unused 2025-10-31 13:08:15 +00:00
Anthony Stirling a2c2579f38 lint 2025-10-31 12:57:02 +00:00
Anthony Stirling a256fd9593 px stuff 2025-10-31 12:52:25 +00:00
Anthony Stirling 28c70cbae7 Merge branch 'settingsPageEnhanced' of git@github.com:Stirling-Tools/Stirling-PDF.git into settingsPageEnhanced 2025-10-31 11:34:28 +00:00
Anthony Stirling f82b8a85c0 better logging 2025-10-31 10:22:57 +00:00
Anthony Stirling ba80ca1f67 app URL settings 2025-10-31 09:58:05 +00:00
Anthony Stirling 0bd4d26638 Merge branch 'V2' into settingsPageEnhanced 2025-10-31 09:44:58 +00:00
Anthony Stirling 6be4c98f7c lint typecheck 2025-10-31 09:42:56 +00:00
Anthony Stirling bb2b321b28 lint 2025-10-31 09:38:46 +00:00
Anthony Stirling 6d98b0b209 restart 2025-10-30 23:40:47 +00:00
Anthony Stirling 7205ba9c9a @app stuff 2025-10-30 14:05:03 +00:00
Anthony Stirling cf2c7517eb team updates 2025-10-30 13:30:17 +00:00
Anthony Stirling e88c69be70 update full UI 2025-10-29 17:36:38 +00:00
Anthony Stirling 02dfafb254 Merge branch 'V2' into settingsPageEnhanced
Resolved conflicts by:
- Kept invite link feature (inviteLinkExpiryHours in ApplicationProperties)
- Kept enhanced email invite validation (both SMTP and invites enabled check)
- Adopted V2's Posthog/Scarf tracking additions
- Adopted V2's password length validation (min 6 chars) on both backend and frontend
- Converted email templates to Java text blocks
- Kept success message functionality in Login flow
- Used @app path aliases consistently across frontend files
- Fixed remaining relative imports in Workbench, RestartConfirmationModal, useRestartServer, and InviteAccept
- Accepted deletion of refactored files (App.tsx, Landing.tsx, etc.)
- Kept InviteAccept.tsx for invite link feature
2025-10-28 20:45:29 +00:00
Anthony Stirling 404e353183 translations 2025-10-27 12:19:28 +00:00
Anthony Stirling 7bc9a40487 lint 2025-10-27 12:03:52 +00:00
Anthony Stirling a4fd02bc58 fixes and remove unused 2025-10-27 11:24:52 +00:00
Anthony Stirling b2a52eca79 lint 2025-10-27 11:07:20 +00:00
Anthony Stirling 31fda096ec send email and invite link 2025-10-27 11:02:00 +00:00
Anthony Stirling 0d6966de92 lint changes 2025-10-26 11:19:11 +00:00
Anthony Stirling 976fb958fd cleanups 2025-10-26 11:13:39 +00:00
Anthony Stirling ac88125bf5 team fuctionality 2025-10-26 11:11:51 +00:00
Anthony Stirling 7f406774e3 translations 2025-10-25 11:10:58 +01:00
Anthony Stirling d9e03ccf2c cleanups 2025-10-25 11:03:14 +01:00
Anthony Stirling cbf48b409b fixes 2025-10-25 10:56:46 +01:00
Anthony Stirling 8f84bb1349 isAdmin interface fixes 2025-10-25 10:51:20 +01:00
Anthony Stirling 81374d9b7c cleanups 2025-10-24 21:13:05 +01:00
Anthony Stirling e4db9e183d fixes 2025-10-24 19:27:37 +01:00
Anthony Stirling 928a591839 conflict fixes 2025-10-24 15:47:38 +01:00
Anthony Stirling 634eb564d6 settings updates 2025-10-21 17:56:07 +01:00
Anthony Stirling 82cf8cfde4 remove unused configs, add others 2025-10-19 22:28:55 +01:00
Anthony Stirling d70ec668f1 remove unused settings and enhance others 2025-10-16 16:25:05 +01:00
Anthony Stirling 535c95b1cb restart func 2025-10-16 12:56:24 +01:00
Anthony Stirling b3c1b4791c settingsPage Init selfhost 2025-10-15 23:37:27 +01:00
464 changed files with 27972 additions and 184925 deletions
-74
View File
@@ -1,74 +0,0 @@
# Node modules and build artifacts
node_modules
frontend/node_modules
frontend/dist
frontend/build
frontend/.vite
frontend/.tauri
# Gradle build artifacts
.gradle
build
bin
target
out
# Git
.git
.gitignore
# IDE
.vscode
.idea
*.iml
*.iws
*.ipr
# Logs
*.log
logs
# Environment files
.env
.env.*
!.env.example
# OS files
.DS_Store
Thumbs.db
# Java compiled files
*.class
*.jar
*.war
*.ear
# Test reports
test-results
coverage
# Docker
docker-compose.override.yml
.dockerignore
# Temporary files
tmp
temp
*.tmp
*.swp
*~
# Runtime database and config files (locked by running app)
app/core/configs/**
stirling/**
stirling-pdf-DB*.mv.db
stirling-pdf-DB*.trace.db
# Documentation
*.md
!README.md
docs
# CI/CD
.github
.gitlab-ci.yml
@@ -1 +0,0 @@
allow-ghsas: GHSA-wrw7-89jp-8q8g
+2 -2
View File
@@ -87,7 +87,7 @@ jobs:
fi
fi
else
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs")
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
@@ -498,4 +498,4 @@ jobs:
if: always()
run: |
rm -f ../private.key
continue-on-error: true
continue-on-error: true
-2
View File
@@ -25,5 +25,3 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: "Dependency Review"
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
with:
config-file: './.github/config/dependency-review-config.yml'
-340
View File
@@ -1,340 +0,0 @@
name: Build Tauri Applications
on:
workflow_dispatch:
inputs:
platform:
description: "Platform to build (windows, macos, linux, or all)"
required: true
default: "all"
type: choice
options:
- all
- windows
- macos
- linux
pull_request:
branches: [main, V2]
paths:
- 'frontend/src-tauri/**'
- 'frontend/src/desktop/**'
- 'frontend/tsconfig.desktop.json'
- '.github/workflows/tauri-build.yml'
push:
branches: [main, V2]
permissions:
contents: read
jobs:
determine-matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Determine build matrix
id: set-matrix
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
case "${{ github.event.inputs.platform }}" in
"windows")
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
;;
"macos")
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
;;
"linux")
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
*)
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
esac
else
# For PR/push events, build all platforms
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
fi
build:
needs: determine-matrix
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
runs-on: ${{ matrix.platform }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Set up JDK 21
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
java-version: "21"
distribution: "temurin"
- name: Build Java backend with JLink
working-directory: ./
shell: bash
run: |
chmod +x ./gradlew
echo "🔧 Building Stirling-PDF JAR..."
# STIRLING_PDF_DESKTOP_UI=false ./gradlew clean bootJar --no-daemon
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Find the built JAR
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
echo "✅ Built JAR: $STIRLING_JAR"
# Create Tauri directories
mkdir -p ./frontend/src-tauri/libs
mkdir -p ./frontend/src-tauri/runtime
# Copy JAR to Tauri libs
cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
echo "✅ JAR copied to Tauri libs"
# Analyze JAR dependencies for jlink modules
echo "🔍 Analyzing JAR dependencies..."
if command -v jdeps &> /dev/null; then
DETECTED_MODULES=$(jdeps --print-module-deps --ignore-missing-deps "$STIRLING_JAR" 2>/dev/null || echo "")
if [ -n "$DETECTED_MODULES" ]; then
echo "📋 jdeps detected modules: $DETECTED_MODULES"
MODULES="$DETECTED_MODULES,java.compiler,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
else
echo "⚠️ jdeps analysis failed, using predefined modules"
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
fi
else
echo "⚠️ jdeps not available, using predefined modules"
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
fi
# Create custom JRE with jlink (always rebuild)
echo "🔧 Creating custom JRE with jlink..."
echo "📋 Using modules: $MODULES"
# Remove any existing JRE
rm -rf ./frontend/src-tauri/runtime/jre
# Create the custom JRE
jlink \
--add-modules "$MODULES" \
--strip-debug \
--compress=2 \
--no-header-files \
--no-man-pages \
--output ./frontend/src-tauri/runtime/jre
if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
echo "❌ Failed to create JLink runtime"
exit 1
fi
# Test the bundled runtime
if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then
RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
echo "✅ Custom JRE created successfully: $RUNTIME_VERSION"
else
echo "❌ Custom JRE executable not found"
exit 1
fi
# Calculate runtime size
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
echo "📊 Custom JRE size: $RUNTIME_SIZE"
env:
DISABLE_ADDITIONAL_FEATURES: true
- name: Install frontend dependencies
working-directory: ./frontend
run: npm install
- name: Import Apple Developer Certificate
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
echo "Importing Apple Developer Certificate..."
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
# Create temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Import certificate
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Clean up
rm certificate.p12
- name: Verify Certificate
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application")
echo "Certificate Info: $CERT_INFO"
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
echo "Certificate ID: $CERT_ID"
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported successfully."
- name: Check DMG creation dependencies (macOS only)
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
run: |
echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..."
echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')"
echo "create-dmg availability: $(which create-dmg || echo 'NOT FOUND')"
echo "Available disk space: $(df -h /tmp | tail -1)"
echo "macOS version: $(sw_vers -productVersion)"
echo "Available tools:"
ls -la /usr/bin/hd* || echo "No hd* tools found"
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
SIGN: 1
CI: true
with:
projectPath: ./frontend
tauriScript: npx tauri
args: ${{ matrix.args }}
- name: Verify notarization (macOS only)
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
run: |
echo "🔍 Verifying notarization status..."
cd ./frontend/src-tauri/target
DMG_FILE=$(find . -name "*.dmg" | head -1)
if [ -n "$DMG_FILE" ]; then
echo "Found DMG: $DMG_FILE"
echo "Checking notarization ticket..."
spctl -a -vvv -t install "$DMG_FILE" || echo "⚠️ Notarization check failed or not yet complete"
stapler validate "$DMG_FILE" || echo "⚠️ No notarization ticket attached"
else
echo "⚠️ No DMG file found to verify"
fi
- name: Rename artifacts
shell: bash
run: |
mkdir -p ./dist
cd ./frontend/src-tauri/target
# Find and rename artifacts based on platform
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \;
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
else
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
fi
- name: Upload artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: Stirling-PDF-${{ matrix.name }}
path: ./dist/*
retention-days: 7
- name: Verify build artifacts
shell: bash
run: |
cd ./frontend/src-tauri/target
# Check for expected artifacts based on platform
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
echo "Checking for Windows artifacts..."
find . -name "*.exe" -o -name "*.msi" | head -5
if [ $(find . -name "*.exe" | wc -l) -eq 0 ]; then
echo "❌ No Windows executable found"
exit 1
fi
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
echo "Checking for macOS artifacts..."
find . -name "*.dmg" -o -name "*.app" | head -5
if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then
echo "❌ No macOS artifacts found"
exit 1
fi
else
echo "Checking for Linux artifacts..."
find . -name "*.deb" -o -name "*.AppImage" | head -5
if [ $(find . -name "*.deb" -o -name "*.AppImage" | wc -l) -eq 0 ]; then
echo "❌ No Linux artifacts found"
exit 1
fi
fi
echo "✅ Build artifacts found for ${{ matrix.name }}"
- name: Test artifact sizes
shell: bash
run: |
cd ./frontend/src-tauri/target
echo "Artifact sizes for ${{ matrix.name }}:"
find . -name "*.exe" -o -name "*.dmg" -o -name "*.deb" -o -name "*.AppImage" -o -name "*.msi" | while read file; do
if [ -f "$file" ]; then
size=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null || echo "unknown")
echo "$file: $size bytes"
# Check if file is suspiciously small (less than 1MB)
if [ "$size" != "unknown" ] && [ "$size" -lt 1048576 ]; then
echo "⚠️ Warning: $file is smaller than 1MB"
fi
fi
done
report:
needs: build
runs-on: ubuntu-latest
if: always()
steps:
- name: Report build results
run: |
if [ "${{ needs.build.result }}" = "success" ]; then
echo "✅ All Tauri builds completed successfully!"
echo "Artifacts are ready for distribution."
else
echo "❌ Some Tauri builds failed."
echo "Please check the logs and fix any issues."
exit 1
fi
+6 -24
View File
@@ -32,12 +32,6 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
- Docker for containerization
- Gradle for build management
**Desktop Application (Tauri):**
- Tauri for cross-platform desktop app packaging
- Rust backend for system integration
- PDF file association support
- Self-contained JRE bundling with JLink
**Legacy (reference only during development):**
- Thymeleaf templates (being completely replaced in 2.0)
@@ -50,8 +44,6 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
- Java JDK 17 or later (JDK 21 recommended)
- Node.js 18+ and npm (required for frontend development)
- Gradle 7.0 or later (Included within the repo)
- Rust and Cargo (required for Tauri desktop app development)
- Tauri CLI (install with `cargo install tauri-cli`)
### Setup Steps
@@ -103,10 +95,6 @@ Stirling 2.0 uses client-side file storage:
### Legacy Code Reference
The existing Thymeleaf templates remain in the codebase during development as reference material but will be completely removed for the 2.0 release.
### Tauri Desktop App Development
Stirling-PDF can be packaged as a cross-platform desktop application using Tauri with PDF file association support and bundled JRE.
See [the frontend README](frontend/README.md#tauri) for build instructions.
## 5. Project Structure
```bash
@@ -121,12 +109,6 @@ Stirling-PDF/
│ │ ├── services/ # API and utility services
│ │ ├── types/ # TypeScript type definitions
│ │ └── utils/ # Utility functions
│ ├── src-tauri/ # Tauri desktop app configuration
│ │ ├── src/ # Rust backend code
│ │ ├── libs/ # JAR files (generated by build scripts)
│ │ ├── runtime/ # Bundled JRE (generated by build scripts)
│ │ ├── Cargo.toml # Rust dependencies
│ │ └── tauri.conf.json # Tauri configuration
│ ├── public/
│ │ └── locales/ # Internationalization files (JSON)
│ ├── package.json # Frontend dependencies
@@ -410,23 +392,23 @@ For Stirling 2.0, new features are built as React components instead of Thymelea
// frontend/src/tools/NewTool.tsx
import { useState } from 'react';
import { Button, FileInput, Container } from '@mantine/core';
interface NewToolProps {
params: Record<string, any>;
updateParams: (updates: Record<string, any>) => void;
}
export default function NewTool({ params, updateParams }: NewToolProps) {
const [files, setFiles] = useState<File[]>([]);
const handleProcess = async () => {
// Process files using API or client-side logic
};
return (
<Container>
<FileInput
multiple
<FileInput
multiple
accept="application/pdf"
onChange={setFiles}
/>
+37 -37
View File
@@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![64%](https://geps.dev/progress/64) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![24%](https://geps.dev/progress/24) |
| Basque (Euskara) (eu_ES) | ![14%](https://geps.dev/progress/14) |
| Bulgarian (Български) (bg_BG) | ![26%](https://geps.dev/progress/26) |
| Catalan (Català) (ca_CA) | ![26%](https://geps.dev/progress/26) |
| Croatian (Hrvatski) (hr_HR) | ![24%](https://geps.dev/progress/24) |
| Czech (Česky) (cs_CZ) | ![26%](https://geps.dev/progress/26) |
| Danish (Dansk) (da_DK) | ![23%](https://geps.dev/progress/23) |
| Dutch (Nederlands) (nl_NL) | ![23%](https://geps.dev/progress/23) |
| Arabic (العربية) (ar_AR) | ![83%](https://geps.dev/progress/83) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![32%](https://geps.dev/progress/32) |
| Basque (Euskara) (eu_ES) | ![18%](https://geps.dev/progress/18) |
| Bulgarian (Български) (bg_BG) | ![35%](https://geps.dev/progress/35) |
| Catalan (Català) (ca_CA) | ![34%](https://geps.dev/progress/34) |
| Croatian (Hrvatski) (hr_HR) | ![31%](https://geps.dev/progress/31) |
| Czech (Česky) (cs_CZ) | ![34%](https://geps.dev/progress/34) |
| Danish (Dansk) (da_DK) | ![30%](https://geps.dev/progress/30) |
| Dutch (Nederlands) (nl_NL) | ![30%](https://geps.dev/progress/30) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![63%](https://geps.dev/progress/63) |
| German (Deutsch) (de_DE) | ![64%](https://geps.dev/progress/64) |
| Greek (Ελληνικά) (el_GR) | ![26%](https://geps.dev/progress/26) |
| Hindi (हिंदी) (hi_IN) | ![26%](https://geps.dev/progress/26) |
| Hungarian (Magyar) (hu_HU) | ![29%](https://geps.dev/progress/29) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![24%](https://geps.dev/progress/24) |
| Irish (Gaeilge) (ga_IE) | ![26%](https://geps.dev/progress/26) |
| Italian (Italiano) (it_IT) | ![64%](https://geps.dev/progress/64) |
| Japanese (日本語) (ja_JP) | ![47%](https://geps.dev/progress/47) |
| Korean (한국어) (ko_KR) | ![26%](https://geps.dev/progress/26) |
| Norwegian (Norsk) (no_NB) | ![24%](https://geps.dev/progress/24) |
| Persian (فارسی) (fa_IR) | ![26%](https://geps.dev/progress/26) |
| Polish (Polski) (pl_PL) | ![27%](https://geps.dev/progress/27) |
| Portuguese (Português) (pt_PT) | ![26%](https://geps.dev/progress/26) |
| Portuguese Brazilian (Português) (pt_BR) | ![64%](https://geps.dev/progress/64) |
| Romanian (Română) (ro_RO) | ![22%](https://geps.dev/progress/22) |
| Russian (Русский) (ru_RU) | ![63%](https://geps.dev/progress/63) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![28%](https://geps.dev/progress/28) |
| Simplified Chinese (简体中文) (zh_CN) | ![65%](https://geps.dev/progress/65) |
| Slovakian (Slovensky) (sk_SK) | ![19%](https://geps.dev/progress/19) |
| Slovenian (Slovenščina) (sl_SI) | ![27%](https://geps.dev/progress/27) |
| Spanish (Español) (es_ES) | ![64%](https://geps.dev/progress/64) |
| Swedish (Svenska) (sv_SE) | ![25%](https://geps.dev/progress/25) |
| Thai (ไทย) (th_TH) | ![23%](https://geps.dev/progress/23) |
| French (Français) (fr_FR) | ![82%](https://geps.dev/progress/82) |
| German (Deutsch) (de_DE) | ![84%](https://geps.dev/progress/84) |
| Greek (Ελληνικά) (el_GR) | ![34%](https://geps.dev/progress/34) |
| Hindi (हिंदी) (hi_IN) | ![34%](https://geps.dev/progress/34) |
| Hungarian (Magyar) (hu_HU) | ![38%](https://geps.dev/progress/38) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![31%](https://geps.dev/progress/31) |
| Irish (Gaeilge) (ga_IE) | ![34%](https://geps.dev/progress/34) |
| Italian (Italiano) (it_IT) | ![84%](https://geps.dev/progress/84) |
| Japanese (日本語) (ja_JP) | ![62%](https://geps.dev/progress/62) |
| Korean (한국어) (ko_KR) | ![34%](https://geps.dev/progress/34) |
| Norwegian (Norsk) (no_NB) | ![32%](https://geps.dev/progress/32) |
| Persian (فارسی) (fa_IR) | ![34%](https://geps.dev/progress/34) |
| Polish (Polski) (pl_PL) | ![36%](https://geps.dev/progress/36) |
| Portuguese (Português) (pt_PT) | ![34%](https://geps.dev/progress/34) |
| Portuguese Brazilian (Português) (pt_BR) | ![83%](https://geps.dev/progress/83) |
| Romanian (Română) (ro_RO) | ![28%](https://geps.dev/progress/28) |
| Russian (Русский) (ru_RU) | ![83%](https://geps.dev/progress/83) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![37%](https://geps.dev/progress/37) |
| Simplified Chinese (简体中文) (zh_CN) | ![85%](https://geps.dev/progress/85) |
| Slovakian (Slovensky) (sk_SK) | ![26%](https://geps.dev/progress/26) |
| Slovenian (Slovenščina) (sl_SI) | ![36%](https://geps.dev/progress/36) |
| Spanish (Español) (es_ES) | ![84%](https://geps.dev/progress/84) |
| Swedish (Svenska) (sv_SE) | ![33%](https://geps.dev/progress/33) |
| Thai (ไทย) (th_TH) | ![31%](https://geps.dev/progress/31) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) |
| Traditional Chinese (繁體中文) (zh_TW) | ![29%](https://geps.dev/progress/29) |
| Turkish (Türkçe) (tr_TR) | ![28%](https://geps.dev/progress/28) |
| Ukrainian (Українська) (uk_UA) | ![28%](https://geps.dev/progress/28) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![21%](https://geps.dev/progress/21) |
| Traditional Chinese (繁體中文) (zh_TW) | ![38%](https://geps.dev/progress/38) |
| Turkish (Türkçe) (tr_TR) | ![37%](https://geps.dev/progress/37) |
| Ukrainian (Українська) (uk_UA) | ![36%](https://geps.dev/progress/36) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![28%](https://geps.dev/progress/28) |
| Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) |
## Stirling PDF Enterprise
@@ -1,37 +0,0 @@
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 Invite management controllers.
* Includes @RestController, @RequestMapping("/api/v1/invite"), and OpenAPI @Tag.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/invite")
@Tag(
name = "Invite",
description =
"""
Invite-link generation and acceptance endpoints for onboarding new users.
Provides the ability to issue invitation tokens, send optional email invites,
validate and accept invite links, and manage pending invitations for teams.
Typical use cases include:
• Admin workflows for issuing time-limited invitations to external users
• Self-service invite acceptance and team assignment
• License limit enforcement when provisioning new accounts
Target users: administrators and automation scripts orchestrating user onboarding.
""")
public @interface InviteApi {}
@@ -505,19 +505,10 @@ public class ApplicationProperties {
public static class Ui {
private String appNameNavbar;
private List<String> languages;
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
public String getAppNameNavbar() {
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
}
public String getLogoStyle() {
// Validate and return either "modern" or "classic"
if ("modern".equalsIgnoreCase(logoStyle)) {
return "modern";
}
return "classic"; // default
}
}
@Data
@@ -8,6 +8,4 @@ public interface UserServiceInterface {
long getTotalUsersCount();
boolean isCurrentUserAdmin();
boolean isCurrentUserFirstLogin();
}
@@ -0,0 +1,658 @@
package stirling.software.common.util;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.*;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public final class FormUtils {
private FormUtils() {}
public static boolean hasAnyRotatedPage(PDDocument document) {
try {
for (PDPage page : document.getPages()) {
int rot = page.getRotation();
int norm = ((rot % 360) + 360) % 360;
if (norm != 0) {
return true;
}
}
} catch (Exception e) {
log.warn("Failed to inspect page rotations: {}", e.getMessage(), e);
}
return false;
}
public static void copyAndTransformFormFields(
PDDocument sourceDocument,
PDDocument newDocument,
int totalPages,
int pagesPerSheet,
int cols,
int rows,
float cellWidth,
float cellHeight)
throws IOException {
PDDocumentCatalog sourceCatalog = sourceDocument.getDocumentCatalog();
PDAcroForm sourceAcroForm = sourceCatalog.getAcroForm();
if (sourceAcroForm == null || sourceAcroForm.getFields().isEmpty()) {
return;
}
PDDocumentCatalog newCatalog = newDocument.getDocumentCatalog();
PDAcroForm newAcroForm = new PDAcroForm(newDocument);
newCatalog.setAcroForm(newAcroForm);
PDResources dr = new PDResources();
PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
PDType1Font zapfDingbats = new PDType1Font(Standard14Fonts.FontName.ZAPF_DINGBATS);
dr.put(COSName.getPDFName("Helv"), helvetica);
dr.put(COSName.getPDFName("ZaDb"), zapfDingbats);
newAcroForm.setDefaultResources(dr);
newAcroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
// Do not mutate the source AcroForm; skip bad widgets during copy
newAcroForm.setNeedAppearances(true);
Map<String, Integer> fieldNameCounters = new HashMap<>();
// Build widget -> field map once for efficient lookups
Map<PDAnnotationWidget, PDField> widgetFieldMap = buildWidgetFieldMap(sourceAcroForm);
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
PDPage sourcePage = sourceDocument.getPage(pageIndex);
List<PDAnnotation> annotations = sourcePage.getAnnotations();
if (annotations.isEmpty()) {
continue;
}
int destinationPageIndex = pageIndex / pagesPerSheet;
int adjustedPageIndex = pageIndex % pagesPerSheet;
int rowIndex = adjustedPageIndex / cols;
int colIndex = adjustedPageIndex % cols;
if (destinationPageIndex >= newDocument.getNumberOfPages()) {
continue;
}
PDPage destinationPage = newDocument.getPage(destinationPageIndex);
PDRectangle sourceRect = sourcePage.getMediaBox();
float scaleWidth = cellWidth / sourceRect.getWidth();
float scaleHeight = cellHeight / sourceRect.getHeight();
float scale = Math.min(scaleWidth, scaleHeight);
float x = colIndex * cellWidth + (cellWidth - sourceRect.getWidth() * scale) / 2;
float y =
destinationPage.getMediaBox().getHeight()
- ((rowIndex + 1) * cellHeight
- (cellHeight - sourceRect.getHeight() * scale) / 2);
copyBasicFormFields(
sourceAcroForm,
newAcroForm,
sourcePage,
destinationPage,
x,
y,
scale,
pageIndex,
fieldNameCounters,
widgetFieldMap);
}
// Refresh appearances to ensure widgets render correctly across viewers
try {
// Use reflection to avoid compile-time dependency on PDFBox version
Method m = newAcroForm.getClass().getMethod("refreshAppearances");
m.invoke(newAcroForm);
} catch (NoSuchMethodException nsme) {
log.warn(
"AcroForm.refreshAppearances() not available in this PDFBox version; relying on NeedAppearances.");
} catch (Throwable t) {
log.warn("Failed to refresh field appearances via AcroForm: {}", t.getMessage(), t);
}
}
private static void copyBasicFormFields(
PDAcroForm sourceAcroForm,
PDAcroForm newAcroForm,
PDPage sourcePage,
PDPage destinationPage,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters,
Map<PDAnnotationWidget, PDField> widgetFieldMap) {
try {
List<PDAnnotation> sourceAnnotations = sourcePage.getAnnotations();
List<PDAnnotation> destinationAnnotations = destinationPage.getAnnotations();
for (PDAnnotation annotation : sourceAnnotations) {
if (annotation instanceof PDAnnotationWidget widgetAnnotation) {
if (widgetAnnotation.getRectangle() == null) {
continue;
}
PDField sourceField =
widgetFieldMap != null ? widgetFieldMap.get(widgetAnnotation) : null;
if (sourceField == null) {
continue; // skip widgets without a matching field
}
if (sourceField instanceof PDTextField pdtextfield) {
createSimpleTextField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdtextfield,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDCheckBox pdCheckBox) {
createSimpleCheckBoxField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdCheckBox,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDRadioButton pdRadioButton) {
createSimpleRadioButtonField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdRadioButton,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDComboBox pdComboBox) {
createSimpleComboBoxField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdComboBox,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDListBox pdlistbox) {
createSimpleListBoxField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdlistbox,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDSignatureField pdSignatureField) {
createSimpleSignatureField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdSignatureField,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDPushButton pdPushButton) {
createSimplePushButtonField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdPushButton,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
}
}
}
} catch (Exception e) {
log.warn(
"Failed to copy basic form fields for page {}: {}",
pageIndex,
e.getMessage(),
e);
}
}
private static void createSimpleTextField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDTextField sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDTextField newTextField = new PDTextField(newAcroForm);
newTextField.setDefaultAppearance("/Helv 12 Tf 0 g");
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newTextField,
sourceField.getPartialName(),
"textField",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.getValueAsString() != null) {
newTextField.setValue(sourceField.getValueAsString());
}
} catch (Exception e) {
log.warn(
"Failed to create text field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleCheckBoxField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDCheckBox sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDCheckBox newCheckBox = new PDCheckBox(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newCheckBox,
sourceField.getPartialName(),
"checkBox",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.isChecked()) {
newCheckBox.check();
} else {
newCheckBox.unCheck();
}
} catch (Exception e) {
log.warn(
"Failed to create checkbox field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleRadioButtonField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDRadioButton sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDRadioButton newRadioButton = new PDRadioButton(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newRadioButton,
sourceField.getPartialName(),
"radioButton",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.getExportValues() != null) {
newRadioButton.setExportValues(sourceField.getExportValues());
}
if (sourceField.getValue() != null) {
newRadioButton.setValue(sourceField.getValue());
}
} catch (Exception e) {
log.warn(
"Failed to create radio button field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleComboBoxField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDComboBox sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDComboBox newComboBox = new PDComboBox(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newComboBox,
sourceField.getPartialName(),
"comboBox",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.getOptions() != null) {
newComboBox.setOptions(sourceField.getOptions());
}
if (sourceField.getValue() != null && !sourceField.getValue().isEmpty()) {
newComboBox.setValue(sourceField.getValue());
}
} catch (Exception e) {
log.warn(
"Failed to create combo box field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleListBoxField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDListBox sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDListBox newListBox = new PDListBox(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newListBox,
sourceField.getPartialName(),
"listBox",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.getOptions() != null) {
newListBox.setOptions(sourceField.getOptions());
}
if (sourceField.getValue() != null && !sourceField.getValue().isEmpty()) {
newListBox.setValue(sourceField.getValue());
}
} catch (Exception e) {
log.warn(
"Failed to create list box field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleSignatureField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDSignatureField sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDSignatureField newSignatureField = new PDSignatureField(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newSignatureField,
sourceField.getPartialName(),
"signature",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
} catch (Exception e) {
log.warn(
"Failed to create signature field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimplePushButtonField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDPushButton sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDPushButton newPushButton = new PDPushButton(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newPushButton,
sourceField.getPartialName(),
"pushButton",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} catch (Exception e) {
log.warn(
"Failed to create push button field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static <T extends PDTerminalField> boolean initializeFieldWithWidget(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
T newField,
String originalName,
String fallbackName,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
String baseName = (originalName != null) ? originalName : fallbackName;
String newFieldName = generateUniqueFieldName(baseName, pageIndex, fieldNameCounters);
newField.setPartialName(newFieldName);
PDAnnotationWidget newWidget = new PDAnnotationWidget();
PDRectangle sourceRect = sourceWidget.getRectangle();
if (sourceRect == null) {
return false;
}
float newX = (sourceRect.getLowerLeftX() * scale) + offsetX;
float newY = (sourceRect.getLowerLeftY() * scale) + offsetY;
float newWidth = sourceRect.getWidth() * scale;
float newHeight = sourceRect.getHeight() * scale;
newWidget.setRectangle(new PDRectangle(newX, newY, newWidth, newHeight));
newWidget.setPage(destinationPage);
newField.getWidgets().add(newWidget);
newWidget.setParent(newField);
newAcroForm.getFields().add(newField);
destinationAnnotations.add(newWidget);
return true;
}
private static String generateUniqueFieldName(
String originalName, int pageIndex, Map<String, Integer> fieldNameCounters) {
String baseName = "page" + pageIndex + "_" + originalName;
Integer counter = fieldNameCounters.get(baseName);
if (counter == null) {
counter = 0;
} else {
counter++;
}
fieldNameCounters.put(baseName, counter);
return counter == 0 ? baseName : baseName + "_" + counter;
}
private static Map<PDAnnotationWidget, PDField> buildWidgetFieldMap(PDAcroForm acroForm) {
Map<PDAnnotationWidget, PDField> map = new HashMap<>();
if (acroForm == null) {
return map;
}
try {
for (PDField field : acroForm.getFieldTree()) {
List<PDAnnotationWidget> widgets = field.getWidgets();
if (widgets != null) {
for (PDAnnotationWidget w : widgets) {
if (w != null) {
map.put(w, field);
}
}
}
}
} catch (Exception e) {
log.warn("Failed to build widget->field map: {}", e.getMessage(), e);
}
return map;
}
}
@@ -1,6 +1,5 @@
package stirling.software.common.util;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
@@ -242,11 +241,6 @@ public final class RegexPatternUtils {
return getPattern("\\s+");
}
/** Pattern for matching punctuation characters */
public Pattern getPunctuationPattern() {
return getPattern("[\\p{Punct}]+");
}
/** Pattern for matching newlines (Windows and Unix style) */
public Pattern getNewlinesPattern() {
return getPattern("\\r?\\n");
@@ -292,24 +286,6 @@ public final class RegexPatternUtils {
return getPattern("[^a-zA-Z0-9 ]");
}
/** Pattern for removing bracketed indices like [0], [Child], etc. in field names */
public Pattern getFormFieldBracketPattern() {
return getPattern("\\[[^\\]]*\\]");
}
/** Pattern that replaces underscores or hyphens with spaces */
public Pattern getUnderscoreHyphenPattern() {
return getPattern("[-_]+");
}
/**
* Pattern that matches camelCase or alpha-numeric boundaries to allow inserting spaces.
* Examples: firstName -> first Name, field1 -> field 1, A5Size -> A5 Size
*/
public Pattern getCamelCaseBoundaryPattern() {
return getPattern("(?<=[a-z])(?=[A-Z])|(?<=[A-Za-z])(?=\\d)|(?<=\\d)(?=[A-Za-z])");
}
/** Pattern for removing angle brackets */
public Pattern getAngleBracketsPattern() {
return getPattern("[<>]");
@@ -359,26 +335,6 @@ public final class RegexPatternUtils {
return getPattern("[1-9][0-9]{0,2}");
}
/**
* Pattern for very simple generic field tokens such as "field", "text", "checkbox" with
* optional numeric suffix (e.g. "field 1"). Case-insensitive.
*/
public Pattern getGenericFieldNamePattern() {
return getPattern(
"^(field|text|checkbox|radio|button|signature|name|value|option|select|choice)(\\s*\\d+)?$",
Pattern.CASE_INSENSITIVE);
}
/** Pattern for short identifiers like t1, f2, a10 etc. */
public Pattern getSimpleFormFieldPattern() {
return getPattern("^[A-Za-z]{1,2}\\s*\\d{1,3}$");
}
/** Pattern for optional leading 't' followed by digits, e.g., t1, 1, t 12. */
public Pattern getOptionalTNumericPattern() {
return getPattern("^(?:t\\s*)?\\d+$", Pattern.CASE_INSENSITIVE);
}
/** Pattern for validating mathematical expressions */
public Pattern getMathExpressionPattern() {
return getPattern("[0-9n+\\-*/() ]+");
@@ -511,11 +467,6 @@ public final class RegexPatternUtils {
return getPattern("/");
}
/** Supported logical types when creating new fields programmatically */
public Set<String> getSupportedNewFieldTypes() {
return Set.of("text", "checkbox", "combobox", "listbox", "radio", "button", "signature");
}
/**
* Pre-compile commonly used patterns for immediate availability. This eliminates first-call
* compilation overhead for frequent patterns.
@@ -49,38 +49,4 @@ public class RequestUriUtils {
|| requestURI.startsWith("/fonts")
|| requestURI.startsWith("/pdfjs"));
}
/**
* Checks if the request URI is a public authentication endpoint that doesn't require
* authentication. This includes login, signup, OAuth callbacks, and public config endpoints.
*
* @param requestURI The full request URI
* @param contextPath The servlet context path
* @return true if the endpoint is public and doesn't require authentication
*/
public static boolean isPublicAuthEndpoint(String requestURI, String contextPath) {
// Remove context path from URI to normalize path matching
String trimmedUri =
requestURI.startsWith(contextPath)
? requestURI.substring(contextPath.length())
: requestURI;
// Public auth endpoints that don't require authentication
return trimmedUri.startsWith("/login")
|| trimmedUri.startsWith("/auth/")
|| trimmedUri.startsWith("/oauth2")
|| trimmedUri.startsWith("/saml2")
|| trimmedUri.contains("/login/oauth2/code/") // Spring Security OAuth2 callback
|| trimmedUri.contains("/oauth2/authorization/") // OAuth2 authorization endpoint
|| trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/logout")
|| trimmedUri.startsWith(
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
// enableLogin)
|| trimmedUri.startsWith("/v1/api-docs")
|| trimmedUri.startsWith("/api/v1/invite/validate")
|| trimmedUri.startsWith("/api/v1/invite/accept")
|| trimmedUri.contains("/v1/api-docs");
}
}
+1
View File
@@ -41,6 +41,7 @@ dependencies {
if (System.getenv('STIRLING_PDF_DESKTOP_UI') != 'false'
|| (project.hasProperty('STIRLING_PDF_DESKTOP_UI')
&& project.getProperty('STIRLING_PDF_DESKTOP_UI') != 'false')) {
implementation 'me.friwi:jcefmaven:132.3.1'
implementation 'org.openjfx:javafx-controls:21'
implementation 'org.openjfx:javafx-swing:21'
}
@@ -5,7 +5,6 @@ import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.common.model.api.misc.HighContrastColorCombination;
import stirling.software.common.model.api.misc.ReplaceAndInvert;
import stirling.software.common.util.TempFileManager;
@@ -19,7 +18,6 @@ import stirling.software.common.util.misc.ReplaceAndInvertColorStrategy;
public class ReplaceAndInvertColorFactory {
private final TempFileManager tempFileManager;
private final EndpointConfiguration endpointConfiguration;
public ReplaceAndInvertColorStrategy replaceAndInvert(
MultipartFile file,
@@ -28,13 +26,6 @@ public class ReplaceAndInvertColorFactory {
String backGroundColor,
String textColor) {
// Check Ghostscript availability for CMYK conversion
if (replaceAndInvertOption == ReplaceAndInvert.COLOR_SPACE_CONVERSION
&& !endpointConfiguration.isGroupEnabled("Ghostscript")) {
throw new IllegalStateException(
"CMYK color space conversion requires Ghostscript, which is not available on this system");
}
return switch (replaceAndInvertOption) {
case CUSTOM_COLOR, HIGH_CONTRAST_COLOR ->
new CustomColorReplaceStrategy(
@@ -144,13 +144,6 @@ public class SPDFApplication {
serverPortStatic = serverPort;
String url = baseUrl + ":" + getStaticPort() + contextPath;
// Log Tauri mode information
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
String parentPid = System.getenv("TAURI_PARENT_PID");
log.info(
"Running in Tauri mode. Parent process PID: {}",
parentPid != null ? parentPid : "not set");
}
// Desktop UI initialization removed - webBrowser dependency eliminated
// Keep backwards compatibility for STIRLING_PDF_DESKTOP_UI system property
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_DESKTOP_UI", "false"))) {
@@ -294,12 +294,6 @@ public class EndpointConfiguration {
addEndpointToGroup("Other", "replace-and-invert-color-pdf");
addEndpointToGroup("Other", "multi-tool");
// Adding form-related endpoints to "Other" group
addEndpointToGroup("Other", "fields");
addEndpointToGroup("Other", "modify-fields");
addEndpointToGroup("Other", "delete-fields");
addEndpointToGroup("Other", "fill");
// Adding endpoints to "Advance" group
addEndpointToGroup("Advance", "adjust-contrast");
addEndpointToGroup("Advance", "compress-pdf");
@@ -409,6 +403,8 @@ public class EndpointConfiguration {
/* Ghostscript */
addEndpointToGroup("Ghostscript", "repair");
addEndpointToGroup("Ghostscript", "compress-pdf");
addEndpointToGroup("Ghostscript", "crop");
addEndpointToGroup("Ghostscript", "replace-invert-pdf");
/* tesseract */
addEndpointToGroup("tesseract", "ocr-pdf");
@@ -28,8 +28,6 @@ public class InitialSetup {
private final ApplicationProperties applicationProperties;
private static boolean isNewServer = false;
@PostConstruct
public void init() throws IOException {
initUUIDKey();
@@ -90,13 +88,6 @@ public class InitialSetup {
}
public void initSetAppVersion() throws IOException {
// Check if this is a new server before setting the version
String existingVersion = applicationProperties.getAutomaticallyGenerated().getAppVersion();
isNewServer =
existingVersion == null
|| existingVersion.isEmpty()
|| existingVersion.equals("0.0.0");
String appVersion = "0.0.0";
Resource resource = new ClassPathResource("version.properties");
Properties props = new Properties();
@@ -108,8 +99,4 @@ public class InitialSetup {
GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion);
applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion);
}
public static boolean isNewServer() {
return isNewServer;
}
}
@@ -1,157 +0,0 @@
package stirling.software.SPDF.config;
import java.lang.management.ManagementFactory;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
/**
* Monitor for Tauri parent process to detect orphaned Java backend processes. When running in Tauri
* mode, this component periodically checks if the parent Tauri process is still alive. If the
* parent process terminates unexpectedly, this will trigger a graceful shutdown of the Java backend
* to prevent orphaned processes.
*/
@Component
@ConditionalOnProperty(name = "STIRLING_PDF_TAURI_MODE", havingValue = "true")
public class TauriProcessMonitor {
private static final Logger logger = LoggerFactory.getLogger(TauriProcessMonitor.class);
private final ApplicationContext applicationContext;
private String parentProcessId;
private ScheduledExecutorService scheduler;
private volatile boolean monitoring = false;
public TauriProcessMonitor(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
@PostConstruct
public void init() {
parentProcessId = System.getenv("TAURI_PARENT_PID");
if (parentProcessId != null && !parentProcessId.trim().isEmpty()) {
logger.info("Tauri mode detected. Parent process ID: {}", parentProcessId);
startMonitoring();
} else {
logger.warn(
"TAURI_PARENT_PID environment variable not found. Tauri process monitoring disabled.");
}
}
private void startMonitoring() {
scheduler =
Executors.newSingleThreadScheduledExecutor(
r -> {
Thread t = new Thread(r, "tauri-process-monitor");
t.setDaemon(true);
return t;
});
monitoring = true;
// Check every 5 seconds
scheduler.scheduleAtFixedRate(this::checkParentProcess, 5, 5, TimeUnit.SECONDS);
logger.info("Started monitoring parent Tauri process (PID: {})", parentProcessId);
}
private void checkParentProcess() {
if (!monitoring) {
return;
}
try {
if (!isProcessAlive(parentProcessId)) {
logger.warn(
"Parent Tauri process (PID: {}) is no longer alive. Initiating graceful shutdown...",
parentProcessId);
initiateGracefulShutdown();
}
} catch (Exception e) {
logger.error("Error checking parent process status", e);
}
}
private boolean isProcessAlive(String pid) {
try {
long processId = Long.parseLong(pid);
// Check if process exists using ProcessHandle (Java 9+)
return ProcessHandle.of(processId).isPresent();
} catch (NumberFormatException e) {
logger.error("Invalid parent process ID format: {}", pid);
return false;
} catch (Exception e) {
logger.error("Error checking if process {} is alive", pid, e);
return false;
}
}
private void initiateGracefulShutdown() {
monitoring = false;
logger.info("Orphaned Java backend detected. Shutting down gracefully...");
// Shutdown asynchronously to avoid blocking the monitor thread
CompletableFuture.runAsync(
() -> {
try {
// Give a small delay to ensure logging completes
Thread.sleep(1000);
if (applicationContext instanceof ConfigurableApplicationContext) {
((ConfigurableApplicationContext) applicationContext).close();
} else {
// Fallback to system exit
logger.warn(
"Unable to shutdown Spring context gracefully, using System.exit");
System.exit(0);
}
} catch (Exception e) {
logger.error("Error during graceful shutdown", e);
System.exit(1);
}
});
}
@PreDestroy
public void cleanup() {
monitoring = false;
if (scheduler != null && !scheduler.isShutdown()) {
logger.info("Shutting down Tauri process monitor");
scheduler.shutdown();
try {
if (!scheduler.awaitTermination(2, TimeUnit.SECONDS)) {
scheduler.shutdownNow();
}
} catch (InterruptedException e) {
scheduler.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
/** Get the current Java process ID for logging/debugging purposes */
public static String getCurrentProcessId() {
try {
return ManagementFactory.getRuntimeMXBean().getName().split("@")[0];
} catch (Exception e) {
return "unknown";
}
}
}
@@ -1,49 +0,0 @@
package stirling.software.SPDF.config;
import java.io.IOException;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.service.WeeklyActiveUsersService;
/**
* Filter to track browser IDs for Weekly Active Users (WAU) counting.
* Only active when security is disabled (no-login mode).
*/
@Component
@ConditionalOnProperty(name = "security.enableLogin", havingValue = "false")
@RequiredArgsConstructor
@Slf4j
public class WAUTrackingFilter implements Filter {
private final WeeklyActiveUsersService wauService;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (request instanceof HttpServletRequest httpRequest) {
// Extract browser ID from header
String browserId = httpRequest.getHeader("X-Browser-Id");
if (browserId != null && !browserId.trim().isEmpty()) {
// Record browser access
wauService.recordBrowserAccess(browserId);
}
}
// Continue the filter chain
chain.doFilter(request, response);
}
}
@@ -1,7 +1,5 @@
package stirling.software.SPDF.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
@@ -18,8 +16,6 @@ public class WebMvcConfig implements WebMvcConfigurer {
private final EndpointInterceptor endpointInterceptor;
private final ApplicationProperties applicationProperties;
private static final Logger logger = LoggerFactory.getLogger(WebMvcConfig.class);
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(endpointInterceptor);
@@ -27,50 +23,10 @@ public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
// Check if running in Tauri mode
boolean isTauriMode =
Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"));
// Check if user has configured custom origins
boolean hasConfiguredOrigins =
applicationProperties.getSystem() != null
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty();
if (isTauriMode) {
// Automatically enable CORS for Tauri desktop app
// Tauri v1 uses tauri://localhost, v2 uses http(s)://tauri.localhost
logger.info("Tauri mode detected - enabling CORS for Tauri protocols (v1 and v2)");
registry.addMapping("/**")
.allowedOrigins(
"tauri://localhost",
"http://tauri.localhost",
"https://tauri.localhost")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN",
"X-Browser-Id")
.exposedHeaders(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
.allowCredentials(true)
.maxAge(3600);
} else if (hasConfiguredOrigins) {
// Use user-configured origins
logger.info(
"Configuring CORS with allowed origins: {}",
applicationProperties.getSystem().getCorsAllowedOrigins());
// Only configure CORS if allowed origins are specified
if (applicationProperties.getSystem() != null
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
String[] allowedOrigins =
applicationProperties
@@ -79,53 +35,21 @@ public class WebMvcConfig implements WebMvcConfigurer {
.toArray(new String[0]);
registry.addMapping("/**")
.allowedOriginPatterns(allowedOrigins)
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN",
"X-Browser-Id")
.exposedHeaders(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
.allowCredentials(true)
.maxAge(3600);
} else {
// Default to allowing all origins when nothing is configured
logger.info(
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN",
"X-Browser-Id")
.exposedHeaders(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
.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
// }
}
@@ -22,7 +22,6 @@ import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.general.CropPdfForm;
import stirling.software.common.annotations.AutoJobPostMapping;
@@ -38,11 +37,6 @@ import stirling.software.common.util.WebResponseUtils;
public class CropController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final EndpointConfiguration endpointConfiguration;
private boolean isGhostscriptEnabled() {
return endpointConfiguration.isGroupEnabled("Ghostscript");
}
@AutoJobPostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@StandardPdfResponse
@@ -52,13 +46,9 @@ public class CropController {
"This operation takes an input PDF file and crops it according to the given"
+ " coordinates. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> cropPdf(@ModelAttribute CropPdfForm request) throws IOException {
if (request.isRemoveDataOutsideCrop() && isGhostscriptEnabled()) {
if (request.isRemoveDataOutsideCrop()) {
return cropWithGhostscript(request);
} else {
if (request.isRemoveDataOutsideCrop()) {
log.warn(
"Ghostscript not available - 'removeDataOutsideCrop' option requires Ghostscript. Falling back to visual crop only.");
}
return cropWithPDFBox(request);
}
}
@@ -29,6 +29,7 @@ import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.JsonDataResponse;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.EditTableOfContentsRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
@@ -48,12 +49,13 @@ public class EditTableOfContentsController {
@AutoJobPostMapping(
value = "/extract-bookmarks",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@JsonDataResponse
@Operation(
summary = "Extract PDF Bookmarks",
description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
@ResponseBody
public ResponseEntity<List<Map<String, Object>>> extractBookmarks(
@RequestParam("file") MultipartFile file) throws Exception {
public List<Map<String, Object>> extractBookmarks(@RequestParam("file") MultipartFile file)
throws Exception {
PDDocument document = null;
try {
document = pdfDocumentFactory.load(file);
@@ -61,10 +63,10 @@ public class EditTableOfContentsController {
if (outline == null) {
log.info("No outline/bookmarks found in PDF");
return ResponseEntity.ok(new ArrayList<>());
return new ArrayList<>();
}
return ResponseEntity.ok(extractBookmarkItems(document, outline));
return extractBookmarkItems(document, outline);
} finally {
if (document != null) {
document.close();
@@ -26,6 +26,7 @@ import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest;
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;
@@ -136,6 +137,26 @@ public class MultiPageLayoutController {
contentStream.close();
// If any source page is rotated, skip form copying/transformation entirely
boolean hasRotation = FormUtils.hasAnyRotatedPage(sourceDocument);
if (hasRotation) {
log.info("Source document has rotated pages; skipping form field copying.");
} else {
try {
FormUtils.copyAndTransformFormFields(
sourceDocument,
newDocument,
totalPages,
pagesPerSheet,
cols,
rows,
cellWidth,
cellHeight);
} catch (Exception e) {
log.warn("Failed to copy and transform form fields: {}", e.getMessage(), e);
}
}
sourceDocument.close();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -10,10 +10,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.InitialSetup;
import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
@@ -22,7 +19,6 @@ import stirling.software.common.service.UserServiceInterface;
@ConfigApi
@Hidden
@Slf4j
public class ConfigController {
private final ApplicationProperties applicationProperties;
@@ -62,15 +58,9 @@ public class ConfigController {
// Extract values from ApplicationProperties
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
configData.put("languages", applicationProperties.getUi().getLanguages());
configData.put("logoStyle", applicationProperties.getUi().getLogoStyle());
// Security settings
// enableLogin requires both the config flag AND proprietary features to be loaded
// If userService is null, proprietary module isn't loaded
// (DISABLE_ADDITIONAL_FEATURES=true or DOCKER_ENABLE_SECURITY=false)
boolean enableLogin =
applicationProperties.getSecurity().getEnableLogin() && userService != null;
configData.put("enableLogin", enableLogin);
configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin());
// Mail settings - check both SMTP enabled AND invites enabled
boolean smtpEnabled = applicationProperties.getMail().isEnabled();
@@ -88,22 +78,6 @@ public class ConfigController {
}
configData.put("isAdmin", isAdmin);
// Check if this is a new server (version was 0.0.0 before initialization)
configData.put("isNewServer", InitialSetup.isNewServer());
// Check if the current user is a first-time user
boolean isNewUser =
false; // Default to false when security is disabled or user not found
if (userService != null) {
try {
isNewUser = userService.isCurrentUserFirstLogin();
} catch (Exception e) {
// If there's an error, assume not new user for safety
isNewUser = false;
}
}
configData.put("isNewUser", isNewUser);
// System settings
configData.put(
"enableAlphaFunctionality",
@@ -154,25 +128,6 @@ public class ConfigController {
// EE features not available, continue without them
}
// Add version and machine info for update checking
try {
if (applicationContext.containsBean("appVersion")) {
configData.put(
"appVersion", applicationContext.getBean("appVersion", String.class));
}
if (applicationContext.containsBean("machineType")) {
configData.put(
"machineType", applicationContext.getBean("machineType", String.class));
}
if (applicationContext.containsBean("activeSecurity")) {
configData.put(
"activeSecurity",
applicationContext.getBean("activeSecurity", Boolean.class));
}
} catch (Exception e) {
// Version/machine info not available
}
return ResponseEntity.ok(configData);
} catch (Exception e) {
@@ -23,7 +23,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointInspector;
import stirling.software.SPDF.config.StartupApplicationListener;
import stirling.software.SPDF.service.WeeklyActiveUsersService;
import stirling.software.common.annotations.api.InfoApi;
import stirling.software.common.model.ApplicationProperties;
@@ -35,7 +34,6 @@ public class MetricsController {
private final ApplicationProperties applicationProperties;
private final MeterRegistry meterRegistry;
private final EndpointInspector endpointInspector;
private final Optional<WeeklyActiveUsersService> wauService;
private boolean metricsEnabled;
@PostConstruct
@@ -354,35 +352,6 @@ public class MetricsController {
return ResponseEntity.ok(formatDuration(uptime));
}
@GetMapping("/wau")
@Operation(
summary = "Weekly Active Users statistics",
description =
"Returns WAU (Weekly Active Users) count and total unique browsers. "
+ "Only available when security is disabled (no-login mode). "
+ "Tracks unique browsers via client-generated UUID in localStorage.")
public ResponseEntity<?> getWeeklyActiveUsers() {
if (!metricsEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
}
// Check if WAU service is available (only when security.enableLogin=false)
if (wauService.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("WAU tracking is only available when security is disabled (no-login mode)");
}
WeeklyActiveUsersService service = wauService.get();
Map<String, Object> wauStats = new HashMap<>();
wauStats.put("weeklyActiveUsers", service.getWeeklyActiveUsers());
wauStats.put("totalUniqueBrowsers", service.getTotalUniqueBrowsers());
wauStats.put("daysOnline", service.getDaysOnline());
wauStats.put("trackingSince", service.getStartTime().toString());
return ResponseEntity.ok(wauStats);
}
private String formatDuration(Duration duration) {
long days = duration.toDays();
long hours = duration.toHoursPart();
@@ -1,100 +0,0 @@
package stirling.software.SPDF.service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Service for tracking Weekly Active Users (WAU) in no-login mode.
* Uses in-memory storage with automatic cleanup of old entries.
*/
@Service
@Slf4j
public class WeeklyActiveUsersService {
// Map of browser ID -> last seen timestamp
private final Map<String, Instant> activeBrowsers = new ConcurrentHashMap<>();
// Track total unique browsers seen (overall)
private long totalUniqueBrowsers = 0;
// Application start time
private final Instant startTime = Instant.now();
/**
* Records a browser access with the current timestamp
* @param browserId Unique browser identifier from X-Browser-Id header
*/
public void recordBrowserAccess(String browserId) {
if (browserId == null || browserId.trim().isEmpty()) {
return;
}
boolean isNewBrowser = !activeBrowsers.containsKey(browserId);
activeBrowsers.put(browserId, Instant.now());
if (isNewBrowser) {
totalUniqueBrowsers++;
log.debug("New browser recorded: {} (Total: {})", browserId, totalUniqueBrowsers);
}
}
/**
* Gets the count of unique browsers seen in the last 7 days
* @return Weekly Active Users count
*/
public long getWeeklyActiveUsers() {
cleanupOldEntries();
return activeBrowsers.size();
}
/**
* Gets the total count of unique browsers ever seen
* @return Total unique browsers count
*/
public long getTotalUniqueBrowsers() {
return totalUniqueBrowsers;
}
/**
* Gets the number of days the service has been running
* @return Days online
*/
public long getDaysOnline() {
return ChronoUnit.DAYS.between(startTime, Instant.now());
}
/**
* Gets the timestamp when tracking started
* @return Start time
*/
public Instant getStartTime() {
return startTime;
}
/**
* Removes entries older than 7 days
*/
private void cleanupOldEntries() {
Instant sevenDaysAgo = Instant.now().minus(7, ChronoUnit.DAYS);
activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo));
}
/**
* Manual cleanup trigger (can be called by scheduled task if needed)
*/
public void performCleanup() {
int sizeBefore = activeBrowsers.size();
cleanupOldEntries();
int sizeAfter = activeBrowsers.size();
if (sizeBefore != sizeAfter) {
log.debug("Cleaned up {} old browser entries", sizeBefore - sizeAfter);
}
}
}
@@ -176,7 +176,6 @@ system:
ui:
appNameNavbar: '' # name displayed on the navigation bar
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
endpoints:
@@ -7,8 +7,6 @@
--md-sys-color-surface-3: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.11) 5%);
--md-sys-color-surface-4: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.12) 5%);
--md-sys-color-surface-5: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.14) 5%);
/* Clear button disabled text color (default/light) */
--spdf-clear-disabled-text: var(--md-sys-color-primary);
/* Icon fill */
--md-sys-icon-fill-0: 'FILL' 0, 'wght' 500;
--md-sys-icon-fill-1: 'FILL' 1, 'wght' 500;
@@ -27,12 +25,6 @@
--md-sys-elevation-5: 0px 8px 10px -6px rgb(var(--md-elevation-shadow-color), 0.2), 0px 16px 24px 2px rgb(var(--md-elevation-shadow-color), 0.14), 0px 6px 30px 5px rgb(var(--md-elevation-shadow-color), 0.12);
}
/* Dark theme overrides */
.dark-theme {
/* In dark mode, use a neutral grey for disabled Clear button text */
--spdf-clear-disabled-text: var(--mantine-color-gray-5, #9e9e9e);
}
.fill {
font-variation-settings: var(--md-sys-icon-fill-1);
}
@@ -24,7 +24,6 @@ import org.mockito.ArgumentMatchers;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
@@ -87,13 +86,9 @@ class EditTableOfContentsControllerTest {
when(mockOutlineItem.getNextSibling()).thenReturn(null);
// When
ResponseEntity<List<Map<String, Object>>> response =
editTableOfContentsController.extractBookmarks(mockFile);
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
List<Map<String, Object>> result = response.getBody();
assertNotNull(result);
assertEquals(1, result.size());
@@ -113,13 +108,9 @@ class EditTableOfContentsControllerTest {
when(mockCatalog.getDocumentOutline()).thenReturn(null);
// When
ResponseEntity<List<Map<String, Object>>> response =
editTableOfContentsController.extractBookmarks(mockFile);
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
List<Map<String, Object>> result = response.getBody();
assertNotNull(result);
assertTrue(result.isEmpty());
verify(mockDocument).close();
@@ -151,13 +142,9 @@ class EditTableOfContentsControllerTest {
when(childItem.getNextSibling()).thenReturn(null);
// When
ResponseEntity<List<Map<String, Object>>> response =
editTableOfContentsController.extractBookmarks(mockFile);
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
List<Map<String, Object>> result = response.getBody();
assertNotNull(result);
assertEquals(1, result.size());
@@ -191,13 +178,9 @@ class EditTableOfContentsControllerTest {
when(mockOutlineItem.getNextSibling()).thenReturn(null);
// When
ResponseEntity<List<Map<String, Object>>> response =
editTableOfContentsController.extractBookmarks(mockFile);
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
List<Map<String, Object>> result = response.getBody();
assertNotNull(result);
assertEquals(1, result.size());
@@ -116,10 +116,6 @@ public class ProprietaryUIDataController {
LoginData data = new LoginData();
Map<String, String> providerList = new HashMap<>();
Security securityProps = applicationProperties.getSecurity();
// Add enableLogin flag so frontend doesn't need to call /app-config
data.setEnableLogin(securityProps.getEnableLogin());
OAUTH2 oauth = securityProps.getOauth2();
if (oauth != null && oauth.getEnabled()) {
@@ -452,7 +448,6 @@ public class ProprietaryUIDataController {
@Data
public static class LoginData {
private Boolean enableLogin;
private Map<String, String> providerList;
private String loginMethod;
private boolean altLogin;
@@ -1,215 +0,0 @@
package stirling.software.proprietary.controller.api.form;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.util.FormUtils;
@RestController
@RequestMapping("/api/v1/form")
@Tag(name = "Forms", description = "PDF form APIs")
@RequiredArgsConstructor
public class FormFillController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ObjectMapper objectMapper;
private static ResponseEntity<byte[]> saveDocument(PDDocument document, String baseName)
throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), baseName + ".pdf");
}
private static String buildBaseName(MultipartFile file, String suffix) {
String original = Filenames.toSimpleFileName(file.getOriginalFilename());
if (original == null || original.isBlank()) {
original = "document";
}
if (!original.toLowerCase().endsWith(".pdf")) {
return original + "_" + suffix;
}
String withoutExtension = original.substring(0, original.length() - 4);
return withoutExtension + "_" + suffix;
}
private static void requirePdf(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileFormatRequired", "{0} must be in PDF format", "file");
}
}
private static String decodePart(byte[] payload) {
if (payload == null || payload.length == 0) {
return null;
}
return new String(payload, StandardCharsets.UTF_8);
}
@PostMapping(value = "/fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Inspect PDF form fields",
description = "Returns metadata describing each field in the provided PDF form")
public ResponseEntity<FormUtils.FormFieldExtraction> listFields(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file)
throws IOException {
requirePdf(file);
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
FormUtils.FormFieldExtraction extraction =
FormUtils.extractFieldsWithTemplate(document);
return ResponseEntity.ok(extraction);
}
}
@PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Modify existing form fields",
description =
"Updates existing fields in the provided PDF and returns the updated file")
public ResponseEntity<byte[]> modifyFields(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file,
@RequestPart(value = "updates", required = false) byte[] updatesPayload)
throws IOException {
String rawUpdates = decodePart(updatesPayload);
List<FormUtils.ModifyFormFieldDefinition> modifications =
FormPayloadParser.parseModificationDefinitions(objectMapper, rawUpdates);
if (modifications.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dataRequired",
"{0} must contain at least one definition",
"updates payload");
}
return processSingleFile(
file, "updated", document -> FormUtils.modifyFormFields(document, modifications));
}
@PostMapping(value = "/delete-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Delete form fields",
description = "Removes the specified fields from the PDF and returns the updated file")
public ResponseEntity<byte[]> deleteFields(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file,
@Parameter(
description =
"JSON array of field names or objects with a name property,"
+ " matching the /fields response format",
example = "[{\"name\":\"Field1\"}]")
@RequestPart(value = "names", required = false)
byte[] namesPayload)
throws IOException {
String rawNames = decodePart(namesPayload);
List<String> names = FormPayloadParser.parseNameList(objectMapper, rawNames);
if (names.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dataRequired", "{0} must contain at least one value", "names payload");
}
return processSingleFile(
file, "updated", document -> FormUtils.deleteFormFields(document, names));
}
@PostMapping(value = "/fill", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Fill PDF form fields",
description =
"Populates the supplied PDF form using values from the provided JSON payload"
+ " and returns the filled PDF")
public ResponseEntity<byte[]> fillForm(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file,
@Parameter(
description = "JSON object of field-value pairs to apply",
example = "{\"field\":\"value\"}")
@RequestPart(value = "data", required = false)
byte[] valuesPayload,
@RequestParam(value = "flatten", defaultValue = "false") boolean flatten)
throws IOException {
String rawValues = decodePart(valuesPayload);
Map<String, Object> values = FormPayloadParser.parseValueMap(objectMapper, rawValues);
return processSingleFile(
file,
"filled",
document -> FormUtils.applyFieldValues(document, values, flatten, true));
}
private ResponseEntity<byte[]> processSingleFile(
MultipartFile file, String suffix, DocumentProcessor processor) throws IOException {
requirePdf(file);
String baseName = buildBaseName(file, suffix);
try (PDDocument document = pdfDocumentFactory.load(file)) {
processor.accept(document);
return saveDocument(document, baseName);
}
}
@FunctionalInterface
private interface DocumentProcessor {
void accept(PDDocument document) throws IOException;
}
}
@@ -1,295 +0,0 @@
package stirling.software.proprietary.controller.api.form;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.proprietary.util.FormUtils;
final class FormPayloadParser {
private static final String KEY_FIELDS = "fields";
private static final String KEY_NAME = "name";
private static final String KEY_TARGET_NAME = "targetName";
private static final String KEY_FIELD_NAME = "fieldName";
private static final String KEY_FIELD = "field";
private static final String KEY_VALUE = "value";
private static final String KEY_DEFAULT_VALUE = "defaultValue";
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
private static final TypeReference<List<FormUtils.ModifyFormFieldDefinition>>
MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {};
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
private FormPayloadParser() {}
static Map<String, Object> parseValueMap(ObjectMapper objectMapper, String json)
throws IOException {
if (json == null || json.isBlank()) {
return Map.of();
}
JsonNode root;
try {
root = objectMapper.readTree(json);
} catch (IOException e) {
// Fallback to legacy direct map parse (will throw again if invalid)
return objectMapper.readValue(json, MAP_TYPE);
}
if (root == null || root.isNull()) {
return Map.of();
}
// 1. If payload already a flat object with no special wrapping, keep legacy behavior
if (root.isObject()) {
// a) Prefer explicit 'template' object if present (new combined /fields response)
JsonNode templateNode = root.get("template");
if (templateNode != null && templateNode.isObject()) {
return objectToLinkedMap(templateNode);
}
// b) Accept an inline 'fields' array of field definitions (build map from them)
JsonNode fieldsNode = root.get(KEY_FIELDS);
if (fieldsNode != null && fieldsNode.isArray()) {
Map<String, Object> record = extractFieldInfoArray(fieldsNode);
if (!record.isEmpty()) {
return record;
}
}
// c) Fallback: treat entire object as the value map (legacy behavior)
return objectToLinkedMap(root);
}
// 2. If an array was supplied to /fill (non-standard), treat first element as record
if (root.isArray()) {
if (root.isEmpty()) {
return Map.of();
}
JsonNode first = root.get(0);
if (first != null && first.isObject()) {
if (first.has(KEY_NAME) || first.has(KEY_VALUE) || first.has(KEY_DEFAULT_VALUE)) {
return extractFieldInfoArray(root);
}
return objectToLinkedMap(first);
}
return Map.of();
}
// 3. Anything else: fallback to strict map parse
return objectMapper.readValue(json, MAP_TYPE);
}
static List<FormUtils.ModifyFormFieldDefinition> parseModificationDefinitions(
ObjectMapper objectMapper, String json) throws IOException {
if (json == null || json.isBlank()) {
return List.of();
}
return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE);
}
static List<String> parseNameList(ObjectMapper objectMapper, String json) throws IOException {
if (json == null || json.isBlank()) {
return List.of();
}
final JsonNode root = objectMapper.readTree(json);
if (root == null || root.isNull()) {
return List.of();
}
final Set<String> names = new LinkedHashSet<>();
if (root.isArray()) {
collectNames(root, names);
} else if (root.isObject()) {
if (root.has(KEY_FIELDS) && root.get(KEY_FIELDS).isArray()) {
collectNames(root.get(KEY_FIELDS), names);
} else {
final String single = extractName(root);
if (nonBlank(single)) {
names.add(single);
}
}
} else if (root.isTextual()) {
final String single = trimToNull(root.asText());
if (single != null) {
names.add(single);
}
}
if (!names.isEmpty()) {
return List.copyOf(names);
}
try {
return objectMapper.readValue(json, STRING_LIST_TYPE);
} catch (IOException e) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"names payload",
"expected array of strings or objects with 'name'-like properties");
}
}
private static Map<String, Object> extractFieldInfoArray(JsonNode fieldsNode) {
final Map<String, Object> record = new LinkedHashMap<>();
if (fieldsNode == null || fieldsNode.isNull() || !fieldsNode.isArray()) {
return record;
}
for (JsonNode fieldNode : fieldsNode) {
if (fieldNode == null || !fieldNode.isObject()) {
continue;
}
final String name = extractName(fieldNode);
if (!nonBlank(name)) {
continue;
}
JsonNode valueNode = fieldNode.get(KEY_VALUE);
if ((valueNode == null || valueNode.isNull())
&& fieldNode.hasNonNull(KEY_DEFAULT_VALUE)) {
valueNode = fieldNode.get(KEY_DEFAULT_VALUE);
}
final String normalized = normalizeFieldValue(valueNode);
record.put(name, normalized == null ? "" : normalized);
}
return record;
}
private static String normalizeFieldValue(JsonNode valueNode) {
if (valueNode == null || valueNode.isNull()) {
return null;
}
if (valueNode.isArray()) {
final List<String> values = new ArrayList<>();
for (JsonNode element : valueNode) {
final String text = coerceScalarToString(element);
if (text != null) {
values.add(text);
}
}
return String.join(",", values);
}
if (valueNode.isObject()) {
// Preserve object as JSON string
return valueNode.toString();
}
// Scalar (text/number/boolean)
return coerceScalarToString(valueNode);
}
private static String coerceScalarToString(JsonNode node) {
if (node == null || node.isNull()) {
return null;
}
if (node.isTextual()) {
return trimToEmpty(node.asText());
}
if (node.isNumber()) {
return node.numberValue().toString();
}
if (node.isBoolean()) {
return Boolean.toString(node.booleanValue());
}
// Fallback for other scalar-like nodes
return trimToEmpty(node.asText());
}
private static void collectNames(JsonNode arrayNode, Set<String> sink) {
if (arrayNode == null || !arrayNode.isArray()) {
return;
}
for (JsonNode node : arrayNode) {
final String name = extractName(node);
if (nonBlank(name)) {
sink.add(name);
}
}
}
private static String extractName(JsonNode node) {
if (node == null || node.isNull()) {
return null;
}
if (node.isTextual()) {
return trimToNull(node.asText());
}
if (node.isObject()) {
final String direct = textProperty(node, KEY_NAME, KEY_TARGET_NAME, KEY_FIELD_NAME);
if (nonBlank(direct)) {
return direct;
}
final JsonNode field = node.get(KEY_FIELD);
if (field != null && field.isObject()) {
final String nested =
textProperty(field, KEY_NAME, KEY_TARGET_NAME, KEY_FIELD_NAME);
if (nonBlank(nested)) {
return nested;
}
}
}
return null;
}
private static String textProperty(JsonNode node, String... keys) {
for (String key : keys) {
final JsonNode valueNode = node.get(key);
final String value = coerceScalarToString(valueNode);
if (nonBlank(value)) {
return value;
}
}
return null;
}
private static Map<String, Object> objectToLinkedMap(JsonNode objectNode) {
final Map<String, Object> result = new LinkedHashMap<>();
objectNode
.fieldNames()
.forEachRemaining(
key -> {
final JsonNode v = objectNode.get(key);
if (v == null || v.isNull()) {
result.put(key, null);
} else if (v.isTextual() || v.isNumber() || v.isBoolean()) {
result.put(key, coerceScalarToString(v));
} else {
result.put(key, v.toString());
}
});
return result;
}
private static boolean nonBlank(String s) {
return s != null && !s.isBlank();
}
private static String trimToNull(String s) {
if (s == null) return null;
final String t = s.trim();
return t.isEmpty() ? null : t;
}
private static String trimToEmpty(String s) {
return s == null ? "" : s.trim();
}
}
@@ -17,20 +17,6 @@ public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
HttpServletResponse response,
AuthenticationException authException)
throws IOException {
String contextPath = request.getContextPath();
String requestURI = request.getRequestURI();
// For API requests, return JSON error
if (requestURI.startsWith(contextPath + "/api/")) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
String message =
authException != null ? authException.getMessage() : "Authentication required";
response.getWriter().write("{\"error\":\"" + message + "\"}");
} else {
// For non-API requests, use default behavior
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
}
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
}
}
@@ -1,6 +1,5 @@
package stirling.software.proprietary.security.configuration;
import java.util.List;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
@@ -29,15 +28,11 @@ import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.savedrequest.NullRequestCache;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.CustomAuthenticationFailureHandler;
import stirling.software.proprietary.security.CustomAuthenticationSuccessHandler;
import stirling.software.proprietary.security.CustomLogoutSuccessHandler;
@@ -72,7 +67,6 @@ public class SecurityConfiguration {
private final boolean loginEnabledValue;
private final boolean runningProOrHigher;
private final ApplicationProperties applicationProperties;
private final ApplicationProperties.Security securityProperties;
private final AppConfig appConfig;
private final UserAuthenticationFilter userAuthenticationFilter;
@@ -92,7 +86,6 @@ public class SecurityConfiguration {
@Qualifier("loginEnabled") boolean loginEnabledValue,
@Qualifier("runningProOrHigher") boolean runningProOrHigher,
AppConfig appConfig,
ApplicationProperties applicationProperties,
ApplicationProperties.Security securityProperties,
UserAuthenticationFilter userAuthenticationFilter,
JwtServiceInterface jwtService,
@@ -109,7 +102,6 @@ public class SecurityConfiguration {
this.loginEnabledValue = loginEnabledValue;
this.runningProOrHigher = runningProOrHigher;
this.appConfig = appConfig;
this.applicationProperties = applicationProperties;
this.securityProperties = securityProperties;
this.userAuthenticationFilter = userAuthenticationFilter;
this.jwtService = jwtService;
@@ -128,71 +120,7 @@ public class SecurityConfiguration {
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
List<String> configuredOrigins = null;
if (applicationProperties.getSystem() != null) {
configuredOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
}
CorsConfiguration cfg = new CorsConfiguration();
if (configuredOrigins != null && !configuredOrigins.isEmpty()) {
cfg.setAllowedOriginPatterns(configuredOrigins);
log.debug(
"CORS configured with allowed origin patterns from settings.yml: {}",
configuredOrigins);
} else {
// Default to allowing all origins when nothing is configured
cfg.setAllowedOriginPatterns(List.of("*"));
log.info(
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
}
// Explicitly configure supported HTTP methods (include OPTIONS for preflight)
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
cfg.setAllowedHeaders(
List.of(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN"));
cfg.setExposedHeaders(
List.of(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
}
@Bean
public SecurityFilterChain filterChain(
HttpSecurity http,
@Lazy IPRateLimitingFilter rateLimitingFilter,
@Lazy JwtAuthenticationFilter jwtAuthenticationFilter)
throws Exception {
// Enable CORS only if we have configured origins
CorsConfigurationSource corsSource = corsConfigurationSource();
if (corsSource != null) {
http.cors(cors -> cors.configurationSource(corsSource));
} else {
// Explicitly disable CORS when no origins are configured
http.cors(cors -> cors.disable());
}
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
if (securityProperties.getCsrfDisabled() || !loginEnabledValue) {
http.csrf(CsrfConfigurer::disable);
}
@@ -202,8 +130,12 @@ public class SecurityConfiguration {
http.addFilterBefore(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
.addFilterBefore(
rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class);
if (v2Enabled) {
http.addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class);
}
if (!securityProperties.getCsrfDisabled()) {
CookieCsrfTokenRepository cookieRepo =
@@ -263,18 +195,6 @@ public class SecurityConfiguration {
});
http.authenticationProvider(daoAuthenticationProvider());
http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache()));
// Configure exception handling for API endpoints
http.exceptionHandling(
exceptions ->
exceptions.defaultAuthenticationEntryPointFor(
jwtAuthenticationEntryPoint,
request -> {
String contextPath = request.getContextPath();
String requestURI = request.getRequestURI();
return requestURI.startsWith(contextPath + "/api/");
}));
http.logout(
logout ->
logout.logoutRequestMatcher(
@@ -307,12 +227,49 @@ public class SecurityConfiguration {
req -> {
String uri = req.getRequestURI();
String contextPath = req.getContextPath();
// Check if it's a public auth endpoint or static
// resource
return RequestUriUtils.isStaticResource(
contextPath, uri)
|| RequestUriUtils.isPublicAuthEndpoint(
uri, contextPath);
// Remove the context path from the URI
String trimmedUri =
uri.startsWith(contextPath)
? uri.substring(
contextPath.length())
: uri;
return trimmedUri.startsWith("/login")
|| trimmedUri.startsWith("/oauth")
|| trimmedUri.startsWith("/oauth2")
|| trimmedUri.startsWith("/saml2")
|| trimmedUri.endsWith(".svg")
|| trimmedUri.startsWith("/register")
|| trimmedUri.startsWith("/signup")
|| trimmedUri.startsWith("/invite")
|| trimmedUri.startsWith("/auth/callback")
|| trimmedUri.startsWith("/error")
|| trimmedUri.startsWith("/images/")
|| trimmedUri.startsWith("/public/")
|| trimmedUri.startsWith("/css/")
|| trimmedUri.startsWith("/fonts/")
|| trimmedUri.startsWith("/js/")
|| trimmedUri.startsWith("/pdfjs/")
|| trimmedUri.startsWith("/pdfjs-legacy/")
|| trimmedUri.startsWith("/favicon")
|| trimmedUri.startsWith(
"/api/v1/info/status")
|| trimmedUri.startsWith("/api/v1/config")
|| trimmedUri.startsWith(
"/api/v1/auth/register")
|| trimmedUri.startsWith(
"/api/v1/user/register")
|| trimmedUri.startsWith(
"/api/v1/auth/login")
|| trimmedUri.startsWith(
"/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/me")
|| trimmedUri.startsWith(
"/api/v1/invite/validate")
|| trimmedUri.startsWith(
"/api/v1/invite/accept")
|| trimmedUri.startsWith("/v1/api-docs")
|| uri.contains("/v1/api-docs");
})
.permitAll()
.anyRequest()
@@ -381,12 +338,8 @@ public class SecurityConfiguration {
.saml2Login(
saml2 -> {
try {
// Only set login page for v1/Thymeleaf mode
if (!v2Enabled) {
saml2.loginPage("/saml2");
}
saml2.relyingPartyRegistrationRepository(
saml2.loginPage("/saml2")
.relyingPartyRegistrationRepository(
saml2RelyingPartyRegistrations)
.authenticationManager(
new ProviderManager(authenticationProvider))
@@ -21,15 +21,11 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
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.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
/** REST API Controller for authentication operations. */
@@ -43,7 +39,6 @@ public class AuthController {
private final UserService userService;
private final JwtServiceInterface jwtService;
private final CustomUserDetailsService userDetailsService;
private final LoginAttemptService loginAttemptService;
/**
* Login endpoint - replaces Supabase signInWithPassword
@@ -54,11 +49,8 @@ public class AuthController {
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/login")
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
public ResponseEntity<?> login(
@RequestBody UsernameAndPass request,
HttpServletRequest httpRequest,
HttpServletResponse response) {
@RequestBody UsernameAndPass request, HttpServletResponse response) {
try {
// Validate input parameters
if (request.getUsername() == null || request.getUsername().trim().isEmpty()) {
@@ -75,30 +67,20 @@ public class AuthController {
.body(Map.of("error", "Password is required"));
}
String username = request.getUsername().trim();
String ip = httpRequest.getRemoteAddr();
log.debug("Login attempt for user: {}", request.getUsername());
// Check if account is blocked due to too many failed attempts
if (loginAttemptService.isBlocked(username)) {
log.warn("Blocked account login attempt for user: {} from IP: {}", username, ip);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Account is locked due to too many failed attempts"));
}
log.debug("Login attempt for user: {} from IP: {}", username, ip);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UserDetails userDetails =
userDetailsService.loadUserByUsername(request.getUsername().trim());
User user = (User) userDetails;
if (!userService.isPasswordCorrect(user, request.getPassword())) {
log.warn("Invalid password for user: {} from IP: {}", username, ip);
loginAttemptService.loginFailed(username);
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: {} from IP: {}", username, ip);
log.warn("Disabled user attempted login: {}", request.getUsername());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "User account is disabled"));
}
@@ -109,9 +91,7 @@ public class AuthController {
String token = jwtService.generateToken(user.getUsername(), claims);
// Record successful login
loginAttemptService.loginSucceeded(username);
log.info("Login successful for user: {} from IP: {}", username, ip);
log.info("Login successful for user: {}", request.getUsername());
return ResponseEntity.ok(
Map.of(
@@ -119,15 +99,11 @@ public class AuthController {
"session", Map.of("access_token", token, "expires_in", 3600)));
} catch (UsernameNotFoundException e) {
String username = request.getUsername();
log.warn("User not found: {}", username);
loginAttemptService.loginFailed(username);
log.warn("User not found: {}", request.getUsername());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid username or password"));
} catch (AuthenticationException e) {
String username = request.getUsername();
log.error("Authentication failed for user: {}", username, e);
loginAttemptService.loginFailed(username);
log.error("Authentication failed for user: {}", request.getUsername(), e);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid credentials"));
} catch (Exception e) {
@@ -252,4 +228,11 @@ public class AuthController {
return userMap;
}
// ===========================
// Request/Response DTOs
// ===========================
/** Login request DTO */
public record LoginRequest(String email, String password) {}
}
@@ -15,7 +15,7 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.InviteApi;
import stirling.software.common.annotations.api.UserApi;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
@@ -26,9 +26,11 @@ import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
@InviteApi
@UserApi
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/invite")
public class InviteLinkController {
private final InviteTokenRepository inviteTokenRepository;
@@ -742,31 +742,4 @@ public class UserController {
return errorMessage;
}
}
@PostMapping("/complete-initial-setup")
public ResponseEntity<?> completeInitialSetup() {
try {
String username = userService.getCurrentUsername();
if (username == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("User not authenticated");
}
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
if (userOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User not found");
}
User user = userOpt.get();
user.setHasCompletedInitialSetup(true);
userRepository.save(user);
log.info("User {} completed initial setup", username);
return ResponseEntity.ok().body(Map.of("success", true));
} catch (Exception e) {
log.error("Error completing initial setup", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to complete initial setup");
}
}
}
@@ -1,6 +1,5 @@
package stirling.software.proprietary.security.filter;
import static stirling.software.common.util.RequestUriUtils.isPublicAuthEndpoint;
import static stirling.software.common.util.RequestUriUtils.isStaticResource;
import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2;
import static stirling.software.proprietary.security.model.AuthenticationType.SAML2;
@@ -81,7 +80,20 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String requestURI = request.getRequestURI();
String contextPath = request.getContextPath();
if (!isPublicAuthEndpoint(requestURI, contextPath)) {
// Public auth endpoints that don't require JWT
boolean isPublicAuthEndpoint =
requestURI.startsWith(contextPath + "/login")
|| requestURI.startsWith(contextPath + "/signup")
|| requestURI.startsWith(contextPath + "/invite")
|| requestURI.startsWith(contextPath + "/auth/")
|| requestURI.startsWith(contextPath + "/oauth2")
|| requestURI.startsWith(contextPath + "/api/v1/auth/login")
|| requestURI.startsWith(contextPath + "/api/v1/auth/register")
|| requestURI.startsWith(contextPath + "/api/v1/auth/refresh")
|| requestURI.startsWith(contextPath + "/api/v1/invite/validate")
|| requestURI.startsWith(contextPath + "/api/v1/invite/accept");
if (!isPublicAuthEndpoint) {
// For API requests, return 401 JSON
String acceptHeader = request.getHeader("Accept");
if (requestURI.startsWith(contextPath + "/api/")
@@ -1,7 +1,5 @@
package stirling.software.proprietary.security.filter;
import static stirling.software.common.util.RequestUriUtils.isPublicAuthEndpoint;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
@@ -107,18 +105,11 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
}
}
// If we still don't have any authentication, check if it's a public endpoint. If not, deny
// the request
// If we still don't have any authentication, deny the request
if (authentication == null || !authentication.isAuthenticated()) {
String method = request.getMethod();
String contextPath = request.getContextPath();
// Allow public auth endpoints to pass through without authentication
if (isPublicAuthEndpoint(requestURI, contextPath)) {
filterChain.doFilter(request, response);
return;
}
if ("GET".equalsIgnoreCase(method) && !requestURI.startsWith(contextPath + "/login")) {
response.sendRedirect(contextPath + "/login"); // redirect to the login page
} else {
@@ -209,24 +200,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
}
private static boolean isPublicAuthEndpoint(String requestURI, String contextPath) {
// Remove context path from URI to normalize path matching
String trimmedUri =
requestURI.startsWith(contextPath)
? requestURI.substring(contextPath.length())
: requestURI;
// Public auth endpoints that don't require authentication
return trimmedUri.startsWith("/login")
|| trimmedUri.startsWith("/auth/")
|| trimmedUri.startsWith("/oauth2")
|| trimmedUri.startsWith("/saml2")
|| trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/logout")
|| trimmedUri.startsWith("/api/v1/proprietary/ui-data/login");
}
private enum UserLoginType {
USERDETAILS("UserDetails"),
OAUTH2USER("OAuth2User"),
@@ -252,6 +225,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
String contextPath = request.getContextPath();
String[] permitAllPatterns = {
contextPath + "/login",
contextPath + "/signup",
contextPath + "/register",
contextPath + "/invite",
contextPath + "/error",
@@ -264,6 +238,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
contextPath + "/pdfjs-legacy/",
contextPath + "/api/v1/info/status",
contextPath + "/api/v1/auth/login",
contextPath + "/api/v1/auth/register",
contextPath + "/api/v1/auth/refresh",
contextPath + "/api/v1/auth/me",
contextPath + "/api/v1/invite/validate",
@@ -56,9 +56,6 @@ public class User implements UserDetails, Serializable {
@Column(name = "isFirstLogin")
private Boolean isFirstLogin = false;
@Column(name = "hasCompletedInitialSetup")
private Boolean hasCompletedInitialSetup = false;
@Column(name = "roleName")
private String roleName;
@@ -106,14 +103,6 @@ public class User implements UserDetails, Serializable {
this.isFirstLogin = isFirstLogin;
}
public boolean hasCompletedInitialSetup() {
return hasCompletedInitialSetup != null && hasCompletedInitialSetup;
}
public void setHasCompletedInitialSetup(boolean hasCompletedInitialSetup) {
this.hasCompletedInitialSetup = hasCompletedInitialSetup;
}
public void setAuthenticationType(AuthenticationType authenticationType) {
this.authenticationType = authenticationType.toString().toLowerCase();
}
@@ -4,14 +4,9 @@ import static stirling.software.proprietary.security.model.AuthenticationType.OA
import static stirling.software.proprietary.security.model.AuthenticationType.SSO;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.util.Map;
import java.util.Optional;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
@@ -21,7 +16,6 @@ import org.springframework.security.web.authentication.SavedRequestAwareAuthenti
import org.springframework.security.web.savedrequest.SavedRequest;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
@@ -43,9 +37,6 @@ import stirling.software.proprietary.security.service.UserService;
public class CustomOAuth2AuthenticationSuccessHandler
extends SavedRequestAwareAuthenticationSuccessHandler {
private static final String SPA_REDIRECT_COOKIE = "stirling_redirect_path";
private static final String DEFAULT_CALLBACK_PATH = "/auth/callback";
private final LoginAttemptService loginAttemptService;
private final ApplicationProperties.Security.OAUTH2 oauth2Properties;
private final UserService userService;
@@ -128,8 +119,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
authentication, Map.of("authType", AuthenticationType.OAUTH2));
// Build context-aware redirect URL based on the original request
String redirectUrl =
buildContextAwareRedirectUrl(request, response, contextPath, jwt);
String redirectUrl = buildContextAwareRedirectUrl(request, contextPath, jwt);
response.sendRedirect(redirectUrl);
} else {
@@ -159,110 +149,30 @@ public class CustomOAuth2AuthenticationSuccessHandler
* Builds a context-aware redirect URL based on the request's origin
*
* @param request The HTTP request
* @param response HTTP response (used to clear redirect cookies)
* @param contextPath The application context path
* @param jwt The JWT token to include
* @return The appropriate redirect URL
*/
private String buildContextAwareRedirectUrl(
HttpServletRequest request,
HttpServletResponse response,
String contextPath,
String jwt) {
String redirectPath = resolveRedirectPath(request, contextPath);
String origin =
resolveForwardedOrigin(request)
.orElseGet(
() ->
resolveOriginFromReferer(request)
.orElseGet(() -> buildOriginFromRequest(request)));
clearRedirectCookie(response);
return origin + redirectPath + "#access_token=" + jwt;
}
private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
return extractRedirectPathFromCookie(request)
.filter(path -> path.startsWith("/"))
.orElseGet(() -> defaultCallbackPath(contextPath));
}
private Optional<String> extractRedirectPathFromCookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return Optional.empty();
}
for (Cookie cookie : cookies) {
if (SPA_REDIRECT_COOKIE.equals(cookie.getName())) {
String value = URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8).trim();
if (!value.isEmpty()) {
return Optional.of(value);
}
}
}
return Optional.empty();
}
private String defaultCallbackPath(String contextPath) {
if (contextPath == null
|| contextPath.isBlank()
|| "/".equals(contextPath)
|| "\\".equals(contextPath)) {
return DEFAULT_CALLBACK_PATH;
}
return contextPath + DEFAULT_CALLBACK_PATH;
}
private Optional<String> resolveForwardedOrigin(HttpServletRequest request) {
String forwardedHostHeader = request.getHeader("X-Forwarded-Host");
if (forwardedHostHeader == null || forwardedHostHeader.isBlank()) {
return Optional.empty();
}
String host = forwardedHostHeader.split(",")[0].trim();
if (host.isEmpty()) {
return Optional.empty();
}
String forwardedProtoHeader = request.getHeader("X-Forwarded-Proto");
String proto =
(forwardedProtoHeader == null || forwardedProtoHeader.isBlank())
? request.getScheme()
: forwardedProtoHeader.split(",")[0].trim();
if (!host.contains(":")) {
String forwardedPort = request.getHeader("X-Forwarded-Port");
if (forwardedPort != null
&& !forwardedPort.isBlank()
&& !isDefaultPort(proto, forwardedPort.trim())) {
host = host + ":" + forwardedPort.trim();
}
}
return Optional.of(proto + "://" + host);
}
private Optional<String> resolveOriginFromReferer(HttpServletRequest request) {
HttpServletRequest request, String contextPath, String jwt) {
// Try to get the origin from the Referer header first
String referer = request.getHeader("Referer");
if (referer != null && !referer.isEmpty()) {
try {
java.net.URL refererUrl = new java.net.URL(referer);
String refererHost = refererUrl.getHost().toLowerCase();
if (!isOAuthProviderDomain(refererHost)) {
String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost();
if (refererUrl.getPort() != -1
&& refererUrl.getPort() != 80
&& refererUrl.getPort() != 443) {
origin += ":" + refererUrl.getPort();
}
return Optional.of(origin);
String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost();
if (refererUrl.getPort() != -1
&& refererUrl.getPort() != 80
&& refererUrl.getPort() != 443) {
origin += ":" + refererUrl.getPort();
}
return origin + "/auth/callback#access_token=" + jwt;
} catch (java.net.MalformedURLException e) {
// ignore and fall back
// Fall back to other methods if referer is malformed
}
}
return Optional.empty();
}
private String buildOriginFromRequest(HttpServletRequest request) {
// Fall back to building from request host/port
String scheme = request.getScheme();
String serverName = request.getServerName();
int serverPort = request.getServerPort();
@@ -270,50 +180,12 @@ public class CustomOAuth2AuthenticationSuccessHandler
StringBuilder origin = new StringBuilder();
origin.append(scheme).append("://").append(serverName);
if ((!"http".equalsIgnoreCase(scheme) || serverPort != 80)
&& (!"https".equalsIgnoreCase(scheme) || serverPort != 443)) {
// Only add port if it's not the default port for the scheme
if ((!"http".equals(scheme) || serverPort != 80)
&& (!"https".equals(scheme) || serverPort != 443)) {
origin.append(":").append(serverPort);
}
return origin.toString();
}
private boolean isDefaultPort(String scheme, String port) {
if (port == null) {
return true;
}
try {
int parsedPort = Integer.parseInt(port);
return ("http".equalsIgnoreCase(scheme) && parsedPort == 80)
|| ("https".equalsIgnoreCase(scheme) && parsedPort == 443);
} catch (NumberFormatException e) {
return false;
}
}
private void clearRedirectCookie(HttpServletResponse response) {
ResponseCookie cookie =
ResponseCookie.from(SPA_REDIRECT_COOKIE, "")
.path("/")
.sameSite("Lax")
.maxAge(0)
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
/**
* Checks if the given hostname belongs to a known OAuth provider.
*
* @param hostname The hostname to check
* @return true if it's an OAuth provider domain, false otherwise
*/
private boolean isOAuthProviderDomain(String hostname) {
return hostname.contains("google.com")
|| hostname.contains("googleapis.com")
|| hostname.contains("github.com")
|| hostname.contains("microsoft.com")
|| hostname.contains("microsoftonline.com")
|| hostname.contains("linkedin.com")
|| hostname.contains("apple.com");
return origin.toString() + "/auth/callback#access_token=" + jwt;
}
}
@@ -165,7 +165,12 @@ public class OAuth2Configuration {
githubClient.getUseAsUsername());
boolean isValid = validateProvider(github);
log.info("Initialised GitHub OAuth2 provider");
log.info(
"GitHub OAuth2 provider validation: {} (clientId: {}, clientSecret: {}, scopes: {})",
isValid,
githubClient.getClientId(),
githubClient.getClientSecret() != null ? "***" : "null",
githubClient.getScopes());
return isValid
? Optional.of(
@@ -4,21 +4,15 @@ import static stirling.software.proprietary.security.model.AuthenticationType.SA
import static stirling.software.proprietary.security.model.AuthenticationType.SSO;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.util.Map;
import java.util.Optional;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.SavedRequest;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
@@ -42,9 +36,6 @@ import stirling.software.proprietary.security.service.UserService;
public class CustomSaml2AuthenticationSuccessHandler
extends SavedRequestAwareAuthenticationSuccessHandler {
private static final String SPA_REDIRECT_COOKIE = "stirling_redirect_path";
private static final String DEFAULT_CALLBACK_PATH = "/auth/callback";
private LoginAttemptService loginAttemptService;
private ApplicationProperties.Security.SAML2 saml2Properties;
private UserService userService;
@@ -157,7 +148,7 @@ public class CustomSaml2AuthenticationSuccessHandler
// Build context-aware redirect URL based on the original request
String redirectUrl =
buildContextAwareRedirectUrl(request, response, contextPath, jwt);
buildContextAwareRedirectUrl(request, contextPath, jwt);
response.sendRedirect(redirectUrl);
} else {
@@ -186,81 +177,8 @@ public class CustomSaml2AuthenticationSuccessHandler
* @return The appropriate redirect URL
*/
private String buildContextAwareRedirectUrl(
HttpServletRequest request,
HttpServletResponse response,
String contextPath,
String jwt) {
String redirectPath = resolveRedirectPath(request, contextPath);
String origin =
resolveForwardedOrigin(request)
.orElseGet(
() ->
resolveOriginFromReferer(request)
.orElseGet(() -> buildOriginFromRequest(request)));
clearRedirectCookie(response);
return origin + redirectPath + "#access_token=" + jwt;
}
private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
return extractRedirectPathFromCookie(request)
.filter(path -> path.startsWith("/"))
.orElseGet(() -> defaultCallbackPath(contextPath));
}
private Optional<String> extractRedirectPathFromCookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return Optional.empty();
}
for (Cookie cookie : cookies) {
if (SPA_REDIRECT_COOKIE.equals(cookie.getName())) {
String value = URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8).trim();
if (!value.isEmpty()) {
return Optional.of(value);
}
}
}
return Optional.empty();
}
private String defaultCallbackPath(String contextPath) {
if (contextPath == null
|| contextPath.isBlank()
|| "/".equals(contextPath)
|| "\\".equals(contextPath)) {
return DEFAULT_CALLBACK_PATH;
}
return contextPath + DEFAULT_CALLBACK_PATH;
}
private Optional<String> resolveForwardedOrigin(HttpServletRequest request) {
String forwardedHostHeader = request.getHeader("X-Forwarded-Host");
if (forwardedHostHeader == null || forwardedHostHeader.isBlank()) {
return Optional.empty();
}
String host = forwardedHostHeader.split(",")[0].trim();
if (host.isEmpty()) {
return Optional.empty();
}
String forwardedProtoHeader = request.getHeader("X-Forwarded-Proto");
String proto =
(forwardedProtoHeader == null || forwardedProtoHeader.isBlank())
? request.getScheme()
: forwardedProtoHeader.split(",")[0].trim();
if (!host.contains(":")) {
String forwardedPort = request.getHeader("X-Forwarded-Port");
if (forwardedPort != null
&& !forwardedPort.isBlank()
&& !isDefaultPort(proto, forwardedPort.trim())) {
host = host + ":" + forwardedPort.trim();
}
}
return Optional.of(proto + "://" + host);
}
private Optional<String> resolveOriginFromReferer(HttpServletRequest request) {
HttpServletRequest request, String contextPath, String jwt) {
// Try to get the origin from the Referer header first
String referer = request.getHeader("Referer");
if (referer != null && !referer.isEmpty()) {
try {
@@ -271,16 +189,14 @@ public class CustomSaml2AuthenticationSuccessHandler
&& refererUrl.getPort() != 443) {
origin += ":" + refererUrl.getPort();
}
return Optional.of(origin);
return origin + "/auth/callback#access_token=" + jwt;
} catch (java.net.MalformedURLException e) {
log.debug(
"Malformed referer URL: {}, falling back to request-based origin", referer);
}
}
return Optional.empty();
}
private String buildOriginFromRequest(HttpServletRequest request) {
// Fall back to building from request host/port
String scheme = request.getScheme();
String serverName = request.getServerName();
int serverPort = request.getServerPort();
@@ -288,34 +204,12 @@ public class CustomSaml2AuthenticationSuccessHandler
StringBuilder origin = new StringBuilder();
origin.append(scheme).append("://").append(serverName);
if ((!"http".equalsIgnoreCase(scheme) || serverPort != 80)
&& (!"https".equalsIgnoreCase(scheme) || serverPort != 443)) {
// Only add port if it's not the default port for the scheme
if ((!"http".equals(scheme) || serverPort != 80)
&& (!"https".equals(scheme) || serverPort != 443)) {
origin.append(":").append(serverPort);
}
return origin.toString();
}
private boolean isDefaultPort(String scheme, String port) {
if (port == null) {
return true;
}
try {
int parsedPort = Integer.parseInt(port);
return ("http".equalsIgnoreCase(scheme) && parsedPort == 80)
|| ("https".equalsIgnoreCase(scheme) && parsedPort == 443);
} catch (NumberFormatException e) {
return false;
}
}
private void clearRedirectCookie(HttpServletResponse response) {
ResponseCookie cookie =
ResponseCookie.from(SPA_REDIRECT_COOKIE, "")
.path("/")
.sameSite("Lax")
.maxAge(0)
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
return origin + "/auth/callback#access_token=" + jwt;
}
}
@@ -663,21 +663,6 @@ public class UserService implements UserServiceInterface {
return false;
}
public boolean isCurrentUserFirstLogin() {
try {
String username = getCurrentUsername();
if (username != null) {
Optional<User> userOpt = findByUsernameIgnoreCase(username);
if (userOpt.isPresent()) {
return !userOpt.get().hasCompletedInitialSetup();
}
}
} catch (Exception e) {
log.debug("Error checking first login status", e);
}
return false;
}
@Transactional
public void syncCustomApiUser(String customApiKey) {
if (customApiKey == null || customApiKey.trim().isBlank()) {
@@ -1,345 +0,0 @@
package stirling.software.proprietary.util;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@UtilityClass
public class FormCopyUtils {
public boolean hasAnyRotatedPage(PDDocument document) {
try {
for (PDPage page : document.getPages()) {
int rot = page.getRotation();
int norm = ((rot % 360) + 360) % 360;
if (norm != 0) {
return true;
}
}
} catch (Exception e) {
log.warn("Failed to inspect page rotations: {}", e.getMessage(), e);
}
return false;
}
public void copyAndTransformFormFields(
PDDocument sourceDocument,
PDDocument newDocument,
int totalPages,
int pagesPerSheet,
int cols,
int rows,
float cellWidth,
float cellHeight)
throws IOException {
PDDocumentCatalog sourceCatalog = sourceDocument.getDocumentCatalog();
PDAcroForm sourceAcroForm = sourceCatalog.getAcroForm();
if (sourceAcroForm == null || sourceAcroForm.getFields().isEmpty()) {
return;
}
PDDocumentCatalog newCatalog = newDocument.getDocumentCatalog();
PDAcroForm newAcroForm = new PDAcroForm(newDocument);
newCatalog.setAcroForm(newAcroForm);
PDResources dr = new PDResources();
PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
PDType1Font zapfDingbats = new PDType1Font(Standard14Fonts.FontName.ZAPF_DINGBATS);
dr.put(COSName.getPDFName("Helv"), helvetica);
dr.put(COSName.getPDFName("ZaDb"), zapfDingbats);
newAcroForm.setDefaultResources(dr);
newAcroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
// Temporarily set NeedAppearances to true during field creation
newAcroForm.setNeedAppearances(true);
Map<String, Integer> fieldNameCounters = new HashMap<>();
// Build widget -> field map once for efficient lookups
Map<PDAnnotationWidget, PDField> widgetFieldMap = buildWidgetFieldMap(sourceAcroForm);
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
PDPage sourcePage = sourceDocument.getPage(pageIndex);
List<PDAnnotation> annotations = sourcePage.getAnnotations();
if (annotations.isEmpty()) {
continue;
}
int destinationPageIndex = pageIndex / pagesPerSheet;
int adjustedPageIndex = pageIndex % pagesPerSheet;
int rowIndex = adjustedPageIndex / cols;
int colIndex = adjustedPageIndex % cols;
if (rowIndex >= rows) {
continue;
}
if (destinationPageIndex >= newDocument.getNumberOfPages()) {
continue;
}
PDPage destinationPage = newDocument.getPage(destinationPageIndex);
PDRectangle sourceRect = sourcePage.getMediaBox();
float scaleWidth = cellWidth / sourceRect.getWidth();
float scaleHeight = cellHeight / sourceRect.getHeight();
float scale = Math.min(scaleWidth, scaleHeight);
float x = colIndex * cellWidth + (cellWidth - sourceRect.getWidth() * scale) / 2;
float y =
destinationPage.getMediaBox().getHeight()
- ((rowIndex + 1) * cellHeight
- (cellHeight - sourceRect.getHeight() * scale) / 2);
copyBasicFormFields(
sourceAcroForm,
newAcroForm,
sourcePage,
destinationPage,
x,
y,
scale,
pageIndex,
fieldNameCounters,
widgetFieldMap);
}
// Generate appearance streams and embed them authoritatively
boolean appearancesGenerated = false;
try {
newAcroForm.refreshAppearances();
appearancesGenerated = true;
} catch (NoSuchMethodError nsme) {
log.warn(
"AcroForm.refreshAppearances() not available in this PDFBox version; "
+ "leaving NeedAppearances=true for viewer-side rendering.");
} catch (Exception t) {
log.warn(
"Failed to refresh field appearances via AcroForm: {}. "
+ "Leaving NeedAppearances=true as fallback.",
t.getMessage(),
t);
}
// After successful appearance generation, set NeedAppearances to false
// to signal that appearance streams are now embedded authoritatively
if (appearancesGenerated) {
try {
newAcroForm.setNeedAppearances(false);
} catch (Exception e) {
log.debug(
"Failed to set NeedAppearances to false: {}. "
+ "Appearances were generated but flag could not be updated.",
e.getMessage());
}
}
}
private void copyBasicFormFields(
PDAcroForm sourceAcroForm,
PDAcroForm newAcroForm,
PDPage sourcePage,
PDPage destinationPage,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters,
Map<PDAnnotationWidget, PDField> widgetFieldMap) {
try {
List<PDAnnotation> sourceAnnotations = sourcePage.getAnnotations();
List<PDAnnotation> destinationAnnotations = destinationPage.getAnnotations();
for (PDAnnotation annotation : sourceAnnotations) {
if (annotation instanceof PDAnnotationWidget widgetAnnotation) {
if (widgetAnnotation.getRectangle() == null) {
continue;
}
PDField sourceField =
widgetFieldMap != null ? widgetFieldMap.get(widgetAnnotation) : null;
if (sourceField == null) {
continue; // skip widgets without a matching field
}
if (!(sourceField instanceof PDTerminalField terminalField)) {
continue;
}
FormFieldTypeSupport handler = FormFieldTypeSupport.forField(terminalField);
if (handler == null) {
log.debug(
"Skipping unsupported field type '{}' for widget '{}'",
sourceField.getClass().getSimpleName(),
Optional.ofNullable(sourceField.getFullyQualifiedName())
.orElseGet(sourceField::getPartialName));
continue;
}
copyFieldUsingHandler(
handler,
terminalField,
newAcroForm,
destinationPage,
destinationAnnotations,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
}
}
} catch (Exception e) {
log.warn(
"Failed to copy basic form fields for page {}: {}",
pageIndex,
e.getMessage(),
e);
}
}
private void copyFieldUsingHandler(
FormFieldTypeSupport handler,
PDTerminalField sourceField,
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDTerminalField newField = handler.createField(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newField,
sourceField.getPartialName(),
handler.fallbackWidgetName(),
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
handler.copyFromOriginal(sourceField, newField);
} catch (Exception e) {
log.warn(
"Failed to copy {} field '{}': {}",
handler.typeName(),
Optional.ofNullable(sourceField.getFullyQualifiedName())
.orElseGet(sourceField::getPartialName),
e.getMessage(),
e);
}
}
private <T extends PDTerminalField> boolean initializeFieldWithWidget(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
T newField,
String originalName,
String fallbackName,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
String baseName = (originalName != null) ? originalName : fallbackName;
String newFieldName = generateUniqueFieldName(baseName, pageIndex, fieldNameCounters);
newField.setPartialName(newFieldName);
PDAnnotationWidget newWidget = new PDAnnotationWidget();
PDRectangle sourceRect = sourceWidget.getRectangle();
if (sourceRect == null) {
return false;
}
float newX = (sourceRect.getLowerLeftX() * scale) + offsetX;
float newY = (sourceRect.getLowerLeftY() * scale) + offsetY;
float newWidth = sourceRect.getWidth() * scale;
float newHeight = sourceRect.getHeight() * scale;
newWidget.setRectangle(new PDRectangle(newX, newY, newWidth, newHeight));
newWidget.setPage(destinationPage);
newField.getWidgets().add(newWidget);
newWidget.setParent(newField);
newAcroForm.getFields().add(newField);
destinationAnnotations.add(newWidget);
return true;
}
private String generateUniqueFieldName(
String originalName, int pageIndex, Map<String, Integer> fieldNameCounters) {
String baseName = "page" + pageIndex + "_" + originalName;
Integer counter = fieldNameCounters.get(baseName);
if (counter == null) {
counter = 0;
} else {
counter++;
}
fieldNameCounters.put(baseName, counter);
return counter == 0 ? baseName : baseName + "_" + counter;
}
private Map<PDAnnotationWidget, PDField> buildWidgetFieldMap(PDAcroForm acroForm) {
Map<PDAnnotationWidget, PDField> map = new HashMap<>();
if (acroForm == null) {
return map;
}
try {
for (PDField field : acroForm.getFieldTree()) {
List<PDAnnotationWidget> widgets = field.getWidgets();
if (widgets == null) {
continue;
}
for (PDAnnotationWidget widget : widgets) {
if (widget != null) {
map.put(widget, field);
}
}
}
} catch (Exception e) {
log.warn("Failed to build widget->field map: {}", e.getMessage(), e);
}
return map;
}
}
@@ -1,368 +0,0 @@
package stirling.software.proprietary.util;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.graphics.color.PDColor;
import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDChoice;
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDListBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public enum FormFieldTypeSupport {
TEXT("text", "textField", PDTextField.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
PDTextField textField = new PDTextField(acroForm);
textField.setDefaultAppearance("/Helv 12 Tf 0 g");
return textField;
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDTextField src = (PDTextField) source;
PDTextField dst = (PDTextField) target;
String value = src.getValueAsString();
if (value != null) {
dst.setValue(value);
}
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
@Override
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
PDTextField textField = (PDTextField) field;
String defaultValue = Optional.ofNullable(definition.defaultValue()).orElse("");
if (!defaultValue.isBlank()) {
FormUtils.setTextValue(textField, defaultValue);
}
}
},
CHECKBOX("checkbox", "checkBox", PDCheckBox.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDCheckBox(acroForm);
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDCheckBox src = (PDCheckBox) source;
PDCheckBox dst = (PDCheckBox) target;
if (src.isChecked()) {
dst.check();
} else {
dst.unCheck();
}
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
@Override
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
PDCheckBox checkBox = (PDCheckBox) field;
if (!options.isEmpty()) {
checkBox.setExportValues(options);
}
ensureCheckBoxAppearance(checkBox);
if (FormUtils.isChecked(definition.defaultValue())) {
checkBox.check();
} else {
checkBox.unCheck();
}
}
private static void ensureCheckBoxAppearance(PDCheckBox checkBox) {
try {
if (checkBox.getWidgets().isEmpty()) {
return;
}
PDAnnotationWidget widget = checkBox.getWidgets().get(0);
PDAppearanceCharacteristicsDictionary appearanceChars =
widget.getAppearanceCharacteristics();
if (appearanceChars == null) {
appearanceChars =
new PDAppearanceCharacteristicsDictionary(widget.getCOSObject());
widget.setAppearanceCharacteristics(appearanceChars);
}
appearanceChars.setBorderColour(
new PDColor(new float[] {0, 0, 0}, PDDeviceRGB.INSTANCE));
appearanceChars.setBackground(
new PDColor(new float[] {1, 1, 1}, PDDeviceRGB.INSTANCE));
appearanceChars.setNormalCaption("4");
widget.setPrinted(true);
widget.setReadOnly(false);
widget.setHidden(false);
} catch (Exception e) {
log.debug("Unable to set checkbox appearance characteristics: {}", e.getMessage());
}
}
},
RADIO("radio", "radioButton", PDRadioButton.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDRadioButton(acroForm);
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDRadioButton src = (PDRadioButton) source;
PDRadioButton dst = (PDRadioButton) target;
if (src.getExportValues() != null) {
dst.setExportValues(src.getExportValues());
}
if (src.getValue() != null) {
dst.setValue(src.getValue());
}
}
},
COMBOBOX("combobox", "comboBox", PDComboBox.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDComboBox(acroForm);
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDComboBox src = (PDComboBox) source;
PDComboBox dst = (PDComboBox) target;
copyChoiceCharacteristics(src, dst);
if (src.getOptions() != null) {
dst.setOptions(src.getOptions());
}
if (src.getValue() != null && !src.getValue().isEmpty()) {
dst.setValue(src.getValue());
}
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
@Override
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
PDComboBox comboBox = (PDComboBox) field;
if (!options.isEmpty()) {
comboBox.setOptions(options);
}
List<String> allowedOptions = FormUtils.resolveOptions(comboBox);
String comboName =
Optional.ofNullable(comboBox.getFullyQualifiedName())
.orElseGet(comboBox::getPartialName);
String defaultValue = definition.defaultValue();
if (defaultValue != null && !defaultValue.isBlank()) {
String filtered =
FormUtils.filterSingleChoiceSelection(
defaultValue, allowedOptions, comboName);
if (filtered != null) {
comboBox.setValue(filtered);
}
}
}
},
LISTBOX("listbox", "listBox", PDListBox.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDListBox(acroForm);
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDListBox src = (PDListBox) source;
PDListBox dst = (PDListBox) target;
copyChoiceCharacteristics(src, dst);
if (src.getOptions() != null) {
dst.setOptions(src.getOptions());
}
if (src.getValue() != null && !src.getValue().isEmpty()) {
dst.setValue(src.getValue());
}
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
@Override
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
PDListBox listBox = (PDListBox) field;
listBox.setMultiSelect(Boolean.TRUE.equals(definition.multiSelect()));
if (!options.isEmpty()) {
listBox.setOptions(options);
}
List<String> allowedOptions = FormUtils.collectChoiceAllowedValues(listBox);
String listBoxName =
Optional.ofNullable(listBox.getFullyQualifiedName())
.orElseGet(listBox::getPartialName);
String defaultValue = definition.defaultValue();
if (defaultValue != null && !defaultValue.isBlank()) {
if (Boolean.TRUE.equals(definition.multiSelect())) {
List<String> selections = FormUtils.parseMultiChoiceSelections(defaultValue);
List<String> filtered =
FormUtils.filterChoiceSelections(
selections, allowedOptions, listBoxName);
if (!filtered.isEmpty()) {
listBox.setValue(filtered);
}
} else {
String filtered =
FormUtils.filterSingleChoiceSelection(
defaultValue, allowedOptions, listBoxName);
if (filtered != null) {
listBox.setValue(filtered);
}
}
}
}
},
SIGNATURE("signature", "signature", PDSignatureField.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDSignatureField(acroForm);
}
},
BUTTON("button", "pushButton", PDPushButton.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDPushButton(acroForm);
}
};
private static final Map<String, FormFieldTypeSupport> BY_TYPE =
Arrays.stream(values())
.collect(
Collectors.toUnmodifiableMap(
FormFieldTypeSupport::typeName, Function.identity()));
private final String typeName;
private final String fallbackWidgetName;
private final Class<? extends PDTerminalField> fieldClass;
FormFieldTypeSupport(
String typeName,
String fallbackWidgetName,
Class<? extends PDTerminalField> fieldClass) {
this.typeName = typeName;
this.fallbackWidgetName = fallbackWidgetName;
this.fieldClass = fieldClass;
}
public static FormFieldTypeSupport forField(PDField field) {
if (field == null) {
return null;
}
for (FormFieldTypeSupport handler : values()) {
if (handler.fieldClass.isInstance(field)) {
return handler;
}
}
return null;
}
public static FormFieldTypeSupport forTypeName(String typeName) {
if (typeName == null) {
return null;
}
return BY_TYPE.get(typeName);
}
private static void copyChoiceCharacteristics(PDChoice sourceField, PDChoice targetField) {
if (sourceField == null || targetField == null) {
return;
}
try {
int flags = sourceField.getCOSObject().getInt(COSName.FF);
targetField.getCOSObject().setInt(COSName.FF, flags);
} catch (Exception e) {
// ignore and continue
}
if (sourceField instanceof PDListBox sourceList
&& targetField instanceof PDListBox targetList) {
try {
targetList.setMultiSelect(sourceList.isMultiSelect());
} catch (Exception ignored) {
// ignore
}
}
}
String typeName() {
return typeName;
}
String fallbackWidgetName() {
return fallbackWidgetName;
}
abstract PDTerminalField createField(PDAcroForm acroForm);
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
// default no-op
}
boolean doesNotsupportsDefinitionCreation() {
return true;
}
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
// default no-op
}
}
File diff suppressed because it is too large Load Diff
@@ -29,8 +29,6 @@ class JwtAuthenticationEntryPointTest {
@Test
void testCommence() throws IOException {
String errorMessage = "Authentication failed";
when(request.getRequestURI()).thenReturn("/redact");
when(authException.getMessage()).thenReturn(errorMessage);
jwtAuthenticationEntryPoint.commence(request, response, authException);
@@ -1,114 +0,0 @@
package stirling.software.proprietary.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
@Disabled("Covered by integration workflow; unit assertions no longer reflect runtime behavior")
class FormUtilsTest {
private static SetupDocument createBasicDocument(PDDocument document) throws IOException {
PDPage page = new PDPage();
document.addPage(page);
PDAcroForm acroForm = new PDAcroForm(document);
acroForm.setDefaultResources(new PDResources());
acroForm.setNeedAppearances(true);
document.getDocumentCatalog().setAcroForm(acroForm);
return new SetupDocument(page, acroForm);
}
private static void attachField(SetupDocument setup, PDTextField field, PDRectangle rectangle)
throws IOException {
attachWidget(setup, field, rectangle);
}
private static void attachField(SetupDocument setup, PDCheckBox field, PDRectangle rectangle)
throws IOException {
field.setExportValues(List.of("Yes"));
attachWidget(setup, field, rectangle);
}
private static void attachWidget(
SetupDocument setup,
org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField field,
PDRectangle rectangle)
throws IOException {
PDAnnotationWidget widget = new PDAnnotationWidget();
widget.setRectangle(rectangle);
widget.setPage(setup.page);
List<PDAnnotationWidget> widgets = field.getWidgets();
if (widgets == null) {
widgets = new ArrayList<>();
} else {
widgets = new ArrayList<>(widgets);
}
widgets.add(widget);
field.setWidgets(widgets);
setup.acroForm.getFields().add(field);
setup.page.getAnnotations().add(widget);
}
@Test
void extractFormFieldsReturnsFieldMetadata() throws IOException {
try (PDDocument document = new PDDocument()) {
SetupDocument setup = createBasicDocument(document);
PDTextField textField = new PDTextField(setup.acroForm);
textField.setPartialName("firstName");
attachField(setup, textField, new PDRectangle(50, 700, 200, 20));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(document);
assertEquals(1, fields.size());
FormUtils.FormFieldInfo info = fields.get(0);
assertEquals("firstName", info.name());
assertEquals("text", info.type());
assertEquals(0, info.pageIndex());
assertEquals("", info.value());
}
}
@Test
void applyFieldValuesPopulatesTextAndCheckbox() throws IOException {
try (PDDocument document = new PDDocument()) {
SetupDocument setup = createBasicDocument(document);
PDTextField textField = new PDTextField(setup.acroForm);
textField.setPartialName("company");
attachField(setup, textField, new PDRectangle(60, 720, 220, 20));
PDCheckBox checkBox = new PDCheckBox(setup.acroForm);
checkBox.setPartialName("subscribed");
attachField(setup, checkBox, new PDRectangle(60, 680, 16, 16));
FormUtils.applyFieldValues(
document, Map.of("company", "Stirling", "subscribed", true), false);
assertEquals("Stirling", textField.getValueAsString());
assertTrue(checkBox.isChecked());
FormUtils.applyFieldValues(document, Map.of("subscribed", false), false);
assertFalse(checkBox.isChecked());
assertEquals("Off", checkBox.getValue());
}
}
private record SetupDocument(PDPage page, PDAcroForm acroForm) {}
}
+1 -1
View File
@@ -57,7 +57,7 @@ repositories {
allprojects {
group = 'stirling.software'
version = '2.0.0'
version = '1.4.0'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
+1 -1
View File
@@ -10,7 +10,7 @@ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend .
RUN DISABLE_ADDITIONAL_FEATURES=false npm run build
RUN npm run build
# Stage 2: Build Backend
FROM gradle:8.14-jdk21 AS backend-build
+1 -1
View File
@@ -7,4 +7,4 @@ VITE_API_BASE_URL=${VITE_API_BASE_URL:-"http://backend:8080"}
sed -i "s|\${VITE_API_BASE_URL}|${VITE_API_BASE_URL}|g" /etc/nginx/nginx.conf
# Start nginx
exec nginx -g "daemon off;"
exec nginx -g "daemon off;"
-3
View File
@@ -1,6 +1,3 @@
# Run nginx as non-root user
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
-56
View File
@@ -72,59 +72,3 @@ This section has moved here: [https://facebook.github.io/create-react-app/docs/d
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
## Tauri
In order to run Tauri, you first have to build the Java backend for Tauri to use.
**macOS/Linux:**
From the root of the repo, run:
```bash
./gradlew clean build
./scripts/build-tauri-jlink.sh
```
**Windows**
From the root of the repo, run:
```batch
gradlew clean build
scripts\build-tauri-jlink.bat
```
### Testing the Bundled Runtime
Before building the full Tauri app, you can test the bundled runtime:
**macOS/Linux:**
```bash
./frontend/src-tauri/runtime/launch-stirling.sh
```
**Windows:**
```cmd
frontend\src-tauri\runtime\launch-stirling.bat
```
This will start Stirling-PDF using the bundled JRE, accessible at http://localhost:8080
### Dev
To run Tauri in development. Use the command in the `frontend` folder:
```bash
npm run tauri-dev
```
This will run the gradle runboot command and the tauri dev command concurrently, starting the app once both are stable.
### Build
To build a deployment of the Tauri app. Use this command in the `frontend` folder:
```bash
npm run tauri-build
```
This will bundle the backend and frontend into one executable for each target. Targets can be set within the `tauri.conf.json` file.
-1
View File
@@ -21,7 +21,6 @@ export default defineConfig(
'dist',
'node_modules',
'public',
'src-tauri',
],
},
eslint.configs.recommended,
+1219 -1728
View File
File diff suppressed because it is too large Load Diff
+20 -28
View File
@@ -6,28 +6,25 @@
"proxy": "http://localhost:8080",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^1.4.1",
"@embedpdf/engines": "^1.4.1",
"@embedpdf/plugin-annotation": "^1.4.1",
"@embedpdf/plugin-export": "^1.4.1",
"@embedpdf/plugin-history": "^1.4.1",
"@embedpdf/plugin-interaction-manager": "^1.4.1",
"@embedpdf/plugin-loader": "^1.4.1",
"@embedpdf/plugin-pan": "^1.4.1",
"@embedpdf/plugin-render": "^1.4.1",
"@embedpdf/plugin-rotate": "^1.4.1",
"@embedpdf/plugin-scroll": "^1.4.1",
"@embedpdf/plugin-search": "^1.4.1",
"@embedpdf/plugin-selection": "^1.4.1",
"@embedpdf/plugin-spread": "^1.4.1",
"@embedpdf/plugin-thumbnail": "^1.4.1",
"@embedpdf/plugin-tiling": "^1.4.1",
"@embedpdf/plugin-viewport": "^1.4.1",
"@embedpdf/plugin-zoom": "^1.4.1",
"@embedpdf/core": "^1.3.14",
"@embedpdf/engines": "^1.3.14",
"@embedpdf/plugin-annotation": "^1.3.14",
"@embedpdf/plugin-export": "^1.3.14",
"@embedpdf/plugin-history": "^1.3.14",
"@embedpdf/plugin-interaction-manager": "^1.3.14",
"@embedpdf/plugin-loader": "^1.3.14",
"@embedpdf/plugin-pan": "^1.3.14",
"@embedpdf/plugin-render": "^1.3.14",
"@embedpdf/plugin-rotate": "^1.3.14",
"@embedpdf/plugin-scroll": "^1.3.14",
"@embedpdf/plugin-search": "^1.3.14",
"@embedpdf/plugin-selection": "^1.3.14",
"@embedpdf/plugin-spread": "^1.3.14",
"@embedpdf/plugin-thumbnail": "^1.3.14",
"@embedpdf/plugin-tiling": "^1.3.14",
"@embedpdf/plugin-viewport": "^1.3.14",
"@embedpdf/plugin-zoom": "^1.3.14",
"@emotion/react": "^11.14.0",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-fs": "^2.4.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
"@mantine/core": "^8.3.1",
@@ -65,14 +62,10 @@
"lint": "eslint --max-warnings=0",
"build": "vite build",
"preview": "vite preview",
"tauri-dev": "tauri dev --no-watch",
"tauri-build": "tauri build",
"tauri-clean": "cd src-tauri && cargo clean && cd .. && rm -rf dist build",
"typecheck": "npm run typecheck:proprietary",
"typecheck": "tsc --noEmit",
"typecheck:core": "tsc --noEmit --project tsconfig.core.json",
"typecheck:proprietary": "tsc --noEmit --project tsconfig.proprietary.json",
"typecheck:desktop": "tsc --noEmit --project tsconfig.desktop.json",
"typecheck:all": "npm run typecheck:core && npm run typecheck:proprietary && npm run typecheck:desktop",
"typecheck:all": "npm run typecheck:core && npm run typecheck:proprietary",
"check": "npm run typecheck && npm run lint && npm run test:run",
"generate-licenses": "node scripts/generate-licenses.js",
"generate-icons": "node scripts/generate-icons.js",
@@ -110,7 +103,6 @@
]
},
"devDependencies": {
"@tauri-apps/cli": "^2.5.0",
"@eslint/js": "^9.36.0",
"@iconify-json/material-symbols": "^1.2.37",
"@iconify/utils": "^3.0.2",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve"><defs id="defs173"><linearGradient id="XMLID_5_" x1="304.496" x2="316.036" y1="422.91" y2="326.263" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#dcf1f3" id="stop156"/><stop offset="1" style="stop-color:#c2c2c9" id="stop158"/></linearGradient></defs><style id="style150" type="text/css">.st1{fill:#c02223}.st2{fill:#882425}.st3{fill:url(#XMLID_5_)}.st4{fill:url(#XMLID_7_)}</style><g id="XMLID_4_"><path id="XMLID_131_" d="M 347.01402,14.355825 98.978019,69.02261 C 73.825483,74.547445 55.942464,96.792175 55.942464,122.52628 v 315.06096 c 0,22.39012 16.719895,41.14548 38.819234,43.76251 L 224.8861,498.36042 339.48636,384.26465 455.76603,265.15425 453.73057,84.870162 C 453.43979,62.916214 433.08513,46.632491 411.71274,51.284984 l -28.78729,6.251786 0.14539,-13.666697 C 383.36162,24.678542 365.62399,10.284894 347.01402,14.355825 Z" class="st1" style="stroke-width:1.45391"/><path id="XMLID_117_" d="m 383.21622,57.53677 v 285.8375 L 456.05681,265.00885 454.02135,78.763767 C 453.87595,59.863016 436.28372,45.905539 417.81914,49.97647 Z" class="st2" style="stroke-width:1.45391"/><polygon id="XMLID_18_" points="234.7 422.6 368.5 387.7 393.5 262.2" class="st3" style="fill:url(#XMLID_5_)" transform="matrix(1.4556308,0,0,1.4548265,-116.73161,-116.45231)"/><linearGradient id="XMLID_7_" x1="223.084" x2="241.417" y1="372.756" y2="114.557" gradientTransform="matrix(1.4539039,0,0,1.4539039,-116.19976,-116.20474)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#dcf1f3" id="stop163"/><stop offset="1" style="stop-color:#c2c2c9" id="stop165"/></linearGradient><path id="XMLID_6_" d="m 282.89686,214.84917 c 0,0 -22.24473,-28.93269 -38.67384,-36.78377 -10.46811,-4.94327 -26.02489,-6.83335 -38.23768,-0.72695 -18.02841,9.0142 -19.91848,34.31213 -3.34397,44.34406 3.92553,2.47165 9.15959,4.50711 15.99294,6.10641 36.63838,8.43264 97.12077,25.87949 89.70587,96.10304 0,0 -4.21633,65.86185 -73.56753,73.42215 -12.2128,1.30851 -24.57098,0.43617 -36.493,-2.32625 -16.42911,-3.63476 -45.50719,-11.04967 -59.75545,-19.91849 l -2.61703,-75.16682 h 6.97875 c 0,0 13.81208,33.43978 53.06749,49.57812 7.26952,2.90781 15.26599,4.07093 22.97168,2.90781 9.74116,-1.45391 21.22699,-6.68796 25.87949,-22.53551 0,0 7.85108,-23.11707 -32.85823,-35.76604 -32.56744,-10.17733 -63.24481,-20.64543 -75.89378,-54.95757 -5.961,-16.28371 -6.97874,-34.31212 -2.90781,-51.61358 5.37944,-22.53551 20.79082,-54.23062 64.40794,-67.89732 0,0 57.28381,-15.55677 96.53922,5.52484 l -1.74468,89.70587 z" class="st4" style="fill:url(#XMLID_7_);stroke-width:1.45391"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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