Merge branch 'V2' into feature/v2/viewer-improvements
1
.github/config/dependency-review-config.yml
vendored
Normal file
@ -0,0 +1 @@
|
||||
allow-ghsas: GHSA-wrw7-89jp-8q8g
|
||||
4
.github/workflows/PR-Auto-Deploy-V2.yml
vendored
@ -87,7 +87,7 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
else
|
||||
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96")
|
||||
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs")
|
||||
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
.github/workflows/dependency-review.yml
vendored
@ -25,3 +25,5 @@ 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'
|
||||
|
||||
334
.github/workflows/tauri-build.yml
vendored
Normal file
@ -0,0 +1,334 @@
|
||||
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/**'
|
||||
- 'frontend/package.json'
|
||||
- 'frontend/package-lock.json'
|
||||
- '.github/workflows/tauri-build.yml'
|
||||
push:
|
||||
branches: [main, V2]
|
||||
paths:
|
||||
- 'frontend/src-tauri/**'
|
||||
- 'frontend/src/**'
|
||||
- 'frontend/package.json'
|
||||
- 'frontend/package-lock.json'
|
||||
- '.github/workflows/tauri-build.yml'
|
||||
|
||||
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-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","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":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
# Disabled Mac builds: {"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}
|
||||
;;
|
||||
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":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
# Disabled Mac builds: {"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}
|
||||
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-latest' || matrix.platform == 'macos-13') && '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
|
||||
|
||||
# Disabled Mac builds - Import Apple Developer Certificate
|
||||
# - name: Import Apple Developer Certificate
|
||||
# if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13'
|
||||
# env:
|
||||
# APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
# APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
# APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
# APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
# KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
# run: |
|
||||
# echo "Importing Apple Developer Certificate..."
|
||||
# echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
|
||||
# security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
# security default-keychain -s build.keychain
|
||||
# security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
# security set-keychain-settings -t 3600 -u build.keychain
|
||||
# security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
||||
# security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
|
||||
# security find-identity -v -p codesigning build.keychain
|
||||
# - name: Verify Certificate
|
||||
# if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13'
|
||||
# run: |
|
||||
# echo "Verifying Apple Developer Certificate..."
|
||||
# CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application")
|
||||
# echo "Certificate Info: $CERT_INFO"
|
||||
# CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
|
||||
# echo "Certificate ID: $CERT_ID"
|
||||
# echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
|
||||
# echo "Certificate imported."
|
||||
|
||||
# - name: Check DMG creation dependencies (macOS only)
|
||||
# if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13'
|
||||
# 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.CERT_ID }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
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: 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" \;
|
||||
# Disabled Mac builds
|
||||
# elif [ "${{ matrix.platform }}" = "macos-latest" ] || [ "${{ matrix.platform }}" = "macos-13" ]; 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
|
||||
# Disabled Mac builds
|
||||
# elif [ "${{ matrix.platform }}" = "macos-latest" ] || [ "${{ matrix.platform }}" = "macos-13" ]; 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
|
||||
@ -32,6 +32,12 @@ 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)
|
||||
|
||||
@ -44,6 +50,8 @@ 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
|
||||
|
||||
@ -95,6 +103,10 @@ 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
|
||||
@ -109,6 +121,12 @@ 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
|
||||
@ -392,23 +410,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}
|
||||
/>
|
||||
|
||||
@ -368,6 +368,10 @@ public class ApplicationProperties {
|
||||
private TempFileManagement tempFileManagement = new TempFileManagement();
|
||||
private DatabaseBackup databaseBackup = new DatabaseBackup();
|
||||
private List<String> corsAllowedOrigins = new ArrayList<>();
|
||||
private String
|
||||
frontendUrl; // Base URL for frontend (used for invite links, etc.). If not set,
|
||||
|
||||
// falls back to backend URL.
|
||||
|
||||
public boolean isAnalyticsEnabled() {
|
||||
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
|
||||
@ -556,6 +560,7 @@ public class ApplicationProperties {
|
||||
public static class Mail {
|
||||
private boolean enabled;
|
||||
private boolean enableInvites = false;
|
||||
private int inviteLinkExpiryHours = 72; // Default: 72 hours (3 days)
|
||||
private String host;
|
||||
private int port;
|
||||
private String username;
|
||||
|
||||
@ -43,26 +43,45 @@ public class JarPathUtil {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to the restart-helper.jar file Expected to be in the same directory as the main
|
||||
* JAR
|
||||
* Gets the path to the restart-helper.jar file. Checks multiple possible locations: 1. Same
|
||||
* directory as the main JAR (production deployment) 2. ./build/libs/restart-helper.jar
|
||||
* (development build) 3. app/common/build/libs/restart-helper.jar (multi-module build)
|
||||
*
|
||||
* @return Path to restart-helper.jar, or null if not found
|
||||
*/
|
||||
public static Path restartHelperJar() {
|
||||
Path appJar = currentJar();
|
||||
if (appJar == null) {
|
||||
return null;
|
||||
|
||||
// Define possible locations to check (in order of preference)
|
||||
Path[] possibleLocations = new Path[4];
|
||||
|
||||
// Location 1: Same directory as main JAR (production)
|
||||
if (appJar != null) {
|
||||
possibleLocations[0] = appJar.getParent().resolve("restart-helper.jar");
|
||||
}
|
||||
|
||||
Path helperJar = appJar.getParent().resolve("restart-helper.jar");
|
||||
// Location 2: ./build/libs/ (development build)
|
||||
possibleLocations[1] = Paths.get("build", "libs", "restart-helper.jar").toAbsolutePath();
|
||||
|
||||
if (Files.isRegularFile(helperJar)) {
|
||||
log.debug("Restart helper JAR located at: {}", helperJar);
|
||||
return helperJar;
|
||||
} else {
|
||||
log.warn("Restart helper JAR not found at: {}", helperJar);
|
||||
return null;
|
||||
// Location 3: app/common/build/libs/ (multi-module build)
|
||||
possibleLocations[2] =
|
||||
Paths.get("app", "common", "build", "libs", "restart-helper.jar").toAbsolutePath();
|
||||
|
||||
// Location 4: Current working directory
|
||||
possibleLocations[3] = Paths.get("restart-helper.jar").toAbsolutePath();
|
||||
|
||||
// Check each location
|
||||
for (Path location : possibleLocations) {
|
||||
if (location != null && Files.isRegularFile(location)) {
|
||||
log.info("Restart helper JAR found at: {}", location);
|
||||
return location;
|
||||
} else if (location != null) {
|
||||
log.debug("Restart helper JAR not found at: {}", location);
|
||||
}
|
||||
}
|
||||
|
||||
log.warn("Restart helper JAR not found in any expected location");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -49,4 +49,35 @@ 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("/v1/api-docs")
|
||||
|| trimmedUri.startsWith("/api/v1/invite/validate")
|
||||
|| trimmedUri.startsWith("/api/v1/invite/accept")
|
||||
|| trimmedUri.contains("/v1/api-docs");
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import java.util.Arrays;
|
||||
import java.util.Deque;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
@ -135,6 +136,17 @@ public class YamlHelper {
|
||||
} else if ("true".equals(newValue) || "false".equals(newValue)) {
|
||||
newValueNode =
|
||||
new ScalarNode(Tag.BOOL, String.valueOf(newValue), ScalarStyle.PLAIN);
|
||||
} else if (newValue instanceof Map<?, ?> map) {
|
||||
// Handle Map objects - convert to MappingNode
|
||||
List<NodeTuple> mapTuples = new ArrayList<>();
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
ScalarNode mapKeyNode =
|
||||
new ScalarNode(
|
||||
Tag.STR, String.valueOf(entry.getKey()), ScalarStyle.PLAIN);
|
||||
Node mapValueNode = convertValueToNode(entry.getValue());
|
||||
mapTuples.add(new NodeTuple(mapKeyNode, mapValueNode));
|
||||
}
|
||||
newValueNode = new MappingNode(Tag.MAP, mapTuples, FlowStyle.BLOCK);
|
||||
} else if (newValue instanceof List<?> list) {
|
||||
List<Node> sequenceNodes = new ArrayList<>();
|
||||
for (Object item : list) {
|
||||
@ -458,6 +470,43 @@ public class YamlHelper {
|
||||
return isInteger(object) || isShort(object) || isByte(object) || isLong(object);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Java value to a YAML Node.
|
||||
*
|
||||
* @param value The value to convert.
|
||||
* @return The corresponding YAML Node.
|
||||
*/
|
||||
private Node convertValueToNode(Object value) {
|
||||
if (value == null) {
|
||||
return new ScalarNode(Tag.NULL, "null", ScalarStyle.PLAIN);
|
||||
} else if (isAnyInteger(value)) {
|
||||
return new ScalarNode(Tag.INT, String.valueOf(value), ScalarStyle.PLAIN);
|
||||
} else if (isFloat(value)) {
|
||||
Object floatValue = Float.valueOf(String.valueOf(value));
|
||||
return new ScalarNode(Tag.FLOAT, String.valueOf(floatValue), ScalarStyle.PLAIN);
|
||||
} else if (value instanceof Boolean || "true".equals(value) || "false".equals(value)) {
|
||||
return new ScalarNode(Tag.BOOL, String.valueOf(value), ScalarStyle.PLAIN);
|
||||
} else if (value instanceof Map<?, ?> map) {
|
||||
// Recursively handle nested maps
|
||||
List<NodeTuple> mapTuples = new ArrayList<>();
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
ScalarNode mapKeyNode =
|
||||
new ScalarNode(Tag.STR, String.valueOf(entry.getKey()), ScalarStyle.PLAIN);
|
||||
Node mapValueNode = convertValueToNode(entry.getValue());
|
||||
mapTuples.add(new NodeTuple(mapKeyNode, mapValueNode));
|
||||
}
|
||||
return new MappingNode(Tag.MAP, mapTuples, FlowStyle.BLOCK);
|
||||
} else if (value instanceof List<?> list) {
|
||||
List<Node> sequenceNodes = new ArrayList<>();
|
||||
for (Object item : list) {
|
||||
sequenceNodes.add(convertValueToNode(item));
|
||||
}
|
||||
return new SequenceNode(Tag.SEQ, sequenceNodes, FlowStyle.FLOW);
|
||||
} else {
|
||||
return new ScalarNode(Tag.STR, String.valueOf(value), ScalarStyle.PLAIN);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies comments from an old node to a new one.
|
||||
*
|
||||
|
||||
@ -41,7 +41,6 @@ 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,6 +5,7 @@ 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;
|
||||
@ -18,6 +19,7 @@ import stirling.software.common.util.misc.ReplaceAndInvertColorStrategy;
|
||||
public class ReplaceAndInvertColorFactory {
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
|
||||
public ReplaceAndInvertColorStrategy replaceAndInvert(
|
||||
MultipartFile file,
|
||||
@ -26,6 +28,13 @@ 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,6 +144,13 @@ 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"))) {
|
||||
|
||||
@ -403,8 +403,6 @@ public class EndpointConfiguration {
|
||||
/* Ghostscript */
|
||||
addEndpointToGroup("Ghostscript", "repair");
|
||||
addEndpointToGroup("Ghostscript", "compress-pdf");
|
||||
addEndpointToGroup("Ghostscript", "crop");
|
||||
addEndpointToGroup("Ghostscript", "replace-invert-pdf");
|
||||
|
||||
/* tesseract */
|
||||
addEndpointToGroup("tesseract", "ocr-pdf");
|
||||
|
||||
@ -0,0 +1,157 @@
|
||||
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,5 +1,7 @@
|
||||
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;
|
||||
@ -16,6 +18,8 @@ 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);
|
||||
@ -23,10 +27,34 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
// Only configure CORS if allowed origins are specified
|
||||
if (applicationProperties.getSystem() != null
|
||||
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
|
||||
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
|
||||
// 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", "DELETE", "OPTIONS", "PATCH")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
} else if (hasConfiguredOrigins) {
|
||||
// Use user-configured origins
|
||||
logger.info(
|
||||
"Configuring CORS with allowed origins: {}",
|
||||
applicationProperties.getSystem().getCorsAllowedOrigins());
|
||||
|
||||
String[] allowedOrigins =
|
||||
applicationProperties
|
||||
@ -41,15 +69,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
}
|
||||
// If no origins are configured, CORS is not enabled (secure by default)
|
||||
// If no origins are configured and not in Tauri mode, 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,6 +22,7 @@ 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;
|
||||
@ -37,6 +38,11 @@ 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
|
||||
@ -46,9 +52,13 @@ 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()) {
|
||||
if (request.isRemoveDataOutsideCrop() && isGhostscriptEnabled()) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,8 +62,10 @@ public class ConfigController {
|
||||
// Security settings
|
||||
configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin());
|
||||
|
||||
// Mail settings
|
||||
configData.put("enableEmailInvites", applicationProperties.getMail().isEnableInvites());
|
||||
// Mail settings - check both SMTP enabled AND invites enabled
|
||||
boolean smtpEnabled = applicationProperties.getMail().isEnabled();
|
||||
boolean invitesEnabled = applicationProperties.getMail().isEnableInvites();
|
||||
configData.put("enableEmailInvites", smtpEnabled && invitesEnabled);
|
||||
|
||||
// Check if user is admin using UserServiceInterface
|
||||
boolean isAdmin = false;
|
||||
|
||||
@ -128,6 +128,7 @@ system:
|
||||
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
|
||||
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
|
||||
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS.
|
||||
frontendUrl: '' # Base URL for frontend (e.g. 'https://pdf.example.com'). Used for generating invite links in emails. If empty, falls back to backend URL.
|
||||
serverCertificate:
|
||||
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
|
||||
organizationName: Stirling-PDF # Organization name for generated certificates
|
||||
|
||||
@ -1,17 +1,15 @@
|
||||
package stirling.software.proprietary.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||
|
||||
/** Configuration to explicitly enable JPA repositories and scheduling for the audit system. */
|
||||
/** Configuration to enable scheduling for the audit system. */
|
||||
@Configuration
|
||||
@EnableTransactionManagement
|
||||
@EnableJpaRepositories(basePackages = "stirling.software.proprietary.repository")
|
||||
@EnableScheduling
|
||||
public class AuditJpaConfig {
|
||||
// This configuration enables JPA repositories in the specified package
|
||||
// and enables scheduling for audit cleanup tasks
|
||||
// This configuration enables scheduling for audit cleanup tasks
|
||||
// JPA repositories are now managed by DatabaseConfig to avoid conflicts
|
||||
// No additional beans or methods needed
|
||||
}
|
||||
|
||||
@ -0,0 +1,434 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.model.security.PersistentAuditEvent;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
|
||||
|
||||
/** REST API controller for audit data used by React frontend. */
|
||||
@Slf4j
|
||||
@ProprietaryUiDataApi
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequiredArgsConstructor
|
||||
@EnterpriseEndpoint
|
||||
public class AuditRestController {
|
||||
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Get audit events with pagination and filters. Maps to frontend's getEvents() call.
|
||||
*
|
||||
* @param page Page number (0-indexed)
|
||||
* @param pageSize Number of items per page
|
||||
* @param eventType Filter by event type
|
||||
* @param username Filter by username (principal)
|
||||
* @param startDate Filter start date
|
||||
* @param endDate Filter end date
|
||||
* @return Paginated audit events response
|
||||
*/
|
||||
@GetMapping("/audit-events")
|
||||
public ResponseEntity<AuditEventsResponse> getAuditEvents(
|
||||
@RequestParam(value = "page", defaultValue = "0") int page,
|
||||
@RequestParam(value = "pageSize", defaultValue = "30") int pageSize,
|
||||
@RequestParam(value = "eventType", required = false) String eventType,
|
||||
@RequestParam(value = "username", required = false) String username,
|
||||
@RequestParam(value = "startDate", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate startDate,
|
||||
@RequestParam(value = "endDate", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate endDate) {
|
||||
|
||||
Pageable pageable = PageRequest.of(page, pageSize, Sort.by("timestamp").descending());
|
||||
Page<PersistentAuditEvent> events;
|
||||
|
||||
// Apply filters based on provided parameters
|
||||
if (eventType != null && username != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events =
|
||||
auditRepository.findByPrincipalAndTypeAndTimestampBetween(
|
||||
username, eventType, start, end, pageable);
|
||||
} else if (eventType != null && username != null) {
|
||||
events = auditRepository.findByPrincipalAndType(username, eventType, pageable);
|
||||
} else if (eventType != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events = auditRepository.findByTypeAndTimestampBetween(eventType, start, end, pageable);
|
||||
} else if (username != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events =
|
||||
auditRepository.findByPrincipalAndTimestampBetween(
|
||||
username, start, end, pageable);
|
||||
} else if (startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events = auditRepository.findByTimestampBetween(start, end, pageable);
|
||||
} else if (eventType != null) {
|
||||
events = auditRepository.findByType(eventType, pageable);
|
||||
} else if (username != null) {
|
||||
events = auditRepository.findByPrincipal(username, pageable);
|
||||
} else {
|
||||
events = auditRepository.findAll(pageable);
|
||||
}
|
||||
|
||||
// Convert to response format expected by frontend
|
||||
List<AuditEventDto> eventDtos =
|
||||
events.getContent().stream().map(this::convertToDto).collect(Collectors.toList());
|
||||
|
||||
AuditEventsResponse response =
|
||||
AuditEventsResponse.builder()
|
||||
.events(eventDtos)
|
||||
.totalEvents((int) events.getTotalElements())
|
||||
.page(events.getNumber())
|
||||
.pageSize(events.getSize())
|
||||
.totalPages(events.getTotalPages())
|
||||
.build();
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chart data for dashboard. Maps to frontend's getChartsData() call.
|
||||
*
|
||||
* @param period Time period for charts (day/week/month)
|
||||
* @return Chart data for events by type, user, and over time
|
||||
*/
|
||||
@GetMapping("/audit-charts")
|
||||
public ResponseEntity<AuditChartsData> getAuditCharts(
|
||||
@RequestParam(value = "period", defaultValue = "week") String period) {
|
||||
|
||||
// Calculate days based on period
|
||||
int days;
|
||||
switch (period.toLowerCase()) {
|
||||
case "day":
|
||||
days = 1;
|
||||
break;
|
||||
case "month":
|
||||
days = 30;
|
||||
break;
|
||||
case "week":
|
||||
default:
|
||||
days = 7;
|
||||
break;
|
||||
}
|
||||
|
||||
// Get events from the specified period
|
||||
Instant startDate = Instant.now().minus(java.time.Duration.ofDays(days));
|
||||
List<PersistentAuditEvent> events = auditRepository.findByTimestampAfter(startDate);
|
||||
|
||||
// Count events by type
|
||||
Map<String, Long> eventsByType =
|
||||
events.stream()
|
||||
.collect(
|
||||
Collectors.groupingBy(
|
||||
PersistentAuditEvent::getType, Collectors.counting()));
|
||||
|
||||
// Count events by principal (user)
|
||||
Map<String, Long> eventsByUser =
|
||||
events.stream()
|
||||
.collect(
|
||||
Collectors.groupingBy(
|
||||
PersistentAuditEvent::getPrincipal, Collectors.counting()));
|
||||
|
||||
// Count events by day
|
||||
Map<String, Long> eventsByDay =
|
||||
events.stream()
|
||||
.collect(
|
||||
Collectors.groupingBy(
|
||||
e ->
|
||||
LocalDateTime.ofInstant(
|
||||
e.getTimestamp(),
|
||||
ZoneId.systemDefault())
|
||||
.format(DateTimeFormatter.ISO_LOCAL_DATE),
|
||||
Collectors.counting()));
|
||||
|
||||
// Convert to ChartData format
|
||||
ChartData eventsByTypeChart =
|
||||
ChartData.builder()
|
||||
.labels(new ArrayList<>(eventsByType.keySet()))
|
||||
.values(
|
||||
eventsByType.values().stream()
|
||||
.map(Long::intValue)
|
||||
.collect(Collectors.toList()))
|
||||
.build();
|
||||
|
||||
ChartData eventsByUserChart =
|
||||
ChartData.builder()
|
||||
.labels(new ArrayList<>(eventsByUser.keySet()))
|
||||
.values(
|
||||
eventsByUser.values().stream()
|
||||
.map(Long::intValue)
|
||||
.collect(Collectors.toList()))
|
||||
.build();
|
||||
|
||||
// Sort events by day for time series
|
||||
TreeMap<String, Long> sortedEventsByDay = new TreeMap<>(eventsByDay);
|
||||
ChartData eventsOverTimeChart =
|
||||
ChartData.builder()
|
||||
.labels(new ArrayList<>(sortedEventsByDay.keySet()))
|
||||
.values(
|
||||
sortedEventsByDay.values().stream()
|
||||
.map(Long::intValue)
|
||||
.collect(Collectors.toList()))
|
||||
.build();
|
||||
|
||||
AuditChartsData chartsData =
|
||||
AuditChartsData.builder()
|
||||
.eventsByType(eventsByTypeChart)
|
||||
.eventsByUser(eventsByUserChart)
|
||||
.eventsOverTime(eventsOverTimeChart)
|
||||
.build();
|
||||
|
||||
return ResponseEntity.ok(chartsData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available event types for filtering. Maps to frontend's getEventTypes() call.
|
||||
*
|
||||
* @return List of unique event types
|
||||
*/
|
||||
@GetMapping("/audit-event-types")
|
||||
public ResponseEntity<List<String>> getEventTypes() {
|
||||
// Get distinct event types from the database
|
||||
List<String> dbTypes = auditRepository.findDistinctEventTypes();
|
||||
|
||||
// Include standard enum types in case they're not in the database yet
|
||||
List<String> enumTypes =
|
||||
Arrays.stream(AuditEventType.values())
|
||||
.map(AuditEventType::name)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// Combine both sources, remove duplicates, and sort
|
||||
Set<String> combinedTypes = new HashSet<>();
|
||||
combinedTypes.addAll(dbTypes);
|
||||
combinedTypes.addAll(enumTypes);
|
||||
|
||||
List<String> result = combinedTypes.stream().sorted().collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of users for filtering. Maps to frontend's getUsers() call.
|
||||
*
|
||||
* @return List of unique usernames
|
||||
*/
|
||||
@GetMapping("/audit-users")
|
||||
public ResponseEntity<List<String>> getUsers() {
|
||||
// Use the countByPrincipal query to get unique principals
|
||||
List<Object[]> principalCounts = auditRepository.countByPrincipal();
|
||||
|
||||
List<String> users =
|
||||
principalCounts.stream()
|
||||
.map(arr -> (String) arr[0])
|
||||
.sorted()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(users);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export audit data in CSV or JSON format. Maps to frontend's exportData() call.
|
||||
*
|
||||
* @param format Export format (csv or json)
|
||||
* @param eventType Filter by event type
|
||||
* @param username Filter by username
|
||||
* @param startDate Filter start date
|
||||
* @param endDate Filter end date
|
||||
* @return File download response
|
||||
*/
|
||||
@GetMapping("/audit-export")
|
||||
public ResponseEntity<byte[]> exportAuditData(
|
||||
@RequestParam(value = "format", defaultValue = "csv") String format,
|
||||
@RequestParam(value = "eventType", required = false) String eventType,
|
||||
@RequestParam(value = "username", required = false) String username,
|
||||
@RequestParam(value = "startDate", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate startDate,
|
||||
@RequestParam(value = "endDate", required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate endDate) {
|
||||
|
||||
// Get data with same filtering as getAuditEvents
|
||||
List<PersistentAuditEvent> events;
|
||||
|
||||
if (eventType != null && username != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events =
|
||||
auditRepository.findAllByPrincipalAndTypeAndTimestampBetweenForExport(
|
||||
username, eventType, start, end);
|
||||
} else if (eventType != null && username != null) {
|
||||
events = auditRepository.findAllByPrincipalAndTypeForExport(username, eventType);
|
||||
} else if (eventType != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events =
|
||||
auditRepository.findAllByTypeAndTimestampBetweenForExport(
|
||||
eventType, start, end);
|
||||
} else if (username != null && startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events =
|
||||
auditRepository.findAllByPrincipalAndTimestampBetweenForExport(
|
||||
username, start, end);
|
||||
} else if (startDate != null && endDate != null) {
|
||||
Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
events = auditRepository.findAllByTimestampBetweenForExport(start, end);
|
||||
} else if (eventType != null) {
|
||||
events = auditRepository.findByTypeForExport(eventType);
|
||||
} else if (username != null) {
|
||||
events = auditRepository.findAllByPrincipalForExport(username);
|
||||
} else {
|
||||
events = auditRepository.findAll();
|
||||
}
|
||||
|
||||
// Export based on format
|
||||
if ("json".equalsIgnoreCase(format)) {
|
||||
return exportAsJson(events);
|
||||
} else {
|
||||
return exportAsCsv(events);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private AuditEventDto convertToDto(PersistentAuditEvent event) {
|
||||
// Parse the JSON data field if present
|
||||
Map<String, Object> details = new HashMap<>();
|
||||
if (event.getData() != null && !event.getData().isEmpty()) {
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> parsed = objectMapper.readValue(event.getData(), Map.class);
|
||||
details = parsed;
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("Failed to parse audit event data as JSON: {}", event.getData());
|
||||
details.put("rawData", event.getData());
|
||||
}
|
||||
}
|
||||
|
||||
return AuditEventDto.builder()
|
||||
.id(String.valueOf(event.getId()))
|
||||
.timestamp(event.getTimestamp().toString())
|
||||
.eventType(event.getType())
|
||||
.username(event.getPrincipal())
|
||||
.ipAddress((String) details.getOrDefault("ipAddress", "")) // Extract if available
|
||||
.details(details)
|
||||
.build();
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> exportAsCsv(List<PersistentAuditEvent> events) {
|
||||
StringBuilder csv = new StringBuilder();
|
||||
csv.append("ID,Principal,Type,Timestamp,Data\n");
|
||||
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ISO_INSTANT;
|
||||
|
||||
for (PersistentAuditEvent event : events) {
|
||||
csv.append(event.getId()).append(",");
|
||||
csv.append(escapeCSV(event.getPrincipal())).append(",");
|
||||
csv.append(escapeCSV(event.getType())).append(",");
|
||||
csv.append(formatter.format(event.getTimestamp())).append(",");
|
||||
csv.append(escapeCSV(event.getData())).append("\n");
|
||||
}
|
||||
|
||||
byte[] csvBytes = csv.toString().getBytes();
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
|
||||
headers.setContentDispositionFormData("attachment", "audit_export.csv");
|
||||
|
||||
return ResponseEntity.ok().headers(headers).body(csvBytes);
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> exportAsJson(List<PersistentAuditEvent> events) {
|
||||
try {
|
||||
byte[] jsonBytes = objectMapper.writeValueAsBytes(events);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.setContentDispositionFormData("attachment", "audit_export.json");
|
||||
|
||||
return ResponseEntity.ok().headers(headers).body(jsonBytes);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("Error serializing audit events to JSON", e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
||||
private String escapeCSV(String field) {
|
||||
if (field == null) {
|
||||
return "";
|
||||
}
|
||||
// Replace double quotes with two double quotes and wrap in quotes
|
||||
return "\"" + field.replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
|
||||
// DTOs for response formatting
|
||||
|
||||
@lombok.Data
|
||||
@lombok.Builder
|
||||
public static class AuditEventsResponse {
|
||||
private List<AuditEventDto> events;
|
||||
private int totalEvents;
|
||||
private int page;
|
||||
private int pageSize;
|
||||
private int totalPages;
|
||||
}
|
||||
|
||||
@lombok.Data
|
||||
@lombok.Builder
|
||||
public static class AuditEventDto {
|
||||
private String id;
|
||||
private String timestamp;
|
||||
private String eventType;
|
||||
private String username;
|
||||
private String ipAddress;
|
||||
private Map<String, Object> details;
|
||||
}
|
||||
|
||||
@lombok.Data
|
||||
@lombok.Builder
|
||||
public static class AuditChartsData {
|
||||
private ChartData eventsByType;
|
||||
private ChartData eventsByUser;
|
||||
private ChartData eventsOverTime;
|
||||
}
|
||||
|
||||
@lombok.Data
|
||||
@lombok.Builder
|
||||
public static class ChartData {
|
||||
private List<String> labels;
|
||||
private List<Integer> values;
|
||||
}
|
||||
}
|
||||
@ -39,6 +39,7 @@ import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.model.dto.TeamWithUserCountDTO;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
|
||||
import stirling.software.proprietary.security.database.repository.SessionRepository;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
@ -50,6 +51,7 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
|
||||
import stirling.software.proprietary.security.service.DatabaseService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@Slf4j
|
||||
@ProprietaryUiDataApi
|
||||
@ -64,6 +66,8 @@ public class ProprietaryUIDataController {
|
||||
private final DatabaseService databaseService;
|
||||
private final boolean runningEE;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final UserLicenseSettingsService licenseSettingsService;
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
|
||||
public ProprietaryUIDataController(
|
||||
ApplicationProperties applicationProperties,
|
||||
@ -74,7 +78,9 @@ public class ProprietaryUIDataController {
|
||||
SessionRepository sessionRepository,
|
||||
DatabaseService databaseService,
|
||||
ObjectMapper objectMapper,
|
||||
@Qualifier("runningEE") boolean runningEE) {
|
||||
@Qualifier("runningEE") boolean runningEE,
|
||||
UserLicenseSettingsService licenseSettingsService,
|
||||
PersistentAuditEventRepository auditRepository) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.auditConfig = auditConfig;
|
||||
this.sessionPersistentRegistry = sessionPersistentRegistry;
|
||||
@ -84,6 +90,8 @@ public class ProprietaryUIDataController {
|
||||
this.databaseService = databaseService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.runningEE = runningEE;
|
||||
this.licenseSettingsService = licenseSettingsService;
|
||||
this.auditRepository = auditRepository;
|
||||
}
|
||||
|
||||
@GetMapping("/audit-dashboard")
|
||||
@ -262,6 +270,13 @@ public class ProprietaryUIDataController {
|
||||
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME))
|
||||
.toList();
|
||||
|
||||
// Calculate license limits
|
||||
int maxAllowedUsers = licenseSettingsService.calculateMaxAllowedUsers();
|
||||
long availableSlots = licenseSettingsService.getAvailableUserSlots();
|
||||
int grandfatheredCount = licenseSettingsService.getDisplayGrandfatheredCount();
|
||||
int licenseMaxUsers = licenseSettingsService.getSettings().getLicenseMaxUsers();
|
||||
boolean premiumEnabled = applicationProperties.getPremium().isEnabled();
|
||||
|
||||
AdminSettingsData data = new AdminSettingsData();
|
||||
data.setUsers(sortedUsers);
|
||||
data.setCurrentUsername(authentication.getName());
|
||||
@ -273,6 +288,11 @@ public class ProprietaryUIDataController {
|
||||
data.setDisabledUsers(disabledUsers);
|
||||
data.setTeams(allTeams);
|
||||
data.setMaxPaidUsers(applicationProperties.getPremium().getMaxUsers());
|
||||
data.setMaxAllowedUsers(maxAllowedUsers);
|
||||
data.setAvailableSlots(availableSlots);
|
||||
data.setGrandfatheredUserCount(grandfatheredCount);
|
||||
data.setLicenseMaxUsers(licenseMaxUsers);
|
||||
data.setPremiumEnabled(premiumEnabled);
|
||||
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
@ -445,6 +465,11 @@ public class ProprietaryUIDataController {
|
||||
private int disabledUsers;
|
||||
private List<Team> teams;
|
||||
private int maxPaidUsers;
|
||||
private int maxAllowedUsers;
|
||||
private long availableSlots;
|
||||
private int grandfatheredUserCount;
|
||||
private int licenseMaxUsers;
|
||||
private boolean premiumEnabled;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@ -0,0 +1,236 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.model.security.PersistentAuditEvent;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
|
||||
|
||||
/** REST API controller for usage analytics data used by React frontend. */
|
||||
@Slf4j
|
||||
@ProprietaryUiDataApi
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequiredArgsConstructor
|
||||
@EnterpriseEndpoint
|
||||
public class UsageRestController {
|
||||
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Get endpoint statistics derived from audit events. This endpoint analyzes HTTP_REQUEST audit
|
||||
* events to generate usage statistics.
|
||||
*
|
||||
* @param limit Optional limit on number of endpoints to return
|
||||
* @param dataType Type of data to include: "all" (default), "api" (API endpoints excluding
|
||||
* auth), or "ui" (non-API endpoints)
|
||||
* @return Endpoint statistics response
|
||||
*/
|
||||
@GetMapping("/usage-endpoint-statistics")
|
||||
public ResponseEntity<EndpointStatisticsResponse> getEndpointStatistics(
|
||||
@RequestParam(value = "limit", required = false) Integer limit,
|
||||
@RequestParam(value = "dataType", defaultValue = "all") String dataType) {
|
||||
|
||||
// Get all HTTP_REQUEST audit events
|
||||
List<PersistentAuditEvent> httpEvents =
|
||||
auditRepository.findByTypeForExport(AuditEventType.HTTP_REQUEST.name());
|
||||
|
||||
// Count visits per endpoint
|
||||
Map<String, Long> endpointCounts = new HashMap<>();
|
||||
|
||||
for (PersistentAuditEvent event : httpEvents) {
|
||||
String endpoint = extractEndpointFromAuditData(event.getData());
|
||||
if (endpoint != null) {
|
||||
// Apply data type filter
|
||||
if (!shouldIncludeEndpoint(endpoint, dataType)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
endpointCounts.merge(endpoint, 1L, Long::sum);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
long totalVisits = endpointCounts.values().stream().mapToLong(Long::longValue).sum();
|
||||
int totalEndpoints = endpointCounts.size();
|
||||
|
||||
// Convert to list and sort by visit count (descending)
|
||||
List<EndpointStatistic> statistics =
|
||||
endpointCounts.entrySet().stream()
|
||||
.map(
|
||||
entry -> {
|
||||
String endpoint = entry.getKey();
|
||||
long visits = entry.getValue();
|
||||
double percentage =
|
||||
totalVisits > 0 ? (visits * 100.0 / totalVisits) : 0.0;
|
||||
|
||||
return EndpointStatistic.builder()
|
||||
.endpoint(endpoint)
|
||||
.visits((int) visits)
|
||||
.percentage(Math.round(percentage * 10.0) / 10.0)
|
||||
.build();
|
||||
})
|
||||
.sorted(Comparator.comparingInt(EndpointStatistic::getVisits).reversed())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// Apply limit if specified
|
||||
if (limit != null && limit > 0 && statistics.size() > limit) {
|
||||
statistics = statistics.subList(0, limit);
|
||||
}
|
||||
|
||||
EndpointStatisticsResponse response =
|
||||
EndpointStatisticsResponse.builder()
|
||||
.endpoints(statistics)
|
||||
.totalEndpoints(totalEndpoints)
|
||||
.totalVisits((int) totalVisits)
|
||||
.build();
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the endpoint path from the audit event's data field. The data field contains JSON
|
||||
* with an "endpoint" or "path" key.
|
||||
*
|
||||
* @param dataJson JSON string from audit event
|
||||
* @return Endpoint path or null if not found
|
||||
*/
|
||||
private String extractEndpointFromAuditData(String dataJson) {
|
||||
if (dataJson == null || dataJson.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> data = objectMapper.readValue(dataJson, Map.class);
|
||||
|
||||
// Try common keys for endpoint path
|
||||
Object endpoint = data.get("endpoint");
|
||||
if (endpoint != null) {
|
||||
return normalizeEndpoint(endpoint.toString());
|
||||
}
|
||||
|
||||
Object path = data.get("path");
|
||||
if (path != null) {
|
||||
return normalizeEndpoint(path.toString());
|
||||
}
|
||||
|
||||
// Fallback: check if there's a request-related key
|
||||
Object requestUri = data.get("requestUri");
|
||||
if (requestUri != null) {
|
||||
return normalizeEndpoint(requestUri.toString());
|
||||
}
|
||||
|
||||
} catch (JsonProcessingException e) {
|
||||
log.debug("Failed to parse audit data JSON: {}", dataJson, e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize endpoint paths by removing query strings and standardizing format.
|
||||
*
|
||||
* @param endpoint Raw endpoint path
|
||||
* @return Normalized endpoint path
|
||||
*/
|
||||
private String normalizeEndpoint(String endpoint) {
|
||||
if (endpoint == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove query string
|
||||
int queryIndex = endpoint.indexOf('?');
|
||||
if (queryIndex != -1) {
|
||||
endpoint = endpoint.substring(0, queryIndex);
|
||||
}
|
||||
|
||||
// Ensure it starts with /
|
||||
if (!endpoint.startsWith("/")) {
|
||||
endpoint = "/" + endpoint;
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an endpoint should be included based on the data type filter.
|
||||
*
|
||||
* @param endpoint The endpoint path to check
|
||||
* @param dataType The filter type: "all", "api", or "ui"
|
||||
* @return true if the endpoint should be included, false otherwise
|
||||
*/
|
||||
private boolean shouldIncludeEndpoint(String endpoint, String dataType) {
|
||||
if ("all".equalsIgnoreCase(dataType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
boolean isApiEndpoint = isApiEndpoint(endpoint);
|
||||
|
||||
if ("api".equalsIgnoreCase(dataType)) {
|
||||
return isApiEndpoint;
|
||||
} else if ("ui".equalsIgnoreCase(dataType)) {
|
||||
return !isApiEndpoint;
|
||||
}
|
||||
|
||||
// Default to including all if unrecognized type
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an endpoint is an API endpoint. API endpoints match /api/v1/* pattern but exclude
|
||||
* /api/v1/auth/* paths.
|
||||
*
|
||||
* @param endpoint The endpoint path to check
|
||||
* @return true if this is an API endpoint (excluding auth endpoints), false otherwise
|
||||
*/
|
||||
private boolean isApiEndpoint(String endpoint) {
|
||||
if (endpoint == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it starts with /api/v1/
|
||||
if (!endpoint.startsWith("/api/v1/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude auth endpoints
|
||||
if (endpoint.startsWith("/api/v1/auth/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// DTOs for response formatting
|
||||
|
||||
@lombok.Data
|
||||
@lombok.Builder
|
||||
public static class EndpointStatisticsResponse {
|
||||
private List<EndpointStatistic> endpoints;
|
||||
private int totalEndpoints;
|
||||
private int totalVisits;
|
||||
}
|
||||
|
||||
@lombok.Data
|
||||
@lombok.Builder
|
||||
public static class EndpointStatistic {
|
||||
private String endpoint;
|
||||
private int visits;
|
||||
private double percentage;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
package stirling.software.proprietary.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* Entity to store user license settings in the database. This is a singleton entity (only one row
|
||||
* should exist). Tracks grandfathered user counts and license limits.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "user_license_settings")
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
public class UserLicenseSettings implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public static final Long SINGLETON_ID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private Long id = SINGLETON_ID;
|
||||
|
||||
/**
|
||||
* The number of users that existed in the database when grandfathering was initialized. This
|
||||
* value is set once during initial setup and should NEVER be modified afterwards.
|
||||
*/
|
||||
@Column(name = "grandfathered_user_count", nullable = false)
|
||||
private int grandfatheredUserCount = 0;
|
||||
|
||||
/**
|
||||
* Flag to indicate that grandfathering has been initialized and locked. Once true, the
|
||||
* grandfatheredUserCount should never change. This prevents manipulation by deleting/recreating
|
||||
* the table.
|
||||
*/
|
||||
@Column(name = "grandfathering_locked", nullable = false)
|
||||
private boolean grandfatheringLocked = false;
|
||||
|
||||
/**
|
||||
* Maximum number of users allowed by the current license. This is updated when the license key
|
||||
* is validated.
|
||||
*/
|
||||
@Column(name = "license_max_users", nullable = false)
|
||||
private int licenseMaxUsers = 0;
|
||||
|
||||
/**
|
||||
* Random salt used when generating signatures. Makes it harder to recompute the signature when
|
||||
* manually editing the table.
|
||||
*/
|
||||
@Column(name = "integrity_salt", nullable = false, length = 64)
|
||||
private String integritySalt = "";
|
||||
|
||||
/**
|
||||
* Signed representation of {@code grandfatheredUserCount}. Stores the original value alongside
|
||||
* a secret-backed HMAC so we can detect tampering and restore the correct count.
|
||||
*/
|
||||
@Column(name = "grandfathered_user_signature", nullable = false, length = 256)
|
||||
private String grandfatheredUserSignature = "";
|
||||
}
|
||||
@ -21,6 +21,7 @@ import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@ -34,6 +35,7 @@ public class InitialSecuritySetup {
|
||||
private final TeamService teamService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final DatabaseServiceInterface databaseService;
|
||||
private final UserLicenseSettingsService licenseSettingsService;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
@ -50,12 +52,18 @@ public class InitialSecuritySetup {
|
||||
configureJWTSettings();
|
||||
assignUsersToDefaultTeamIfMissing();
|
||||
initializeInternalApiUser();
|
||||
initializeUserLicenseSettings();
|
||||
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
|
||||
log.error("Failed to initialize security setup.", e);
|
||||
System.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeUserLicenseSettings() {
|
||||
licenseSettingsService.initializeGrandfatheredCount();
|
||||
licenseSettingsService.updateLicenseMaxUsers();
|
||||
}
|
||||
|
||||
private void configureJWTSettings() {
|
||||
ApplicationProperties.Security.Jwt jwtProperties =
|
||||
applicationProperties.getSecurity().getJwt();
|
||||
|
||||
@ -17,6 +17,20 @@ public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||
HttpServletResponse response,
|
||||
AuthenticationException authException)
|
||||
throws IOException {
|
||||
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,7 +25,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
@EnableJpaRepositories(
|
||||
basePackages = {
|
||||
"stirling.software.proprietary.security.database.repository",
|
||||
"stirling.software.proprietary.security.repository"
|
||||
"stirling.software.proprietary.security.repository",
|
||||
"stirling.software.proprietary.repository"
|
||||
})
|
||||
@EntityScan({"stirling.software.proprietary.security.model", "stirling.software.proprietary.model"})
|
||||
public class DatabaseConfig {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.security.configuration;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@ -28,11 +29,15 @@ 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;
|
||||
@ -67,6 +72,7 @@ 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;
|
||||
@ -86,6 +92,7 @@ public class SecurityConfiguration {
|
||||
@Qualifier("loginEnabled") boolean loginEnabledValue,
|
||||
@Qualifier("runningProOrHigher") boolean runningProOrHigher,
|
||||
AppConfig appConfig,
|
||||
ApplicationProperties applicationProperties,
|
||||
ApplicationProperties.Security securityProperties,
|
||||
UserAuthenticationFilter userAuthenticationFilter,
|
||||
JwtServiceInterface jwtService,
|
||||
@ -102,6 +109,7 @@ public class SecurityConfiguration {
|
||||
this.loginEnabledValue = loginEnabledValue;
|
||||
this.runningProOrHigher = runningProOrHigher;
|
||||
this.appConfig = appConfig;
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.securityProperties = securityProperties;
|
||||
this.userAuthenticationFilter = userAuthenticationFilter;
|
||||
this.jwtService = jwtService;
|
||||
@ -120,7 +128,79 @@ public class SecurityConfiguration {
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
// Read CORS allowed origins from settings
|
||||
if (applicationProperties.getSystem() != null
|
||||
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
|
||||
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
|
||||
|
||||
List<String> allowedOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
|
||||
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
|
||||
// Use setAllowedOriginPatterns for better wildcard and port support
|
||||
cfg.setAllowedOriginPatterns(allowedOrigins);
|
||||
log.debug(
|
||||
"CORS configured with allowed origin patterns from settings.yml: {}",
|
||||
allowedOrigins);
|
||||
|
||||
// Set allowed methods explicitly (including OPTIONS for preflight)
|
||||
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||
|
||||
// Set allowed headers explicitly
|
||||
cfg.setAllowedHeaders(
|
||||
List.of(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-API-KEY",
|
||||
"X-CSRF-TOKEN"));
|
||||
|
||||
// Set exposed headers (headers that the browser can access)
|
||||
cfg.setExposedHeaders(
|
||||
List.of(
|
||||
"WWW-Authenticate",
|
||||
"X-Total-Count",
|
||||
"X-Page-Number",
|
||||
"X-Page-Size",
|
||||
"Content-Disposition",
|
||||
"Content-Type"));
|
||||
|
||||
// Allow credentials (cookies, authorization headers)
|
||||
cfg.setAllowCredentials(true);
|
||||
|
||||
// Set max age for preflight cache
|
||||
cfg.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", cfg);
|
||||
return source;
|
||||
} else {
|
||||
// No CORS origins configured - return null to disable CORS processing entirely
|
||||
// This avoids empty CORS policy that unexpectedly rejects preflights
|
||||
log.info(
|
||||
"CORS is disabled - no allowed origins configured in settings.yml (system.corsAllowedOrigins)");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@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());
|
||||
}
|
||||
|
||||
if (securityProperties.getCsrfDisabled() || !loginEnabledValue) {
|
||||
http.csrf(CsrfConfigurer::disable);
|
||||
}
|
||||
@ -130,12 +210,8 @@ public class SecurityConfiguration {
|
||||
|
||||
http.addFilterBefore(
|
||||
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(
|
||||
rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
if (v2Enabled) {
|
||||
http.addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class);
|
||||
}
|
||||
.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
|
||||
|
||||
if (!securityProperties.getCsrfDisabled()) {
|
||||
CookieCsrfTokenRepository cookieRepo =
|
||||
@ -195,6 +271,18 @@ 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(
|
||||
@ -227,44 +315,12 @@ public class SecurityConfiguration {
|
||||
req -> {
|
||||
String uri = req.getRequestURI();
|
||||
String contextPath = req.getContextPath();
|
||||
|
||||
// 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("/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("/v1/api-docs")
|
||||
|| uri.contains("/v1/api-docs");
|
||||
// Check if it's a public auth endpoint or static
|
||||
// resource
|
||||
return RequestUriUtils.isStaticResource(
|
||||
contextPath, uri)
|
||||
|| RequestUriUtils.isPublicAuthEndpoint(
|
||||
uri, contextPath);
|
||||
})
|
||||
.permitAll()
|
||||
.anyRequest()
|
||||
@ -333,8 +389,12 @@ public class SecurityConfiguration {
|
||||
.saml2Login(
|
||||
saml2 -> {
|
||||
try {
|
||||
saml2.loginPage("/saml2")
|
||||
.relyingPartyRegistrationRepository(
|
||||
// Only set login page for v1/Thymeleaf mode
|
||||
if (!v2Enabled) {
|
||||
saml2.loginPage("/saml2");
|
||||
}
|
||||
|
||||
saml2.relyingPartyRegistrationRepository(
|
||||
saml2RelyingPartyRegistrations)
|
||||
.authenticationManager(
|
||||
new ProviderManager(authenticationProvider))
|
||||
|
||||
@ -5,13 +5,20 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@ -23,21 +30,36 @@ public class LicenseKeyChecker {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
private final UserLicenseSettingsService licenseSettingsService;
|
||||
|
||||
private License premiumEnabledResult = License.NORMAL;
|
||||
|
||||
public LicenseKeyChecker(
|
||||
KeygenLicenseVerifier licenseService, ApplicationProperties applicationProperties) {
|
||||
KeygenLicenseVerifier licenseService,
|
||||
ApplicationProperties applicationProperties,
|
||||
@Lazy UserLicenseSettingsService licenseSettingsService) {
|
||||
this.licenseService = licenseService;
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.checkLicense();
|
||||
this.licenseSettingsService = licenseSettingsService;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
evaluateLicense();
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void onApplicationReady() {
|
||||
synchronizeLicenseSettings();
|
||||
}
|
||||
|
||||
@Scheduled(initialDelay = 604800000, fixedRate = 604800000) // 7 days in milliseconds
|
||||
public void checkLicensePeriodically() {
|
||||
checkLicense();
|
||||
evaluateLicense();
|
||||
synchronizeLicenseSettings();
|
||||
}
|
||||
|
||||
private void checkLicense() {
|
||||
private void evaluateLicense() {
|
||||
if (!applicationProperties.getPremium().isEnabled()) {
|
||||
premiumEnabledResult = License.NORMAL;
|
||||
} else {
|
||||
@ -58,6 +80,10 @@ public class LicenseKeyChecker {
|
||||
}
|
||||
}
|
||||
|
||||
private void synchronizeLicenseSettings() {
|
||||
licenseSettingsService.updateLicenseMaxUsers();
|
||||
}
|
||||
|
||||
private String getLicenseKeyContent(String keyOrFilePath) {
|
||||
if (keyOrFilePath == null || keyOrFilePath.trim().isEmpty()) {
|
||||
log.error("License key is not specified");
|
||||
@ -85,6 +111,13 @@ public class LicenseKeyChecker {
|
||||
return keyOrFilePath;
|
||||
}
|
||||
|
||||
public void updateLicenseKey(String newKey) throws IOException {
|
||||
applicationProperties.getPremium().setKey(newKey);
|
||||
GeneralUtils.saveKeyToSettings("EnterpriseEdition.key", newKey);
|
||||
evaluateLicense();
|
||||
synchronizeLicenseSettings();
|
||||
}
|
||||
|
||||
public License getPremiumLicenseEnabledResult() {
|
||||
return premiumEnabledResult;
|
||||
}
|
||||
|
||||
@ -21,11 +21,15 @@ 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. */
|
||||
@ -39,6 +43,7 @@ public class AuthController {
|
||||
private final UserService userService;
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
|
||||
/**
|
||||
* Login endpoint - replaces Supabase signInWithPassword
|
||||
@ -49,8 +54,11 @@ public class AuthController {
|
||||
*/
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/login")
|
||||
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
|
||||
public ResponseEntity<?> login(
|
||||
@RequestBody UsernameAndPass request, HttpServletResponse response) {
|
||||
@RequestBody UsernameAndPass request,
|
||||
HttpServletRequest httpRequest,
|
||||
HttpServletResponse response) {
|
||||
try {
|
||||
// Validate input parameters
|
||||
if (request.getUsername() == null || request.getUsername().trim().isEmpty()) {
|
||||
@ -67,20 +75,30 @@ public class AuthController {
|
||||
.body(Map.of("error", "Password is required"));
|
||||
}
|
||||
|
||||
log.debug("Login attempt for user: {}", request.getUsername());
|
||||
String username = request.getUsername().trim();
|
||||
String ip = httpRequest.getRemoteAddr();
|
||||
|
||||
UserDetails userDetails =
|
||||
userDetailsService.loadUserByUsername(request.getUsername().trim());
|
||||
// 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);
|
||||
User user = (User) userDetails;
|
||||
|
||||
if (!userService.isPasswordCorrect(user, request.getPassword())) {
|
||||
log.warn("Invalid password for user: {}", request.getUsername());
|
||||
log.warn("Invalid password for user: {} from IP: {}", username, ip);
|
||||
loginAttemptService.loginFailed(username);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid credentials"));
|
||||
}
|
||||
|
||||
if (!user.isEnabled()) {
|
||||
log.warn("Disabled user attempted login: {}", request.getUsername());
|
||||
log.warn("Disabled user attempted login: {} from IP: {}", username, ip);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "User account is disabled"));
|
||||
}
|
||||
@ -91,7 +109,9 @@ public class AuthController {
|
||||
|
||||
String token = jwtService.generateToken(user.getUsername(), claims);
|
||||
|
||||
log.info("Login successful for user: {}", request.getUsername());
|
||||
// Record successful login
|
||||
loginAttemptService.loginSucceeded(username);
|
||||
log.info("Login successful for user: {} from IP: {}", username, ip);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
@ -99,11 +119,15 @@ public class AuthController {
|
||||
"session", Map.of("access_token", token, "expires_in", 3600)));
|
||||
|
||||
} catch (UsernameNotFoundException e) {
|
||||
log.warn("User not found: {}", request.getUsername());
|
||||
String username = request.getUsername();
|
||||
log.warn("User not found: {}", username);
|
||||
loginAttemptService.loginFailed(username);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid username or password"));
|
||||
} catch (AuthenticationException e) {
|
||||
log.error("Authentication failed for user: {}", request.getUsername(), e);
|
||||
String username = request.getUsername();
|
||||
log.error("Authentication failed for user: {}", username, e);
|
||||
loginAttemptService.loginFailed(username);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid credentials"));
|
||||
} catch (Exception e) {
|
||||
@ -228,11 +252,4 @@ public class AuthController {
|
||||
|
||||
return userMap;
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Request/Response DTOs
|
||||
// ===========================
|
||||
|
||||
/** Login request DTO */
|
||||
public record LoginRequest(String email, String password) {}
|
||||
}
|
||||
|
||||
@ -0,0 +1,488 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.UserApi;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.InviteToken;
|
||||
import stirling.software.proprietary.security.repository.InviteTokenRepository;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@UserApi
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/invite")
|
||||
public class InviteLinkController {
|
||||
|
||||
private final InviteTokenRepository inviteTokenRepository;
|
||||
private final TeamRepository teamRepository;
|
||||
private final UserService userService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final Optional<EmailService> emailService;
|
||||
|
||||
/**
|
||||
* Generate a new invite link (admin only)
|
||||
*
|
||||
* @param email The email address to invite
|
||||
* @param role The role to assign (default: ROLE_USER)
|
||||
* @param teamId The team to assign (optional, uses default team if not provided)
|
||||
* @param expiryHours Custom expiry hours (optional, uses default from config)
|
||||
* @param sendEmail Whether to send the invite link via email (default: false)
|
||||
* @param principal The authenticated admin user
|
||||
* @param request The HTTP request
|
||||
* @return ResponseEntity with the invite link or error
|
||||
*/
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/generate")
|
||||
public ResponseEntity<?> generateInviteLink(
|
||||
@RequestParam(name = "email", required = false) String email,
|
||||
@RequestParam(name = "role", defaultValue = "ROLE_USER") String role,
|
||||
@RequestParam(name = "teamId", required = false) Long teamId,
|
||||
@RequestParam(name = "expiryHours", required = false) Integer expiryHours,
|
||||
@RequestParam(name = "sendEmail", defaultValue = "false") boolean sendEmail,
|
||||
Principal principal,
|
||||
HttpServletRequest request) {
|
||||
|
||||
try {
|
||||
// Check if email invites are enabled
|
||||
if (!applicationProperties.getMail().isEnableInvites()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Email invites are not enabled"));
|
||||
}
|
||||
|
||||
// If email is provided, validate and check for conflicts
|
||||
if (email != null && !email.trim().isEmpty()) {
|
||||
// Validate email format
|
||||
if (!email.contains("@")) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid email address"));
|
||||
}
|
||||
|
||||
email = email.trim().toLowerCase();
|
||||
|
||||
// Check if user already exists
|
||||
if (userService.usernameExistsIgnoreCase(email)) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "User already exists"));
|
||||
}
|
||||
|
||||
// Check if there's already an active invite for this email
|
||||
Optional<InviteToken> existingInvite = inviteTokenRepository.findByEmail(email);
|
||||
if (existingInvite.isPresent() && existingInvite.get().isValid()) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"An active invite already exists for this email address"));
|
||||
}
|
||||
|
||||
// If sendEmail is requested but no email provided, reject
|
||||
if (sendEmail) {
|
||||
// Email will be sent
|
||||
}
|
||||
} else {
|
||||
// No email provided - this is a general invite link
|
||||
email = null; // Ensure it's null, not empty string
|
||||
|
||||
// Cannot send email if no email address provided
|
||||
if (sendEmail) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot send email without an email address"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check license limits
|
||||
if (applicationProperties.getPremium().isEnabled()) {
|
||||
long currentUserCount = userService.getTotalUsersCount();
|
||||
long activeInvites = inviteTokenRepository.countActiveInvites(LocalDateTime.now());
|
||||
int maxUsers = applicationProperties.getPremium().getMaxUsers();
|
||||
|
||||
if (currentUserCount + activeInvites >= maxUsers) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"License limit reached ("
|
||||
+ (currentUserCount + activeInvites)
|
||||
+ "/"
|
||||
+ maxUsers
|
||||
+ " users). Contact your administrator to upgrade your license."));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate role
|
||||
try {
|
||||
Role roleEnum = Role.fromString(role);
|
||||
if (roleEnum == Role.INTERNAL_API_USER) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot assign INTERNAL_API_USER role"));
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid role specified"));
|
||||
}
|
||||
|
||||
// Determine team
|
||||
Long effectiveTeamId = teamId;
|
||||
if (effectiveTeamId == null) {
|
||||
Team defaultTeam =
|
||||
teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
|
||||
if (defaultTeam != null) {
|
||||
effectiveTeamId = defaultTeam.getId();
|
||||
}
|
||||
} else {
|
||||
Team selectedTeam = teamRepository.findById(effectiveTeamId).orElse(null);
|
||||
if (selectedTeam != null
|
||||
&& TeamService.INTERNAL_TEAM_NAME.equals(selectedTeam.getName())) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot assign users to Internal team"));
|
||||
}
|
||||
}
|
||||
|
||||
// Generate token
|
||||
String token = UUID.randomUUID().toString();
|
||||
|
||||
// Determine expiry time
|
||||
int effectiveExpiryHours =
|
||||
(expiryHours != null && expiryHours > 0)
|
||||
? expiryHours
|
||||
: applicationProperties.getMail().getInviteLinkExpiryHours();
|
||||
LocalDateTime expiresAt = LocalDateTime.now().plusHours(effectiveExpiryHours);
|
||||
|
||||
// Create invite token
|
||||
InviteToken inviteToken = new InviteToken();
|
||||
inviteToken.setToken(token);
|
||||
inviteToken.setEmail(email);
|
||||
inviteToken.setRole(role);
|
||||
inviteToken.setTeamId(effectiveTeamId);
|
||||
inviteToken.setExpiresAt(expiresAt);
|
||||
inviteToken.setCreatedBy(principal.getName());
|
||||
|
||||
inviteTokenRepository.save(inviteToken);
|
||||
|
||||
// Build invite URL
|
||||
// Use configured frontend URL if available, otherwise fall back to backend URL
|
||||
String baseUrl;
|
||||
String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
||||
if (configuredFrontendUrl != null && !configuredFrontendUrl.trim().isEmpty()) {
|
||||
// Use configured frontend URL (remove trailing slash if present)
|
||||
baseUrl =
|
||||
configuredFrontendUrl.endsWith("/")
|
||||
? configuredFrontendUrl.substring(
|
||||
0, configuredFrontendUrl.length() - 1)
|
||||
: configuredFrontendUrl;
|
||||
} else {
|
||||
// Fall back to backend URL from request
|
||||
baseUrl =
|
||||
request.getScheme()
|
||||
+ "://"
|
||||
+ request.getServerName()
|
||||
+ (request.getServerPort() != 80 && request.getServerPort() != 443
|
||||
? ":" + request.getServerPort()
|
||||
: "");
|
||||
}
|
||||
String inviteUrl = baseUrl + "/invite?token=" + token;
|
||||
|
||||
log.info("Generated invite link for {} by {}", email, principal.getName());
|
||||
|
||||
// Optionally send email
|
||||
boolean emailSent = false;
|
||||
String emailError = null;
|
||||
if (sendEmail) {
|
||||
if (!emailService.isPresent()) {
|
||||
emailError = "Email service is not configured";
|
||||
log.warn("Cannot send invite email: Email service not configured");
|
||||
} else {
|
||||
try {
|
||||
emailService
|
||||
.get()
|
||||
.sendInviteLinkEmail(email, inviteUrl, expiresAt.toString());
|
||||
emailSent = true;
|
||||
log.info("Sent invite link email to: {}", email);
|
||||
} catch (Exception emailEx) {
|
||||
emailError = emailEx.getMessage();
|
||||
log.error(
|
||||
"Failed to send invite email to {}: {}",
|
||||
email,
|
||||
emailEx.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("token", token);
|
||||
response.put("inviteUrl", inviteUrl);
|
||||
response.put("email", email);
|
||||
response.put("expiresAt", expiresAt.toString());
|
||||
response.put("expiryHours", effectiveExpiryHours);
|
||||
if (sendEmail) {
|
||||
response.put("emailSent", emailSent);
|
||||
if (emailError != null) {
|
||||
response.put("emailError", emailError);
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate invite link: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to generate invite link: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all active invite links (admin only)
|
||||
*
|
||||
* @return List of active invite tokens
|
||||
*/
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@GetMapping("/list")
|
||||
public ResponseEntity<?> listInviteLinks() {
|
||||
try {
|
||||
List<InviteToken> activeInvites =
|
||||
inviteTokenRepository.findByUsedFalseAndExpiresAtAfter(LocalDateTime.now());
|
||||
|
||||
List<Map<String, Object>> inviteList =
|
||||
activeInvites.stream()
|
||||
.map(
|
||||
invite -> {
|
||||
Map<String, Object> inviteMap = new HashMap<>();
|
||||
inviteMap.put("id", invite.getId());
|
||||
inviteMap.put("email", invite.getEmail());
|
||||
inviteMap.put("role", invite.getRole());
|
||||
inviteMap.put("teamId", invite.getTeamId());
|
||||
inviteMap.put("createdBy", invite.getCreatedBy());
|
||||
inviteMap.put(
|
||||
"createdAt", invite.getCreatedAt().toString());
|
||||
inviteMap.put(
|
||||
"expiresAt", invite.getExpiresAt().toString());
|
||||
return inviteMap;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(Map.of("invites", inviteList));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to list invite links: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to list invite links"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an invite link (admin only)
|
||||
*
|
||||
* @param inviteId The invite token ID to revoke
|
||||
* @return Success or error response
|
||||
*/
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@DeleteMapping("/revoke/{inviteId}")
|
||||
public ResponseEntity<?> revokeInviteLink(@PathVariable Long inviteId) {
|
||||
try {
|
||||
Optional<InviteToken> inviteOpt = inviteTokenRepository.findById(inviteId);
|
||||
if (inviteOpt.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "Invite not found"));
|
||||
}
|
||||
|
||||
inviteTokenRepository.deleteById(inviteId);
|
||||
log.info("Revoked invite link ID: {}", inviteId);
|
||||
|
||||
return ResponseEntity.ok(Map.of("message", "Invite link revoked successfully"));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to revoke invite link: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to revoke invite link"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired invite tokens (admin only)
|
||||
*
|
||||
* @return Number of deleted tokens
|
||||
*/
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/cleanup")
|
||||
public ResponseEntity<?> cleanupExpiredInvites() {
|
||||
try {
|
||||
List<InviteToken> expiredInvites =
|
||||
inviteTokenRepository.findAll().stream()
|
||||
.filter(invite -> !invite.isValid())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
int count = expiredInvites.size();
|
||||
inviteTokenRepository.deleteAll(expiredInvites);
|
||||
|
||||
log.info("Cleaned up {} expired invite tokens", count);
|
||||
|
||||
return ResponseEntity.ok(Map.of("deletedCount", count));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to cleanup expired invites: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to cleanup expired invites"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an invite token (public endpoint)
|
||||
*
|
||||
* @param token The invite token to validate
|
||||
* @return Invite details if valid, error otherwise
|
||||
*/
|
||||
@GetMapping("/validate/{token}")
|
||||
public ResponseEntity<?> validateInviteToken(@PathVariable String token) {
|
||||
try {
|
||||
Optional<InviteToken> inviteOpt = inviteTokenRepository.findByToken(token);
|
||||
|
||||
if (inviteOpt.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "Invalid invite link"));
|
||||
}
|
||||
|
||||
InviteToken invite = inviteOpt.get();
|
||||
|
||||
if (invite.isUsed()) {
|
||||
return ResponseEntity.status(HttpStatus.GONE)
|
||||
.body(Map.of("error", "This invite link has already been used"));
|
||||
}
|
||||
|
||||
if (invite.isExpired()) {
|
||||
return ResponseEntity.status(HttpStatus.GONE)
|
||||
.body(Map.of("error", "This invite link has expired"));
|
||||
}
|
||||
|
||||
// Check if user already exists (only if email is pre-set)
|
||||
if (invite.getEmail() != null
|
||||
&& userService.usernameExistsIgnoreCase(invite.getEmail())) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "User already exists"));
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("email", invite.getEmail());
|
||||
response.put("role", invite.getRole());
|
||||
response.put("expiresAt", invite.getExpiresAt().toString());
|
||||
response.put("emailRequired", invite.getEmail() == null);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to validate invite token: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to validate invite link"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an invite and create user account (public endpoint)
|
||||
*
|
||||
* @param token The invite token
|
||||
* @param email The email address (required if not pre-set in invite)
|
||||
* @param password The password to set for the new account
|
||||
* @return Success or error response
|
||||
*/
|
||||
@PostMapping("/accept/{token}")
|
||||
public ResponseEntity<?> acceptInvite(
|
||||
@PathVariable String token,
|
||||
@RequestParam(name = "email", required = false) String email,
|
||||
@RequestParam(name = "password") String password) {
|
||||
try {
|
||||
// Validate password
|
||||
if (password == null || password.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password is required"));
|
||||
}
|
||||
|
||||
Optional<InviteToken> inviteOpt = inviteTokenRepository.findByToken(token);
|
||||
|
||||
if (inviteOpt.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "Invalid invite link"));
|
||||
}
|
||||
|
||||
InviteToken invite = inviteOpt.get();
|
||||
|
||||
if (invite.isUsed()) {
|
||||
return ResponseEntity.status(HttpStatus.GONE)
|
||||
.body(Map.of("error", "This invite link has already been used"));
|
||||
}
|
||||
|
||||
if (invite.isExpired()) {
|
||||
return ResponseEntity.status(HttpStatus.GONE)
|
||||
.body(Map.of("error", "This invite link has expired"));
|
||||
}
|
||||
|
||||
// Determine the email to use
|
||||
String effectiveEmail = invite.getEmail();
|
||||
if (effectiveEmail == null) {
|
||||
// Email not pre-set, must be provided by user
|
||||
if (email == null || email.trim().isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Email address is required"));
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
if (!email.contains("@")) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid email address"));
|
||||
}
|
||||
|
||||
effectiveEmail = email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
if (userService.usernameExistsIgnoreCase(effectiveEmail)) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "User already exists"));
|
||||
}
|
||||
|
||||
// Create the user account
|
||||
userService.saveUser(
|
||||
effectiveEmail,
|
||||
password,
|
||||
invite.getTeamId(),
|
||||
invite.getRole(),
|
||||
false); // Don't force password change
|
||||
|
||||
// Mark invite as used
|
||||
invite.setUsed(true);
|
||||
invite.setUsedAt(LocalDateTime.now());
|
||||
inviteTokenRepository.save(invite);
|
||||
|
||||
log.info(
|
||||
"User account created via invite link: {} with role: {}",
|
||||
effectiveEmail,
|
||||
invite.getRole());
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of("message", "Account created successfully", "username", effectiveEmail));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to accept invite: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to create account: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -43,6 +43,7 @@ import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@UserApi
|
||||
@Slf4j
|
||||
@ -56,6 +57,7 @@ public class UserController {
|
||||
private final TeamRepository teamRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final Optional<EmailService> emailService;
|
||||
private final UserLicenseSettingsService licenseSettingsService;
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/register")
|
||||
@ -80,10 +82,9 @@ public class UserController {
|
||||
.body(Map.of("error", "Invalid username format"));
|
||||
}
|
||||
|
||||
if (usernameAndPass.getPassword() == null
|
||||
|| usernameAndPass.getPassword().length() < 6) {
|
||||
if (usernameAndPass.getPassword() == null || usernameAndPass.getPassword().isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password must be at least 6 characters"));
|
||||
.body(Map.of("error", "Password is required"));
|
||||
}
|
||||
|
||||
Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
|
||||
@ -316,11 +317,17 @@ public class UserController {
|
||||
"error",
|
||||
"Invalid username format. Username must be 3-50 characters."));
|
||||
}
|
||||
if (applicationProperties.getPremium().isEnabled()
|
||||
&& applicationProperties.getPremium().getMaxUsers()
|
||||
<= userService.getTotalUsersCount()) {
|
||||
if (licenseSettingsService.wouldExceedLimit(1)) {
|
||||
long availableSlots = licenseSettingsService.getAvailableUserSlots();
|
||||
int maxAllowed = licenseSettingsService.calculateMaxAllowedUsers();
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Maximum number of users reached for your license."));
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Maximum number of users reached. Allowed: "
|
||||
+ maxAllowed
|
||||
+ ", Available slots: "
|
||||
+ availableSlots));
|
||||
}
|
||||
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
|
||||
if (userOpt.isPresent()) {
|
||||
@ -413,20 +420,19 @@ public class UserController {
|
||||
}
|
||||
|
||||
// Check license limits
|
||||
if (applicationProperties.getPremium().isEnabled()) {
|
||||
long currentUserCount = userService.getTotalUsersCount();
|
||||
int maxUsers = applicationProperties.getPremium().getMaxUsers();
|
||||
long availableSlots = maxUsers - currentUserCount;
|
||||
if (availableSlots < emailArray.length) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Not enough user slots available. Available: "
|
||||
+ availableSlots
|
||||
+ ", Requested: "
|
||||
+ emailArray.length));
|
||||
}
|
||||
if (licenseSettingsService.wouldExceedLimit(emailArray.length)) {
|
||||
long availableSlots = licenseSettingsService.getAvailableUserSlots();
|
||||
int maxAllowed = licenseSettingsService.calculateMaxAllowedUsers();
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Not enough user slots available. Allowed: "
|
||||
+ maxAllowed
|
||||
+ ", Available: "
|
||||
+ availableSlots
|
||||
+ ", Requested: "
|
||||
+ emailArray.length));
|
||||
}
|
||||
|
||||
// Validate role
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
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;
|
||||
@ -80,17 +81,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
String requestURI = request.getRequestURI();
|
||||
String contextPath = request.getContextPath();
|
||||
|
||||
// Public auth endpoints that don't require JWT
|
||||
boolean isPublicAuthEndpoint =
|
||||
requestURI.startsWith(contextPath + "/login")
|
||||
|| requestURI.startsWith(contextPath + "/signup")
|
||||
|| requestURI.startsWith(contextPath + "/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");
|
||||
|
||||
if (!isPublicAuthEndpoint) {
|
||||
if (!isPublicAuthEndpoint(requestURI, contextPath)) {
|
||||
// For API requests, return 401 JSON
|
||||
String acceptHeader = request.getHeader("Accept");
|
||||
if (requestURI.startsWith(contextPath + "/api/")
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
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;
|
||||
@ -105,11 +107,17 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
}
|
||||
}
|
||||
|
||||
// If we still don't have any authentication, deny the request
|
||||
// If we still don't have any authentication, check if it's a public endpoint. If not, 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 {
|
||||
@ -200,6 +208,23 @@ 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");
|
||||
}
|
||||
|
||||
private enum UserLoginType {
|
||||
USERDETAILS("UserDetails"),
|
||||
OAUTH2USER("OAuth2User"),
|
||||
@ -225,8 +250,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
String contextPath = request.getContextPath();
|
||||
String[] permitAllPatterns = {
|
||||
contextPath + "/login",
|
||||
contextPath + "/signup",
|
||||
contextPath + "/register",
|
||||
contextPath + "/invite",
|
||||
contextPath + "/error",
|
||||
contextPath + "/images/",
|
||||
contextPath + "/public/",
|
||||
@ -237,9 +262,10 @@ 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",
|
||||
contextPath + "/api/v1/invite/accept",
|
||||
contextPath + "/site.webmanifest"
|
||||
};
|
||||
|
||||
|
||||
@ -0,0 +1,62 @@
|
||||
package stirling.software.proprietary.security.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "invite_tokens")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class InviteToken implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "token", unique = true, nullable = false, length = 100)
|
||||
private String token;
|
||||
|
||||
@Column(name = "email", nullable = true, length = 255)
|
||||
private String email; // Optional - if not set, user can provide their own email
|
||||
|
||||
@Column(name = "role", nullable = false, length = 50)
|
||||
private String role;
|
||||
|
||||
@Column(name = "team_id")
|
||||
private Long teamId;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
@Column(name = "used", nullable = false)
|
||||
private boolean used = false;
|
||||
|
||||
@Column(name = "created_by", nullable = false, length = 255)
|
||||
private String createdBy;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "used_at")
|
||||
private LocalDateTime usedAt;
|
||||
|
||||
public boolean isExpired() {
|
||||
return LocalDateTime.now().isAfter(expiresAt);
|
||||
}
|
||||
|
||||
public boolean isValid() {
|
||||
return !used && !isExpired();
|
||||
}
|
||||
}
|
||||
@ -4,9 +4,14 @@ 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;
|
||||
@ -16,6 +21,7 @@ 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;
|
||||
@ -37,6 +43,9 @@ 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;
|
||||
@ -119,7 +128,8 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
authentication, Map.of("authType", AuthenticationType.OAUTH2));
|
||||
|
||||
// Build context-aware redirect URL based on the original request
|
||||
String redirectUrl = buildContextAwareRedirectUrl(request, contextPath, jwt);
|
||||
String redirectUrl =
|
||||
buildContextAwareRedirectUrl(request, response, contextPath, jwt);
|
||||
|
||||
response.sendRedirect(redirectUrl);
|
||||
} else {
|
||||
@ -149,30 +159,110 @@ 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, String contextPath, String jwt) {
|
||||
// Try to get the origin from the Referer header first
|
||||
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) {
|
||||
String referer = request.getHeader("Referer");
|
||||
if (referer != null && !referer.isEmpty()) {
|
||||
try {
|
||||
java.net.URL refererUrl = new java.net.URL(referer);
|
||||
String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost();
|
||||
if (refererUrl.getPort() != -1
|
||||
&& refererUrl.getPort() != 80
|
||||
&& refererUrl.getPort() != 443) {
|
||||
origin += ":" + refererUrl.getPort();
|
||||
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);
|
||||
}
|
||||
return origin + "/auth/callback#access_token=" + jwt;
|
||||
} catch (java.net.MalformedURLException e) {
|
||||
// Fall back to other methods if referer is malformed
|
||||
// ignore and fall back
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// Fall back to building from request host/port
|
||||
private String buildOriginFromRequest(HttpServletRequest request) {
|
||||
String scheme = request.getScheme();
|
||||
String serverName = request.getServerName();
|
||||
int serverPort = request.getServerPort();
|
||||
@ -180,12 +270,50 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
StringBuilder origin = new StringBuilder();
|
||||
origin.append(scheme).append("://").append(serverName);
|
||||
|
||||
// Only add port if it's not the default port for the scheme
|
||||
if ((!"http".equals(scheme) || serverPort != 80)
|
||||
&& (!"https".equals(scheme) || serverPort != 443)) {
|
||||
if ((!"http".equalsIgnoreCase(scheme) || serverPort != 80)
|
||||
&& (!"https".equalsIgnoreCase(scheme) || serverPort != 443)) {
|
||||
origin.append(":").append(serverPort);
|
||||
}
|
||||
|
||||
return origin.toString() + "/auth/callback#access_token=" + jwt;
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -165,12 +165,7 @@ public class OAuth2Configuration {
|
||||
githubClient.getUseAsUsername());
|
||||
|
||||
boolean isValid = validateProvider(github);
|
||||
log.info(
|
||||
"GitHub OAuth2 provider validation: {} (clientId: {}, clientSecret: {}, scopes: {})",
|
||||
isValid,
|
||||
githubClient.getClientId(),
|
||||
githubClient.getClientSecret() != null ? "***" : "null",
|
||||
githubClient.getScopes());
|
||||
log.info("Initialised GitHub OAuth2 provider");
|
||||
|
||||
return isValid
|
||||
? Optional.of(
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
package stirling.software.proprietary.security.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.proprietary.security.model.InviteToken;
|
||||
|
||||
@Repository
|
||||
public interface InviteTokenRepository extends JpaRepository<InviteToken, Long> {
|
||||
|
||||
Optional<InviteToken> findByToken(String token);
|
||||
|
||||
Optional<InviteToken> findByEmail(String email);
|
||||
|
||||
List<InviteToken> findByUsedFalseAndExpiresAtAfter(LocalDateTime now);
|
||||
|
||||
List<InviteToken> findByCreatedBy(String createdBy);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM InviteToken it WHERE it.expiresAt < :now")
|
||||
void deleteExpiredTokens(@Param("now") LocalDateTime now);
|
||||
|
||||
@Query("SELECT COUNT(it) FROM InviteToken it WHERE it.used = false AND it.expiresAt > :now")
|
||||
long countActiveInvites(@Param("now") LocalDateTime now);
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
package stirling.software.proprietary.security.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.proprietary.model.UserLicenseSettings;
|
||||
|
||||
@Repository
|
||||
public interface UserLicenseSettingsRepository extends JpaRepository<UserLicenseSettings, Long> {
|
||||
|
||||
/**
|
||||
* Finds the singleton UserLicenseSettings record.
|
||||
*
|
||||
* @return Optional containing the settings if they exist
|
||||
*/
|
||||
default Optional<UserLicenseSettings> findSettings() {
|
||||
return findById(UserLicenseSettings.SINGLETON_ID);
|
||||
}
|
||||
}
|
||||
@ -4,15 +4,21 @@ 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;
|
||||
@ -36,6 +42,9 @@ 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;
|
||||
@ -148,7 +157,7 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
|
||||
// Build context-aware redirect URL based on the original request
|
||||
String redirectUrl =
|
||||
buildContextAwareRedirectUrl(request, contextPath, jwt);
|
||||
buildContextAwareRedirectUrl(request, response, contextPath, jwt);
|
||||
|
||||
response.sendRedirect(redirectUrl);
|
||||
} else {
|
||||
@ -177,8 +186,81 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
* @return The appropriate redirect URL
|
||||
*/
|
||||
private String buildContextAwareRedirectUrl(
|
||||
HttpServletRequest request, String contextPath, String jwt) {
|
||||
// Try to get the origin from the Referer header first
|
||||
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) {
|
||||
String referer = request.getHeader("Referer");
|
||||
if (referer != null && !referer.isEmpty()) {
|
||||
try {
|
||||
@ -189,14 +271,16 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
&& refererUrl.getPort() != 443) {
|
||||
origin += ":" + refererUrl.getPort();
|
||||
}
|
||||
return origin + "/auth/callback#access_token=" + jwt;
|
||||
return Optional.of(origin);
|
||||
} catch (java.net.MalformedURLException e) {
|
||||
log.debug(
|
||||
"Malformed referer URL: {}, falling back to request-based origin", referer);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// Fall back to building from request host/port
|
||||
private String buildOriginFromRequest(HttpServletRequest request) {
|
||||
String scheme = request.getScheme();
|
||||
String serverName = request.getServerName();
|
||||
int serverPort = request.getServerPort();
|
||||
@ -204,12 +288,34 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
StringBuilder origin = new StringBuilder();
|
||||
origin.append(scheme).append("://").append(serverName);
|
||||
|
||||
// Only add port if it's not the default port for the scheme
|
||||
if ((!"http".equals(scheme) || serverPort != 80)
|
||||
&& (!"https".equals(scheme) || serverPort != 443)) {
|
||||
if ((!"http".equalsIgnoreCase(scheme) || serverPort != 80)
|
||||
&& (!"https".equalsIgnoreCase(scheme) || serverPort != 443)) {
|
||||
origin.append(":").append(serverPort);
|
||||
}
|
||||
|
||||
return origin + "/auth/callback#access_token=" + jwt;
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -159,4 +159,58 @@ public class EmailService {
|
||||
|
||||
sendPlainEmail(to, subject, body, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an invitation link email to a new user.
|
||||
*
|
||||
* @param to The recipient email address
|
||||
* @param inviteUrl The full URL for accepting the invite
|
||||
* @param expiresAt The expiration timestamp
|
||||
* @throws MessagingException If there is an issue with creating or sending the email.
|
||||
*/
|
||||
@Async
|
||||
public void sendInviteLinkEmail(String to, String inviteUrl, String expiresAt)
|
||||
throws MessagingException {
|
||||
String subject = "You've been invited to Stirling PDF";
|
||||
|
||||
String body =
|
||||
"""
|
||||
<html><body style="margin: 0; padding: 0;">
|
||||
<div style="font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;">
|
||||
<div style="max-width: 600px; margin: auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0;">
|
||||
<!-- Logo -->
|
||||
<div style="text-align: center; padding: 20px; background-color: #222;">
|
||||
<img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling-transparent.svg" alt="Stirling PDF" style="max-height: 60px;">
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div style="padding: 30px; color: #333;">
|
||||
<h2 style="color: #222; margin-top: 0;">Welcome to Stirling PDF!</h2>
|
||||
<p>Hi there,</p>
|
||||
<p>You have been invited to join the Stirling PDF workspace. Click the button below to set up your account:</p>
|
||||
<!-- CTA Button -->
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="%s" style="display: inline-block; background-color: #007bff; color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 5px; font-weight: bold;">Accept Invitation</a>
|
||||
</div>
|
||||
<p style="font-size: 14px; color: #666;">Or copy and paste this link in your browser:</p>
|
||||
<div style="background-color: #f8f9fa; padding: 12px; margin: 15px 0; border-radius: 4px; word-break: break-all; font-size: 13px; color: #555;">
|
||||
%s
|
||||
</div>
|
||||
<div style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; color: #856404; font-size: 14px;"><strong>⚠️ Important:</strong> This invitation link will expire on %s. Please complete your registration before then.</p>
|
||||
</div>
|
||||
<p>If you didn't expect this invitation, you can safely ignore this email.</p>
|
||||
<p style="margin-bottom: 0;">— The Stirling PDF Team</p>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div style="text-align: center; padding: 15px; font-size: 12px; color: #777; background-color: #f0f0f0;">
|
||||
© 2025 Stirling PDF. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
.formatted(inviteUrl, inviteUrl, expiresAt);
|
||||
|
||||
sendPlainEmail(to, subject, body, true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,411 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.model.UserLicenseSettings;
|
||||
import stirling.software.proprietary.security.repository.UserLicenseSettingsRepository;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* Service for managing user license settings and grandfathering logic.
|
||||
*
|
||||
* <p>User limit calculation:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Default limit: 5 users
|
||||
* <li>Grandfathered limit: max(5, existing user count at initialization)
|
||||
* <li>With pro license: grandfathered limit + license maxUsers
|
||||
* <li>Without pro license: grandfathered limit
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class UserLicenseSettingsService {
|
||||
|
||||
private static final int DEFAULT_USER_LIMIT = 5;
|
||||
private static final String SIGNATURE_SEPARATOR = ":";
|
||||
private static final String DEFAULT_INTEGRITY_SECRET = "stirling-pdf-user-license-guard";
|
||||
|
||||
private final UserLicenseSettingsRepository settingsRepository;
|
||||
private final UserService userService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
/**
|
||||
* Gets the current user license settings, creating them if they don't exist.
|
||||
*
|
||||
* @return The current settings
|
||||
*/
|
||||
@Transactional
|
||||
public UserLicenseSettings getOrCreateSettings() {
|
||||
return settingsRepository
|
||||
.findSettings()
|
||||
.orElseGet(
|
||||
() -> {
|
||||
log.info("Initializing user license settings");
|
||||
UserLicenseSettings settings = new UserLicenseSettings();
|
||||
settings.setId(UserLicenseSettings.SINGLETON_ID);
|
||||
settings.setGrandfatheredUserCount(0);
|
||||
settings.setLicenseMaxUsers(0);
|
||||
settings.setGrandfatheringLocked(false);
|
||||
settings.setIntegritySalt(UUID.randomUUID().toString());
|
||||
settings.setGrandfatheredUserSignature("");
|
||||
return settingsRepository.save(settings);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the grandfathered user count if not already set. This should be called on
|
||||
* application startup.
|
||||
*
|
||||
* <p>IMPORTANT: Once grandfathering is locked, this value can NEVER be changed. This prevents
|
||||
* manipulation by deleting the settings table.
|
||||
*
|
||||
* <p>Logic:
|
||||
*
|
||||
* <ul>
|
||||
* <li>If grandfatheringLocked is true: Skip initialization (already set permanently)
|
||||
* <li>If users exist in database: Set to max(5, current user count) - this is an existing
|
||||
* installation
|
||||
* <li>If no users exist: Set to 5 (default) - this is a fresh installation
|
||||
* <li>Lock grandfathering immediately after setting
|
||||
* </ul>
|
||||
*/
|
||||
@Transactional
|
||||
public void initializeGrandfatheredCount() {
|
||||
UserLicenseSettings settings = getOrCreateSettings();
|
||||
|
||||
boolean changed = ensureIntegritySalt(settings);
|
||||
|
||||
// CRITICAL: Never change grandfathering once it's locked
|
||||
if (settings.isGrandfatheringLocked()) {
|
||||
if (settings.getGrandfatheredUserSignature() == null
|
||||
|| settings.getGrandfatheredUserSignature().isBlank()) {
|
||||
settings.setGrandfatheredUserSignature(
|
||||
generateSignature(settings.getGrandfatheredUserCount(), settings));
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
settingsRepository.save(settings);
|
||||
}
|
||||
log.debug(
|
||||
"Grandfathering is locked. Current grandfathered count: {}",
|
||||
settings.getGrandfatheredUserCount());
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if this is an existing installation or fresh install
|
||||
long currentUserCount = userService.getTotalUsersCount();
|
||||
boolean isExistingInstallation = currentUserCount > 0;
|
||||
|
||||
int grandfatheredCount;
|
||||
if (isExistingInstallation) {
|
||||
// Existing installation (v2.0+ or has users) - grandfather current user count
|
||||
grandfatheredCount = Math.max(DEFAULT_USER_LIMIT, (int) currentUserCount);
|
||||
log.info(
|
||||
"Existing installation detected. Grandfathering {} users (current: {}, minimum:"
|
||||
+ " {})",
|
||||
grandfatheredCount,
|
||||
currentUserCount,
|
||||
DEFAULT_USER_LIMIT);
|
||||
} else {
|
||||
// Fresh installation - set to default
|
||||
grandfatheredCount = DEFAULT_USER_LIMIT;
|
||||
log.info(
|
||||
"Fresh installation detected. Setting default grandfathered limit: {}",
|
||||
grandfatheredCount);
|
||||
}
|
||||
|
||||
// Set and LOCK the grandfathering permanently
|
||||
settings.setGrandfatheredUserCount(grandfatheredCount);
|
||||
settings.setGrandfatheringLocked(true);
|
||||
settings.setGrandfatheredUserSignature(generateSignature(grandfatheredCount, settings));
|
||||
settingsRepository.save(settings);
|
||||
|
||||
log.warn(
|
||||
"GRANDFATHERING LOCKED: {} users. This value can never be changed.",
|
||||
grandfatheredCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the license max users from the application properties. This should be called when the
|
||||
* license is validated.
|
||||
*/
|
||||
@Transactional
|
||||
public void updateLicenseMaxUsers() {
|
||||
UserLicenseSettings settings = getOrCreateSettings();
|
||||
|
||||
int licenseMaxUsers = 0;
|
||||
if (applicationProperties.getPremium().isEnabled()) {
|
||||
licenseMaxUsers = applicationProperties.getPremium().getMaxUsers();
|
||||
}
|
||||
|
||||
if (settings.getLicenseMaxUsers() != licenseMaxUsers) {
|
||||
settings.setLicenseMaxUsers(licenseMaxUsers);
|
||||
settingsRepository.save(settings);
|
||||
log.info("Updated license max users to: {}", licenseMaxUsers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and enforces the integrity of license settings. This ensures that even if someone
|
||||
* manually modifies the database, the grandfathering rules are still enforced.
|
||||
*/
|
||||
@Transactional
|
||||
public void validateSettingsIntegrity() {
|
||||
UserLicenseSettings settings = getOrCreateSettings();
|
||||
boolean changed = ensureIntegritySalt(settings);
|
||||
|
||||
Optional<Integer> signedCountOpt = extractSignedCount(settings);
|
||||
boolean signatureValid =
|
||||
signedCountOpt.isPresent()
|
||||
&& signatureMatches(
|
||||
signedCountOpt.get(),
|
||||
settings.getGrandfatheredUserSignature(),
|
||||
settings);
|
||||
|
||||
int targetCount = settings.getGrandfatheredUserCount();
|
||||
String targetSignature = settings.getGrandfatheredUserSignature();
|
||||
|
||||
if (!signatureValid) {
|
||||
int restoredCount =
|
||||
signedCountOpt.orElseGet(
|
||||
() ->
|
||||
Math.max(
|
||||
DEFAULT_USER_LIMIT,
|
||||
(int) userService.getTotalUsersCount()));
|
||||
log.error(
|
||||
"Grandfathered user signature invalid or missing. Restoring locked count to {}.",
|
||||
restoredCount);
|
||||
targetCount = restoredCount;
|
||||
targetSignature = generateSignature(targetCount, settings);
|
||||
changed = true;
|
||||
} else {
|
||||
int signedCount = signedCountOpt.get();
|
||||
if (targetCount != signedCount) {
|
||||
log.error(
|
||||
"Grandfathered user count ({}) was modified without signature update. Restoring to {}.",
|
||||
targetCount,
|
||||
signedCount);
|
||||
targetCount = signedCount;
|
||||
targetSignature = generateSignature(targetCount, settings);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetCount < DEFAULT_USER_LIMIT) {
|
||||
if (targetCount != DEFAULT_USER_LIMIT) {
|
||||
log.warn(
|
||||
"Grandfathered count ({}) is below minimum ({}). Enforcing minimum.",
|
||||
targetCount,
|
||||
DEFAULT_USER_LIMIT);
|
||||
}
|
||||
targetCount = DEFAULT_USER_LIMIT;
|
||||
targetSignature = generateSignature(targetCount, settings);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (targetSignature == null || targetSignature.isBlank()) {
|
||||
targetSignature = generateSignature(targetCount, settings);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed
|
||||
|| settings.getGrandfatheredUserCount() != targetCount
|
||||
|| (targetSignature != null
|
||||
&& !targetSignature.equals(settings.getGrandfatheredUserSignature()))) {
|
||||
settings.setGrandfatheredUserCount(targetCount);
|
||||
settings.setGrandfatheredUserSignature(targetSignature);
|
||||
settingsRepository.save(settings);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the maximum allowed users based on grandfathering rules.
|
||||
*
|
||||
* <p>Logic:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Grandfathered limit = max(5, existing user count at initialization)
|
||||
* <li>If premium enabled: total limit = grandfathered limit + license maxUsers
|
||||
* <li>If premium disabled: total limit = grandfathered limit
|
||||
* </ul>
|
||||
*
|
||||
* @return Maximum number of users allowed
|
||||
*/
|
||||
public int calculateMaxAllowedUsers() {
|
||||
validateSettingsIntegrity();
|
||||
UserLicenseSettings settings = getOrCreateSettings();
|
||||
|
||||
int grandfatheredLimit = settings.getGrandfatheredUserCount();
|
||||
if (grandfatheredLimit == 0) {
|
||||
// Fallback if not initialized yet - should not happen with validation
|
||||
log.warn("Grandfathered limit is 0, using default: {}", DEFAULT_USER_LIMIT);
|
||||
grandfatheredLimit = DEFAULT_USER_LIMIT;
|
||||
}
|
||||
|
||||
int totalLimit = grandfatheredLimit;
|
||||
|
||||
if (applicationProperties.getPremium().isEnabled()) {
|
||||
totalLimit = grandfatheredLimit + settings.getLicenseMaxUsers();
|
||||
}
|
||||
|
||||
log.debug(
|
||||
"Calculated max allowed users: {} (grandfathered: {}, license: {}, premium enabled: {})",
|
||||
totalLimit,
|
||||
grandfatheredLimit,
|
||||
settings.getLicenseMaxUsers(),
|
||||
applicationProperties.getPremium().isEnabled());
|
||||
|
||||
return totalLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if adding new users would exceed the limit.
|
||||
*
|
||||
* @param newUsersCount Number of new users to add
|
||||
* @return true if the addition would exceed the limit
|
||||
*/
|
||||
public boolean wouldExceedLimit(int newUsersCount) {
|
||||
long currentUserCount = userService.getTotalUsersCount();
|
||||
int maxAllowed = calculateMaxAllowedUsers();
|
||||
return (currentUserCount + newUsersCount) > maxAllowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of available user slots.
|
||||
*
|
||||
* @return Number of users that can still be added
|
||||
*/
|
||||
public long getAvailableUserSlots() {
|
||||
long currentUserCount = userService.getTotalUsersCount();
|
||||
int maxAllowed = calculateMaxAllowedUsers();
|
||||
return Math.max(0, maxAllowed - currentUserCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the grandfathered user count for display purposes. Returns only the excess users beyond
|
||||
* the base limit (5).
|
||||
*
|
||||
* <p>Examples:
|
||||
*
|
||||
* <ul>
|
||||
* <li>If grandfathered = 5: returns 0 (base amount, nothing special)
|
||||
* <li>If grandfathered = 10: returns 5 (5 extra users)
|
||||
* <li>If grandfathered = 15: returns 10 (10 extra users)
|
||||
* </ul>
|
||||
*
|
||||
* @return Number of grandfathered users beyond the base limit
|
||||
*/
|
||||
public int getDisplayGrandfatheredCount() {
|
||||
UserLicenseSettings settings = getOrCreateSettings();
|
||||
int totalGrandfathered = settings.getGrandfatheredUserCount();
|
||||
return Math.max(0, totalGrandfathered - DEFAULT_USER_LIMIT);
|
||||
}
|
||||
|
||||
/** Gets the current settings. */
|
||||
public UserLicenseSettings getSettings() {
|
||||
return getOrCreateSettings();
|
||||
}
|
||||
|
||||
private boolean ensureIntegritySalt(UserLicenseSettings settings) {
|
||||
if (settings.getIntegritySalt() == null || settings.getIntegritySalt().isBlank()) {
|
||||
settings.setIntegritySalt(UUID.randomUUID().toString());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Optional<Integer> extractSignedCount(UserLicenseSettings settings) {
|
||||
String signature = settings.getGrandfatheredUserSignature();
|
||||
if (signature == null || signature.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String[] parts = signature.split(SIGNATURE_SEPARATOR, 2);
|
||||
if (parts.length != 2) {
|
||||
log.warn("Invalid grandfathered user signature format detected");
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
return Optional.of(Integer.parseInt(parts[0]));
|
||||
} catch (NumberFormatException ex) {
|
||||
log.warn("Unable to parse grandfathered user signature count", ex);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean signatureMatches(int count, String signature, UserLicenseSettings settings) {
|
||||
if (signature == null || signature.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
return generateSignature(count, settings).equals(signature);
|
||||
}
|
||||
|
||||
private String generateSignature(int count, UserLicenseSettings settings) {
|
||||
if (settings.getIntegritySalt() == null || settings.getIntegritySalt().isBlank()) {
|
||||
throw new IllegalStateException("Integrity salt must be initialized before signing.");
|
||||
}
|
||||
String payload = buildSignaturePayload(count, settings.getIntegritySalt());
|
||||
String secret = deriveIntegritySecret();
|
||||
String digest = computeHmac(payload, secret);
|
||||
return count + SIGNATURE_SEPARATOR + digest;
|
||||
}
|
||||
|
||||
private String buildSignaturePayload(int count, String salt) {
|
||||
return count + SIGNATURE_SEPARATOR + salt;
|
||||
}
|
||||
|
||||
private String deriveIntegritySecret() {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
appendIfPresent(builder, applicationProperties.getAutomaticallyGenerated().getKey());
|
||||
appendIfPresent(builder, applicationProperties.getAutomaticallyGenerated().getUUID());
|
||||
appendIfPresent(builder, applicationProperties.getPremium().getKey());
|
||||
|
||||
if (builder.length() == 0) {
|
||||
builder.append(DEFAULT_INTEGRITY_SECRET);
|
||||
}
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private void appendIfPresent(StringBuilder builder, String value) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
if (builder.length() > 0) {
|
||||
builder.append(SIGNATURE_SEPARATOR);
|
||||
}
|
||||
builder.append(value);
|
||||
}
|
||||
}
|
||||
|
||||
private String computeHmac(String payload, String secret) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
SecretKeySpec keySpec =
|
||||
new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
|
||||
mac.init(keySpec);
|
||||
byte[] digest = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("Failed to compute grandfathered user signature", e);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IllegalStateException("Invalid key for grandfathered user signature", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -29,6 +29,8 @@ 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);
|
||||
|
||||
@ -17,11 +17,13 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LicenseKeyCheckerTest {
|
||||
|
||||
@Mock private KeygenLicenseVerifier verifier;
|
||||
@Mock private UserLicenseSettingsService userLicenseSettingsService;
|
||||
|
||||
@Test
|
||||
void premiumDisabled_skipsVerification() {
|
||||
@ -29,7 +31,9 @@ class LicenseKeyCheckerTest {
|
||||
props.getPremium().setEnabled(false);
|
||||
props.getPremium().setKey("dummy");
|
||||
|
||||
LicenseKeyChecker checker = new LicenseKeyChecker(verifier, props);
|
||||
LicenseKeyChecker checker =
|
||||
new LicenseKeyChecker(verifier, props, userLicenseSettingsService);
|
||||
checker.init();
|
||||
|
||||
assertEquals(License.NORMAL, checker.getPremiumLicenseEnabledResult());
|
||||
verifyNoInteractions(verifier);
|
||||
@ -42,7 +46,9 @@ class LicenseKeyCheckerTest {
|
||||
props.getPremium().setKey("abc");
|
||||
when(verifier.verifyLicense("abc")).thenReturn(License.PRO);
|
||||
|
||||
LicenseKeyChecker checker = new LicenseKeyChecker(verifier, props);
|
||||
LicenseKeyChecker checker =
|
||||
new LicenseKeyChecker(verifier, props, userLicenseSettingsService);
|
||||
checker.init();
|
||||
|
||||
assertEquals(License.PRO, checker.getPremiumLicenseEnabledResult());
|
||||
verify(verifier).verifyLicense("abc");
|
||||
@ -58,7 +64,9 @@ class LicenseKeyCheckerTest {
|
||||
props.getPremium().setKey("file:" + file.toString());
|
||||
when(verifier.verifyLicense("filekey")).thenReturn(License.ENTERPRISE);
|
||||
|
||||
LicenseKeyChecker checker = new LicenseKeyChecker(verifier, props);
|
||||
LicenseKeyChecker checker =
|
||||
new LicenseKeyChecker(verifier, props, userLicenseSettingsService);
|
||||
checker.init();
|
||||
|
||||
assertEquals(License.ENTERPRISE, checker.getPremiumLicenseEnabledResult());
|
||||
verify(verifier).verifyLicense("filekey");
|
||||
@ -71,7 +79,9 @@ class LicenseKeyCheckerTest {
|
||||
props.getPremium().setEnabled(true);
|
||||
props.getPremium().setKey("file:" + file.toString());
|
||||
|
||||
LicenseKeyChecker checker = new LicenseKeyChecker(verifier, props);
|
||||
LicenseKeyChecker checker =
|
||||
new LicenseKeyChecker(verifier, props, userLicenseSettingsService);
|
||||
checker.init();
|
||||
|
||||
assertEquals(License.NORMAL, checker.getPremiumLicenseEnabledResult());
|
||||
verifyNoInteractions(verifier);
|
||||
|
||||
@ -10,7 +10,7 @@ COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend .
|
||||
RUN npm run build
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false npm run build
|
||||
|
||||
# Stage 2: Build Backend
|
||||
FROM gradle:8.14-jdk21 AS backend-build
|
||||
|
||||
@ -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;"
|
||||
|
||||
@ -72,3 +72,59 @@ 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.
|
||||
|
||||
@ -21,6 +21,7 @@ export default defineConfig(
|
||||
'dist',
|
||||
'node_modules',
|
||||
'public',
|
||||
'src-tauri',
|
||||
],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
|
||||
2546
frontend/package-lock.json
generated
@ -25,6 +25,8 @@
|
||||
"@embedpdf/plugin-viewport": "^1.4.1",
|
||||
"@embedpdf/plugin-zoom": "^1.4.1",
|
||||
"@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",
|
||||
@ -62,10 +64,13 @@
|
||||
"lint": "eslint --max-warnings=0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"tauri-dev": "tauri dev --no-watch",
|
||||
"tauri-build": "tauri build",
|
||||
"typecheck": "npm run typecheck:proprietary",
|
||||
"typecheck:core": "tsc --noEmit --project tsconfig.core.json",
|
||||
"typecheck:proprietary": "tsc --noEmit --project tsconfig.proprietary.json",
|
||||
"typecheck:all": "npm run typecheck:core && npm run typecheck:proprietary",
|
||||
"typecheck:desktop": "tsc --noEmit --project tsconfig.desktop.json",
|
||||
"typecheck:all": "npm run typecheck:core && npm run typecheck:proprietary && npm run typecheck:desktop",
|
||||
"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",
|
||||
@ -103,6 +108,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@iconify-json/material-symbols": "^1.2.37",
|
||||
"@iconify/utils": "^3.0.2",
|
||||
|
||||
@ -298,6 +298,20 @@
|
||||
"general": {
|
||||
"title": "General",
|
||||
"description": "Configure general application preferences.",
|
||||
"account": "Account",
|
||||
"accountDescription": "Manage your account settings",
|
||||
"user": "User",
|
||||
"signedInAs": "Signed in as",
|
||||
"logout": "Log out",
|
||||
"enableFeatures": {
|
||||
"title": "For System Administrators",
|
||||
"intro": "Enable user authentication, team management, and workspace features for your organization.",
|
||||
"action": "Configure",
|
||||
"and": "and",
|
||||
"benefit": "Enables user roles, team collaboration, admin controls, and enterprise features.",
|
||||
"learnMore": "Learn more in documentation",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"autoUnzip": "Auto-unzip API responses",
|
||||
"autoUnzipDescription": "Automatically extract files from ZIP responses",
|
||||
"autoUnzipTooltip": "Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.",
|
||||
@ -399,8 +413,10 @@
|
||||
"top20": "Top 20",
|
||||
"all": "All",
|
||||
"refresh": "Refresh",
|
||||
"includeHomepage": "Include Homepage ('/')",
|
||||
"includeLoginPage": "Include Login Page ('/login')",
|
||||
"dataTypeLabel": "Data Type:",
|
||||
"dataTypeAll": "All",
|
||||
"dataTypeApi": "API",
|
||||
"dataTypeUi": "UI",
|
||||
"totalEndpoints": "Total Endpoints",
|
||||
"totalVisits": "Total Visits",
|
||||
"showing": "Showing",
|
||||
@ -1418,6 +1434,26 @@
|
||||
},
|
||||
"submit": "Remove Pages"
|
||||
},
|
||||
"extractPages": {
|
||||
"title": "Extract Pages",
|
||||
"pageNumbers": {
|
||||
"label": "Pages to Extract",
|
||||
"placeholder": "e.g., 1,3,5-8 or odd & 1-10"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"tooltip": {
|
||||
"description": "Extracts the selected pages into a new PDF, preserving order."
|
||||
},
|
||||
"error": {
|
||||
"failed": "Failed to extract pages"
|
||||
},
|
||||
"results": {
|
||||
"title": "Pages Extracted"
|
||||
},
|
||||
"submit": "Extract Pages"
|
||||
},
|
||||
"pageSelection": {
|
||||
"tooltip": {
|
||||
"header": {
|
||||
@ -1494,6 +1530,7 @@
|
||||
}
|
||||
},
|
||||
"bulkSelection": {
|
||||
"syntaxError": "There is a syntax issue. See Page Selection tips for help.",
|
||||
"header": {
|
||||
"title": "Page Selection Guide"
|
||||
},
|
||||
@ -2975,7 +3012,8 @@
|
||||
"options": {
|
||||
"highContrast": "High contrast",
|
||||
"invertAll": "Invert all colours",
|
||||
"custom": "Custom"
|
||||
"custom": "Custom",
|
||||
"cmyk": "Convert to CMYK"
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
@ -3002,6 +3040,10 @@
|
||||
"text": "Define your own text and background colours using the colour pickers. Perfect for creating branded documents or specific accessibility requirements.",
|
||||
"bullet1": "Text colour - Choose the colour for text elements",
|
||||
"bullet2": "Background colour - Set the background colour for the document"
|
||||
},
|
||||
"cmyk": {
|
||||
"title": "Convert to CMYK",
|
||||
"text": "Convert the PDF from RGB colour space to CMYK colour space, optimized for professional printing. This process converts colours to the Cyan, Magenta, Yellow, Black model used by printers."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@ -3061,7 +3103,10 @@
|
||||
"magicLinkSent": "Magic link sent to {{email}}! Check your email and click the link to sign in.",
|
||||
"passwordResetSent": "Password reset link sent to {{email}}! Check your email and follow the instructions.",
|
||||
"failedToSignIn": "Failed to sign in with {{provider}}: {{message}}",
|
||||
"unexpectedError": "Unexpected error: {{message}}"
|
||||
"unexpectedError": "Unexpected error: {{message}}",
|
||||
"accountCreatedSuccess": "Account created successfully! You can now sign in.",
|
||||
"passwordChangedSuccess": "Password changed successfully! Please sign in with your new password.",
|
||||
"credentialsUpdated": "Your credentials have been updated. Please sign in again."
|
||||
},
|
||||
"signup": {
|
||||
"title": "Create an account",
|
||||
@ -3521,8 +3566,8 @@
|
||||
"restartingMessage": "The server is restarting. Please wait a moment...",
|
||||
"restartError": "Failed to restart server. Please restart manually.",
|
||||
"general": {
|
||||
"title": "General",
|
||||
"description": "Configure general application settings including branding and default behaviour.",
|
||||
"title": "System Settings",
|
||||
"description": "Configure system-wide application settings including branding and default behaviour.",
|
||||
"ui": "User Interface",
|
||||
"system": "System",
|
||||
"appName": "Application Name",
|
||||
@ -3707,7 +3752,7 @@
|
||||
"enableAnalytics": "Enable Analytics",
|
||||
"enableAnalytics.description": "Collect anonymous usage analytics to help improve the application",
|
||||
"metricsEnabled": "Enable Metrics",
|
||||
"metricsEnabled.description": "Enable collection of performance and usage metrics",
|
||||
"metricsEnabled.description": "Enable collection of performance and usage metrics. Provides API endpoint for admins to access metrics data",
|
||||
"searchEngine": "Search Engine Visibility",
|
||||
"googleVisibility": "Google Visibility",
|
||||
"googleVisibility.description": "Allow search engines to index this application"
|
||||
@ -3782,7 +3827,9 @@
|
||||
"from": "From Address",
|
||||
"from.description": "The email address to use as the sender",
|
||||
"enableInvites": "Enable Email Invites",
|
||||
"enableInvites.description": "Allow admins to invite users via email with auto-generated passwords"
|
||||
"enableInvites.description": "Allow admins to invite users via email with auto-generated passwords",
|
||||
"frontendUrl": "Frontend URL",
|
||||
"frontendUrl.description": "Base URL for frontend (e.g. https://pdf.example.com). Used for generating invite links in emails. Leave empty to use backend URL."
|
||||
},
|
||||
"legal": {
|
||||
"title": "Legal Documents",
|
||||
@ -4486,10 +4533,47 @@
|
||||
"directInvite": {
|
||||
"tab": "Direct Create"
|
||||
},
|
||||
"inviteLinkTab": {
|
||||
"tab": "Invite Link"
|
||||
},
|
||||
"inviteLink": {
|
||||
"description": "Generate a secure link that allows the user to set their own password",
|
||||
"email": "Email Address",
|
||||
"emailPlaceholder": "user@example.com (optional)",
|
||||
"emailDescription": "Optional - leave blank for a general invite link that can be used by anyone",
|
||||
"emailRequired": "Email address is required",
|
||||
"emailOptional": "Optional - leave blank for a general invite link",
|
||||
"emailRequiredForSend": "Email address is required to send email notification",
|
||||
"expiryHours": "Expiry Hours",
|
||||
"expiryDescription": "How many hours until the link expires",
|
||||
"sendEmail": "Send invite link via email",
|
||||
"sendEmailDescription": "If enabled, the invite link will be sent to the specified email address",
|
||||
"smtpRequired": "SMTP not configured",
|
||||
"generate": "Generate Link",
|
||||
"generated": "Invite Link Generated",
|
||||
"copied": "Link copied to clipboard",
|
||||
"success": "Invite link generated successfully",
|
||||
"successWithEmail": "Invite link generated and sent via email",
|
||||
"emailFailed": "Invite link generated, but email failed",
|
||||
"emailFailedDetails": "Error: {0}. Please share the invite link manually.",
|
||||
"error": "Failed to generate invite link",
|
||||
"submit": "Generate Invite Link"
|
||||
},
|
||||
"inviteMode": {
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"link": "Link",
|
||||
"emailDisabled": "Email invites require SMTP configuration and mail.enableInvites=true in settings"
|
||||
},
|
||||
"license": {
|
||||
"users": "users",
|
||||
"availableSlots": "Available Slots",
|
||||
"grandfathered": "Grandfathered",
|
||||
"grandfatheredShort": "{{count}} grandfathered",
|
||||
"fromLicense": "from license",
|
||||
"slotsAvailable": "{{count}} user slot(s) available",
|
||||
"noSlotsAvailable": "No slots available",
|
||||
"currentUsage": "Currently using {{current}} of {{max}} user licences"
|
||||
}
|
||||
},
|
||||
"teams": {
|
||||
@ -4573,6 +4657,89 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"currency": "Currency",
|
||||
"popular": "Popular",
|
||||
"current": "Current Plan",
|
||||
"upgrade": "Upgrade",
|
||||
"contact": "Contact Us",
|
||||
"customPricing": "Custom",
|
||||
"showComparison": "Compare All Features",
|
||||
"hideComparison": "Hide Feature Comparison",
|
||||
"featureComparison": "Feature Comparison",
|
||||
"activePlan": {
|
||||
"title": "Active Plan",
|
||||
"subtitle": "Your current subscription details"
|
||||
},
|
||||
"availablePlans": {
|
||||
"title": "Available Plans",
|
||||
"subtitle": "Choose the plan that fits your needs"
|
||||
},
|
||||
"static": {
|
||||
"title": "Billing Information",
|
||||
"message": "Online billing is not currently configured. To upgrade your plan or manage subscriptions, please contact us directly.",
|
||||
"contactSales": "Contact Sales",
|
||||
"contactToUpgrade": "Contact us to upgrade or customize your plan",
|
||||
"maxUsers": "Max Users",
|
||||
"upTo": "Up to"
|
||||
},
|
||||
"period": {
|
||||
"month": "month"
|
||||
},
|
||||
"free": {
|
||||
"name": "Free",
|
||||
"highlight1": "Limited Tool Usage Per week",
|
||||
"highlight2": "Access to all tools",
|
||||
"highlight3": "Community support"
|
||||
},
|
||||
"pro": {
|
||||
"name": "Pro",
|
||||
"highlight1": "Unlimited Tool Usage",
|
||||
"highlight2": "Advanced PDF tools",
|
||||
"highlight3": "No watermarks"
|
||||
},
|
||||
"enterprise": {
|
||||
"name": "Enterprise",
|
||||
"highlight1": "Custom pricing",
|
||||
"highlight2": "Dedicated support",
|
||||
"highlight3": "Latest features"
|
||||
},
|
||||
"feature": {
|
||||
"title": "Feature",
|
||||
"pdfTools": "Basic PDF Tools",
|
||||
"fileSize": "File Size Limit",
|
||||
"automation": "Automate tool workflows",
|
||||
"api": "API Access",
|
||||
"priority": "Priority Support",
|
||||
"customPricing": "Custom Pricing"
|
||||
}
|
||||
},
|
||||
"subscription": {
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"pastDue": "Past Due",
|
||||
"canceled": "Canceled",
|
||||
"incomplete": "Incomplete",
|
||||
"trialing": "Trial",
|
||||
"none": "No Subscription"
|
||||
},
|
||||
"renewsOn": "Renews on {{date}}",
|
||||
"cancelsOn": "Cancels on {{date}}"
|
||||
},
|
||||
"billing": {
|
||||
"manageBilling": "Manage Billing",
|
||||
"portal": {
|
||||
"error": "Failed to open billing portal"
|
||||
}
|
||||
},
|
||||
"payment": {
|
||||
"preparing": "Preparing your checkout...",
|
||||
"upgradeTitle": "Upgrade to {{planName}}",
|
||||
"success": "Payment Successful!",
|
||||
"successMessage": "Your subscription has been activated successfully. You will receive a confirmation email shortly.",
|
||||
"autoClose": "This window will close automatically...",
|
||||
"error": "Payment Error"
|
||||
},
|
||||
"firstLogin": {
|
||||
"title": "First Time Login",
|
||||
"welcomeTitle": "Welcome!",
|
||||
@ -4592,5 +4759,141 @@
|
||||
"passwordMustBeDifferent": "New password must be different from current password",
|
||||
"passwordChangedSuccess": "Password changed successfully! Please log in again.",
|
||||
"passwordChangeFailed": "Failed to change password. Please check your current password."
|
||||
},
|
||||
"invite": {
|
||||
"welcome": "Welcome to Stirling PDF",
|
||||
"invalidToken": "Invalid invitation link",
|
||||
"validationError": "Failed to validate invitation link",
|
||||
"passwordRequired": "Password is required",
|
||||
"passwordTooShort": "Password must be at least 6 characters",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"acceptError": "Failed to create account",
|
||||
"validating": "Validating invitation...",
|
||||
"invalidInvitation": "Invalid Invitation",
|
||||
"goToLogin": "Go to Login",
|
||||
"welcomeTitle": "You've been invited!",
|
||||
"welcomeSubtitle": "Complete your account setup to get started",
|
||||
"accountFor": "Creating account for",
|
||||
"linkExpires": "Link expires",
|
||||
"email": "Email address",
|
||||
"emailPlaceholder": "Enter your email address",
|
||||
"emailRequired": "Email address is required",
|
||||
"invalidEmail": "Invalid email address",
|
||||
"choosePassword": "Choose a password",
|
||||
"passwordPlaceholder": "Enter your password",
|
||||
"confirmPassword": "Confirm password",
|
||||
"confirmPasswordPlaceholder": "Re-enter your password",
|
||||
"createAccount": "Create Account",
|
||||
"creating": "Creating Account...",
|
||||
"alreadyHaveAccount": "Already have an account?",
|
||||
"signIn": "Sign in"
|
||||
},
|
||||
"audit": {
|
||||
"error": {
|
||||
"title": "Error loading audit system"
|
||||
},
|
||||
"notAvailable": "Audit system not available",
|
||||
"notAvailableMessage": "The audit system is not configured or not available.",
|
||||
"disabled": "Audit logging is disabled",
|
||||
"disabledMessage": "Enable audit logging in your application configuration to track system events.",
|
||||
"systemStatus": {
|
||||
"title": "System Status",
|
||||
"status": "Audit Logging",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"level": "Audit Level",
|
||||
"retention": "Retention Period",
|
||||
"days": "days",
|
||||
"totalEvents": "Total Events"
|
||||
},
|
||||
"tabs": {
|
||||
"dashboard": "Dashboard",
|
||||
"events": "Audit Events",
|
||||
"export": "Export"
|
||||
},
|
||||
"charts": {
|
||||
"title": "Audit Dashboard",
|
||||
"error": "Error loading charts",
|
||||
"day": "Day",
|
||||
"week": "Week",
|
||||
"month": "Month",
|
||||
"byType": "Events by Type",
|
||||
"byUser": "Events by User",
|
||||
"overTime": "Events Over Time"
|
||||
},
|
||||
"events": {
|
||||
"title": "Audit Events",
|
||||
"filterByType": "Filter by type",
|
||||
"filterByUser": "Filter by user",
|
||||
"startDate": "Start date",
|
||||
"endDate": "End date",
|
||||
"clearFilters": "Clear",
|
||||
"error": "Error loading events",
|
||||
"noEvents": "No events found",
|
||||
"timestamp": "Timestamp",
|
||||
"type": "Type",
|
||||
"user": "User",
|
||||
"ipAddress": "IP Address",
|
||||
"actions": "Actions",
|
||||
"viewDetails": "View Details",
|
||||
"eventDetails": "Event Details",
|
||||
"details": "Details"
|
||||
},
|
||||
"export": {
|
||||
"title": "Export Audit Data",
|
||||
"description": "Export audit events to CSV or JSON format. Use filters to limit the exported data.",
|
||||
"format": "Export Format",
|
||||
"filters": "Filters (Optional)",
|
||||
"filterByType": "Filter by type",
|
||||
"filterByUser": "Filter by user",
|
||||
"startDate": "Start date",
|
||||
"endDate": "End date",
|
||||
"clearFilters": "Clear",
|
||||
"exportButton": "Export Data",
|
||||
"error": "Failed to export data"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"noData": "No data available",
|
||||
"error": "Error loading usage statistics",
|
||||
"noDataMessage": "No usage statistics are currently available.",
|
||||
"controls": {
|
||||
"top10": "Top 10",
|
||||
"top20": "Top 20",
|
||||
"all": "All",
|
||||
"refresh": "Refresh",
|
||||
"dataTypeLabel": "Data Type:",
|
||||
"dataType": {
|
||||
"all": "All",
|
||||
"api": "API",
|
||||
"ui": "UI"
|
||||
}
|
||||
},
|
||||
"showing": {
|
||||
"top10": "Top 10",
|
||||
"top20": "Top 20",
|
||||
"all": "All"
|
||||
},
|
||||
"stats": {
|
||||
"totalEndpoints": "Total Endpoints",
|
||||
"totalVisits": "Total Visits",
|
||||
"showing": "Showing",
|
||||
"selectedVisits": "Selected Visits"
|
||||
},
|
||||
"chart": {
|
||||
"title": "Endpoint Usage Chart"
|
||||
},
|
||||
"table": {
|
||||
"title": "Detailed Statistics",
|
||||
"endpoint": "Endpoint",
|
||||
"visits": "Visits",
|
||||
"percentage": "Percentage",
|
||||
"noData": "No data available"
|
||||
}
|
||||
},
|
||||
"backendHealth": {
|
||||
"checking": "Checking backend status...",
|
||||
"online": "Backend Online",
|
||||
"offline": "Backend Offline"
|
||||
}
|
||||
}
|
||||
|
||||
@ -893,6 +893,26 @@
|
||||
},
|
||||
"submit": "Remove Pages"
|
||||
},
|
||||
"extractPages": {
|
||||
"title": "Extract Pages",
|
||||
"pageNumbers": {
|
||||
"label": "Pages to Extract",
|
||||
"placeholder": "e.g., 1,3,5-8 or odd & 1-10"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"tooltip": {
|
||||
"description": "Extracts the selected pages into a new PDF, preserving order."
|
||||
},
|
||||
"error": {
|
||||
"failed": "Failed to extract pages"
|
||||
},
|
||||
"results": {
|
||||
"title": "Pages Extracted"
|
||||
},
|
||||
"submit": "Extract Pages"
|
||||
},
|
||||
"pageSelection": {
|
||||
"tooltip": {
|
||||
"header": {
|
||||
@ -958,6 +978,7 @@
|
||||
}
|
||||
},
|
||||
"bulkSelection": {
|
||||
"syntaxError": "There is a syntax issue. See Page Selection tips for help.",
|
||||
"header": { "title": "Page Selection Guide" },
|
||||
"syntax": {
|
||||
"title": "Syntax Basics",
|
||||
|
||||
5
frontend/src-tauri/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
/runtime/
|
||||
5686
frontend/src-tauri/Cargo.lock
generated
Normal file
35
frontend/src-tauri/Cargo.toml
Normal file
@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "stirling-pdf"
|
||||
version = "0.1.0"
|
||||
description = "Stirling-PDF Desktop Application"
|
||||
authors = ["Stirling-PDF Contributors"]
|
||||
license = ""
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.2.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
tauri = { version = "2.9.0", features = [ "devtools"] }
|
||||
tauri-plugin-log = "2.0.0-rc"
|
||||
tauri-plugin-shell = "2.1.0"
|
||||
tauri-plugin-fs = "2.4.4"
|
||||
tokio = { version = "1.0", features = ["time"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
|
||||
# macOS-specific dependencies for native file opening
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc = "0.2"
|
||||
cocoa = "0.24"
|
||||
once_cell = "1.19"
|
||||
3
frontend/src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
15
frontend/src-tauri/capabilities/default.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
{
|
||||
"identifier": "fs:allow-read-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
frontend/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
frontend/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/src-tauri/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 829 B |
BIN
frontend/src-tauri/icons/192x192.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
frontend/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
frontend/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
frontend/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
frontend/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
frontend/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
frontend/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
frontend/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
frontend/src-tauri/icons/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
frontend/src-tauri/icons/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
frontend/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
BIN
frontend/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
BIN
frontend/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
BIN
frontend/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/src-tauri/icons/icon.icns
Normal file
BIN
frontend/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
frontend/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
frontend/src-tauri/icons/icon_orig.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |