Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84ac407017 | ||
|
|
794d15ebaa | ||
|
|
1e11848d4f | ||
|
|
5f32502b54 | ||
|
|
4f4f3c90ba | ||
|
|
2e51a7b288 | ||
|
|
8140ff8c02 | ||
|
|
432e1047d9 | ||
|
|
89df78c347 | ||
|
|
dd6419743f | ||
|
|
fca8470637 | ||
|
|
76f2fd3b76 | ||
|
|
06af6be14b | ||
|
|
c87da6d5cc | ||
|
|
6c8d2c89fe | ||
|
|
8d9e70c796 | ||
|
|
f4725b98b0 | ||
|
|
75414b89f9 | ||
|
|
dd1c653301 | ||
|
|
0f73a1cf13 | ||
|
|
8ffe53536d | ||
|
|
6177ccd333 | ||
|
|
87bf7a5b7f | ||
|
|
a8ea0b60cf | ||
|
|
15b8447626 | ||
|
|
a7fc36586a | ||
|
|
a415c457e9 | ||
|
|
28eb9baa02 | ||
|
|
4b43693e29 | ||
|
|
5f62e6f81f | ||
|
|
5c9e590856 | ||
|
|
be78c72887 | ||
|
|
1117ce6164 | ||
|
|
50760b5302 | ||
|
|
edba81ff9d | ||
|
|
052a3ae653 | ||
|
|
350fdcf29a | ||
|
|
aa20dbb7a6 | ||
|
|
d06391a927 | ||
|
|
3ae2946d57 | ||
|
|
eb5f36aa15 | ||
|
|
a05c5a53c7 | ||
|
|
74a1438c21 | ||
|
|
c8615518a6 | ||
|
|
a5e2b54274 | ||
|
|
f22f697edc | ||
|
|
0913dbf5b7 | ||
|
|
044bf3c2aa | ||
|
|
4d349c047b | ||
|
|
ce6b2460d8 | ||
|
|
3cf89b6ede | ||
|
|
ebf4bab80b | ||
|
|
f4543d26cd | ||
|
|
45389340ed | ||
|
|
9671f6835e | ||
|
|
ac3e10eb99 |
@@ -0,0 +1,74 @@
|
||||
# Node modules and build artifacts
|
||||
node_modules
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
frontend/build
|
||||
frontend/.vite
|
||||
frontend/.tauri
|
||||
|
||||
# Gradle build artifacts
|
||||
.gradle
|
||||
build
|
||||
bin
|
||||
target
|
||||
out
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.iml
|
||||
*.iws
|
||||
*.ipr
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Java compiled files
|
||||
*.class
|
||||
*.jar
|
||||
*.war
|
||||
*.ear
|
||||
|
||||
# Test reports
|
||||
test-results
|
||||
coverage
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
.dockerignore
|
||||
|
||||
# Temporary files
|
||||
tmp
|
||||
temp
|
||||
*.tmp
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# Runtime database and config files (locked by running app)
|
||||
app/core/configs/**
|
||||
stirling/**
|
||||
stirling-pdf-DB*.mv.db
|
||||
stirling-pdf-DB*.trace.db
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
!README.md
|
||||
docs
|
||||
|
||||
# CI/CD
|
||||
.github
|
||||
.gitlab-ci.yml
|
||||
@@ -14,21 +14,14 @@ on:
|
||||
- macos
|
||||
- linux
|
||||
pull_request:
|
||||
branches: [main, V2]
|
||||
branches: [main, V2, V2-tauri-windows]
|
||||
paths:
|
||||
- 'frontend/src-tauri/**'
|
||||
- 'frontend/src/**'
|
||||
- 'frontend/package.json'
|
||||
- 'frontend/package-lock.json'
|
||||
- 'frontend/src/desktop/**'
|
||||
- 'frontend/tsconfig.desktop.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
|
||||
@@ -47,21 +40,19 @@ jobs:
|
||||
"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
|
||||
# ;;
|
||||
"macos")
|
||||
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"linux")
|
||||
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
*)
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"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"}
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# For PR/push events, build all platforms
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"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"}
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build:
|
||||
@@ -70,6 +61,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
@@ -96,7 +90,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-latest' || matrix.platform == 'macos-13') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
|
||||
|
||||
@@ -183,85 +177,400 @@ jobs:
|
||||
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."
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
|
||||
uses: digicert/ssm-code-signing@v1.1.0
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
|
||||
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
|
||||
# - 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: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
|
||||
- name: Build Tauri app
|
||||
# Decode client certificate
|
||||
$certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}")
|
||||
$certPath = "D:\Certificate_pkcs12.p12"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Set environment variables
|
||||
echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
|
||||
echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV
|
||||
echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV
|
||||
echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV
|
||||
echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV
|
||||
|
||||
# Get PKCS11 config path from DigiCert action
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if ($pkcs11Config) {
|
||||
Write-Host "Found PKCS11_CONFIG: $pkcs11Config"
|
||||
echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV
|
||||
} else {
|
||||
Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path"
|
||||
$defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg"
|
||||
if (Test-Path $defaultPath) {
|
||||
Write-Host "Found config at default path: $defaultPath"
|
||||
echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV
|
||||
} else {
|
||||
Write-Host "Warning: Could not find PKCS11 config file"
|
||||
}
|
||||
}
|
||||
|
||||
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
|
||||
- name: Import Windows Code Signing Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
shell: powershell
|
||||
run: |
|
||||
if ($env:WINDOWS_CERTIFICATE) {
|
||||
Write-Host "Importing Windows Code Signing Certificate..."
|
||||
|
||||
# Decode base64 certificate and save to file
|
||||
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
|
||||
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Import certificate to CurrentUser\My store
|
||||
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
|
||||
|
||||
# Extract and set thumbprint as environment variable
|
||||
$thumbprint = $cert.Thumbprint
|
||||
Write-Host "Certificate imported with thumbprint: $thumbprint"
|
||||
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
|
||||
|
||||
# Clean up certificate file
|
||||
Remove-Item $certPath
|
||||
|
||||
Write-Host "Windows certificate import completed."
|
||||
} else {
|
||||
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
echo "Importing Apple Developer Certificate..."
|
||||
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
|
||||
# Create temporary keychain
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
# Import certificate
|
||||
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
# Clean up
|
||||
rm certificate.p12
|
||||
|
||||
- name: Verify Certificate
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application")
|
||||
echo "Certificate Info: $CERT_INFO"
|
||||
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
|
||||
echo "Certificate ID: $CERT_ID"
|
||||
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
|
||||
echo "Certificate imported successfully."
|
||||
|
||||
- name: Check DMG creation dependencies (macOS only)
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
run: |
|
||||
echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..."
|
||||
echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')"
|
||||
echo "create-dmg availability: $(which create-dmg || echo 'NOT FOUND')"
|
||||
echo "Available disk space: $(df -h /tmp | tail -1)"
|
||||
echo "macOS version: $(sw_vers -productVersion)"
|
||||
echo "Available tools:"
|
||||
ls -la /usr/bin/hd* || echo "No hd* tools found"
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
|
||||
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
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
SIGN: ${{ (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
CI: true
|
||||
with:
|
||||
projectPath: ./frontend
|
||||
tauriScript: npx tauri
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
|
||||
# Sign with DigiCert KeyLocker (post-build)
|
||||
- name: Sign Windows binaries with DigiCert KeyLocker
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "=== DigiCert KeyLocker Signing ==="
|
||||
|
||||
# Test smctl connectivity first
|
||||
Write-Host "Testing smctl connection..."
|
||||
$healthCheck = & smctl healthcheck 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "[SUCCESS] Connected to DigiCert KeyLocker"
|
||||
} else {
|
||||
Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker"
|
||||
Write-Host $healthCheck
|
||||
exit 1
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# Sync certificates to Windows certificate store
|
||||
Write-Host "Syncing certificates to Windows certificate store..."
|
||||
$syncOutput = & smctl windows certsync 2>&1
|
||||
Write-Host "Cert sync result: $syncOutput"
|
||||
Write-Host ""
|
||||
|
||||
# List available certificates and check if they have certificates attached
|
||||
Write-Host "Checking for available certificates..."
|
||||
$certList = & smctl keypair ls 2>&1
|
||||
Write-Host "Keypair list output:"
|
||||
Write-Host $certList
|
||||
Write-Host ""
|
||||
|
||||
# Parse the output to check certificate status
|
||||
$lines = $certList -split "`n"
|
||||
$foundKeypair = $false
|
||||
$hasCertificate = $false
|
||||
|
||||
foreach ($line in $lines) {
|
||||
if ($line -match "${{ secrets.SM_KEYPAIR_ALIAS }}") {
|
||||
$foundKeypair = $true
|
||||
Write-Host "[SUCCESS] Found keypair in list"
|
||||
|
||||
# Check if this line has certificate info (not just empty spaces after alias)
|
||||
$parts = $line -split "\s+"
|
||||
if ($parts.Count -gt 2 -and $parts[1] -ne "" -and $parts[1] -ne "CERTIFICATE") {
|
||||
$hasCertificate = $true
|
||||
Write-Host "[SUCCESS] Certificate is associated with keypair"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $foundKeypair) {
|
||||
Write-Host "[ERROR] Keypair not found: ${{ secrets.SM_KEYPAIR_ALIAS }}"
|
||||
Write-Host "Available keypairs are listed above"
|
||||
Write-Host ""
|
||||
Write-Host "Please verify:"
|
||||
Write-Host " 1. Keypair alias is correct in GitHub secret"
|
||||
Write-Host " 2. API key has access to this keypair"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not $hasCertificate) {
|
||||
Write-Host "[ERROR] No certificate associated with keypair"
|
||||
Write-Host "This usually means:"
|
||||
Write-Host " 1. Certificate not yet synced to KeyLocker (run sync manually)"
|
||||
Write-Host " 2. Certificate is pending approval"
|
||||
Write-Host " 3. Certificate needs to be attached to the keypair"
|
||||
Write-Host ""
|
||||
Write-Host "Try running in DigiCert ONE portal:"
|
||||
Write-Host " smctl keypair sync"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "[SUCCESS] Certificate check passed"
|
||||
Write-Host ""
|
||||
|
||||
# Find only the files we need to sign (not build scripts)
|
||||
$filesToSign = @()
|
||||
|
||||
# Main application executable
|
||||
$mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue
|
||||
if ($mainExe) { $filesToSign += $mainExe }
|
||||
|
||||
# MSI installer
|
||||
$msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
|
||||
$filesToSign += $msiFiles
|
||||
|
||||
if ($filesToSign.Count -eq 0) {
|
||||
Write-Host "[ERROR] No files found to sign"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($filesToSign.Count) files to sign:"
|
||||
foreach ($f in $filesToSign) { Write-Host " - $($f.Name)" }
|
||||
Write-Host ""
|
||||
|
||||
$signedCount = 0
|
||||
foreach ($file in $filesToSign) {
|
||||
Write-Host "Signing: $($file.Name)"
|
||||
|
||||
# Get PKCS11 config file path (set by DigiCert action)
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if (-not $pkcs11Config) {
|
||||
Write-Host "[ERROR] PKCS11_CONFIG environment variable not set"
|
||||
Write-Host "DigiCert KeyLocker action may not have run correctly"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Using PKCS11 config: $pkcs11Config"
|
||||
|
||||
# Try signing with certificate fingerprint first (if available)
|
||||
$fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}"
|
||||
if ($fingerprint -and $fingerprint -ne "") {
|
||||
Write-Host "Attempting to sign with certificate fingerprint..."
|
||||
$output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
} else {
|
||||
Write-Host "No fingerprint provided, using keypair alias..."
|
||||
# Use smctl to sign with keypair alias
|
||||
$output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
}
|
||||
|
||||
Write-Host "Exit code: $exitCode"
|
||||
Write-Host "Output: $output"
|
||||
|
||||
# Check if output contains "FAILED" even with exit code 0
|
||||
if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") {
|
||||
Write-Host ""
|
||||
Write-Host "[ERROR] Signing failed for $($file.Name)"
|
||||
Write-Host "[ERROR] smctl returned success but output indicates failure"
|
||||
Write-Host ""
|
||||
Write-Host "Possible issues:"
|
||||
Write-Host " 1. Certificate not fully synced to KeyLocker (wait a few minutes)"
|
||||
Write-Host " 2. Incorrect keypair alias"
|
||||
Write-Host " 3. API key lacks signing permissions"
|
||||
Write-Host ""
|
||||
Write-Host "Please verify in DigiCert ONE portal:"
|
||||
Write-Host " - Certificate status is 'Issued' (not Pending)"
|
||||
Write-Host " - Keypair status is 'Online'"
|
||||
Write-Host " - 'Can sign' is set to 'Yes'"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Host "[ERROR] Failed to sign $($file.Name)"
|
||||
Write-Host "Full error output:"
|
||||
Write-Host $output
|
||||
exit 1
|
||||
}
|
||||
|
||||
$signedCount++
|
||||
Write-Host "[SUCCESS] Signed: $($file.Name)"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
Write-Host "=== Summary ==="
|
||||
Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully"
|
||||
|
||||
- name: Verify notarization (macOS only)
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
run: |
|
||||
echo "🔍 Verifying notarization status..."
|
||||
cd ./frontend/src-tauri/target
|
||||
DMG_FILE=$(find . -name "*.dmg" | head -1)
|
||||
if [ -n "$DMG_FILE" ]; then
|
||||
echo "Found DMG: $DMG_FILE"
|
||||
echo "Checking notarization ticket..."
|
||||
spctl -a -vvv -t install "$DMG_FILE" || echo "⚠️ Notarization check failed or not yet complete"
|
||||
stapler validate "$DMG_FILE" || echo "⚠️ No notarization ticket attached"
|
||||
else
|
||||
echo "⚠️ No DMG file found to verify"
|
||||
fi
|
||||
|
||||
- name: Rename artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ./dist
|
||||
cd ./frontend/src-tauri/target
|
||||
|
||||
|
||||
# Find and rename artifacts based on platform
|
||||
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
|
||||
find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \;
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
# 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" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
|
||||
else
|
||||
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
|
||||
fi
|
||||
|
||||
- name: Verify Windows Code Signature
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Verifying Windows code signatures..."
|
||||
|
||||
$exePath = "./dist/Stirling-PDF-${{ matrix.name }}.exe"
|
||||
$msiPath = "./dist/Stirling-PDF-${{ matrix.name }}.msi"
|
||||
|
||||
$allSigned = $true
|
||||
$usingKeyLocker = "${{ env.SM_API_KEY }}" -ne ""
|
||||
$usingPfx = "${{ env.WINDOWS_CERTIFICATE }}" -ne ""
|
||||
|
||||
# Check EXE signature
|
||||
if (Test-Path $exePath) {
|
||||
$exeSig = Get-AuthenticodeSignature -FilePath $exePath
|
||||
Write-Host "EXE Signature Status: $($exeSig.Status)"
|
||||
Write-Host "EXE Signer: $($exeSig.SignerCertificate.Subject)"
|
||||
Write-Host "EXE Timestamp: $($exeSig.TimeStamperCertificate.NotAfter)"
|
||||
|
||||
if ($exeSig.Status -ne "Valid") {
|
||||
Write-Host "[WARNING] EXE is not properly signed (Status: $($exeSig.Status))"
|
||||
if ($usingKeyLocker -or $usingPfx) {
|
||||
Write-Host "[ERROR] Certificate was provided but signing failed"
|
||||
$allSigned = $false
|
||||
} else {
|
||||
Write-Host "[INFO] Building unsigned binary (no certificate provided)"
|
||||
}
|
||||
} else {
|
||||
Write-Host "[SUCCESS] EXE is properly signed"
|
||||
}
|
||||
}
|
||||
|
||||
# Check MSI signature
|
||||
if (Test-Path $msiPath) {
|
||||
$msiSig = Get-AuthenticodeSignature -FilePath $msiPath
|
||||
Write-Host "MSI Signature Status: $($msiSig.Status)"
|
||||
Write-Host "MSI Signer: $($msiSig.SignerCertificate.Subject)"
|
||||
Write-Host "MSI Timestamp: $($msiSig.TimeStamperCertificate.NotAfter)"
|
||||
|
||||
if ($msiSig.Status -ne "Valid") {
|
||||
Write-Host "[WARNING] MSI is not properly signed (Status: $($msiSig.Status))"
|
||||
if ($usingKeyLocker -or $usingPfx) {
|
||||
Write-Host "[ERROR] Certificate was provided but signing failed"
|
||||
$allSigned = $false
|
||||
} else {
|
||||
Write-Host "[INFO] Building unsigned binary (no certificate provided)"
|
||||
}
|
||||
} else {
|
||||
Write-Host "[SUCCESS] MSI is properly signed"
|
||||
}
|
||||
}
|
||||
|
||||
if (($usingKeyLocker -or $usingPfx) -and -not $allSigned) {
|
||||
Write-Host "[ERROR] Code signing verification failed"
|
||||
exit 1
|
||||
} else {
|
||||
Write-Host "[SUCCESS] Code signature verification completed"
|
||||
}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
@@ -273,7 +582,7 @@ jobs:
|
||||
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..."
|
||||
@@ -282,14 +591,13 @@ jobs:
|
||||
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
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
echo "Checking for macOS artifacts..."
|
||||
find . -name "*.dmg" -o -name "*.app" | head -5
|
||||
if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then
|
||||
echo "❌ No macOS artifacts found"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Checking for Linux artifacts..."
|
||||
find . -name "*.deb" -o -name "*.AppImage" | head -5
|
||||
@@ -298,7 +606,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
echo "✅ Build artifacts found for ${{ matrix.name }}"
|
||||
|
||||
- name: Test artifact sizes
|
||||
@@ -331,4 +639,4 @@ jobs:
|
||||
echo "❌ Some Tauri builds failed."
|
||||
echo "Please check the logs and fix any issues."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
|
||||
|
||||
| Language | Progress |
|
||||
| -------------------------------------------- | -------------------------------------- |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| English (English) (en_GB) |  |
|
||||
| English (US) (en_US) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
|
||||
## Stirling PDF Enterprise
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# Windows Code Signing Setup Guide
|
||||
|
||||
This guide explains how to set up Windows code signing for Stirling-PDF desktop application builds.
|
||||
|
||||
## Overview
|
||||
|
||||
Windows code signing is essential for:
|
||||
- Preventing Windows SmartScreen warnings
|
||||
- Building trust with users
|
||||
- Enabling Microsoft Store distribution
|
||||
- Professional application distribution
|
||||
|
||||
## Certificate Types
|
||||
|
||||
### OV Certificate (Organization Validated)
|
||||
- More affordable option
|
||||
- Requires business verification
|
||||
- May trigger SmartScreen warnings initially until reputation builds
|
||||
- Suitable for most independent software vendors
|
||||
|
||||
### EV Certificate (Extended Validation)
|
||||
- Premium option with immediate SmartScreen reputation
|
||||
- Requires hardware security module (HSM) or cloud-based signing
|
||||
- Higher cost but provides immediate trust
|
||||
- Required since June 2023 for new certificates
|
||||
|
||||
## Obtaining a Certificate
|
||||
|
||||
### Certificate Authorities
|
||||
Popular certificate authorities for Windows code signing:
|
||||
- DigiCert
|
||||
- Sectigo (formerly Comodo)
|
||||
- GlobalSign
|
||||
- SSL.com
|
||||
|
||||
### Certificate Format
|
||||
You'll receive a certificate in one of these formats:
|
||||
- `.pfx` or `.p12` (preferred - contains both certificate and private key)
|
||||
- `.cer` + private key (needs conversion to .pfx)
|
||||
|
||||
### Converting to PFX (if needed)
|
||||
If you have separate certificate and private key files:
|
||||
|
||||
```bash
|
||||
openssl pkcs12 -export -out certificate.pfx -inkey private-key.key -in certificate.cer
|
||||
```
|
||||
|
||||
## Setting Up GitHub Secrets
|
||||
|
||||
### Required Secrets
|
||||
|
||||
Navigate to your GitHub repository → Settings → Secrets and variables → Actions
|
||||
|
||||
Add the following secrets:
|
||||
|
||||
#### 1. `WINDOWS_CERTIFICATE`
|
||||
- **Description**: Base64-encoded .pfx certificate file
|
||||
- **How to create**:
|
||||
|
||||
**On macOS/Linux:**
|
||||
```bash
|
||||
base64 -i certificate.pfx | pbcopy # Copies to clipboard
|
||||
```
|
||||
|
||||
**On Windows (PowerShell):**
|
||||
```powershell
|
||||
[Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx")) | Set-Clipboard
|
||||
```
|
||||
|
||||
Paste the entire base64 string into the GitHub secret.
|
||||
|
||||
#### 2. `WINDOWS_CERTIFICATE_PASSWORD`
|
||||
- **Description**: Password for the .pfx certificate
|
||||
- **Value**: The password you set when creating/exporting the .pfx file
|
||||
|
||||
### Optional Secrets for Tauri Updater
|
||||
|
||||
If you're using Tauri's built-in updater feature:
|
||||
|
||||
#### `TAURI_SIGNING_PRIVATE_KEY`
|
||||
- Generated using Tauri CLI: `npm run tauri signer generate`
|
||||
- Used for update package verification
|
||||
|
||||
#### `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`
|
||||
- Password for the Tauri signing key
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### 1. Tauri Configuration (frontend/src-tauri/tauri.conf.json)
|
||||
|
||||
The Windows signing configuration is already set up:
|
||||
|
||||
```json
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "http://timestamp.digicert.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration Options:**
|
||||
- `certificateThumbprint`: Automatically extracted from imported certificate (leave as `null`)
|
||||
- `digestAlgorithm`: Hashing algorithm - `sha256` is recommended
|
||||
- `timestampUrl`: Timestamp server to prove signing time (survives certificate expiration)
|
||||
|
||||
**Alternative Timestamp Servers:**
|
||||
- DigiCert: `http://timestamp.digicert.com`
|
||||
- Sectigo: `http://timestamp.sectigo.com`
|
||||
- GlobalSign: `http://timestamp.globalsign.com`
|
||||
|
||||
### 2. GitHub Workflow (.github/workflows/tauri-build.yml)
|
||||
|
||||
The workflow includes three Windows signing steps:
|
||||
|
||||
1. **Import Certificate**: Decodes and imports the .pfx certificate into Windows certificate store
|
||||
2. **Build Tauri App**: Builds and signs the application using the imported certificate
|
||||
3. **Verify Signature**: Validates that both .exe and .msi files are properly signed
|
||||
|
||||
## Testing the Setup
|
||||
|
||||
### 1. Local Testing (Windows Only)
|
||||
|
||||
Before pushing to GitHub, test locally:
|
||||
|
||||
```powershell
|
||||
# Set environment variables
|
||||
$env:WINDOWS_CERTIFICATE = [Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx"))
|
||||
$env:WINDOWS_CERTIFICATE_PASSWORD = "your-certificate-password"
|
||||
|
||||
# Build the application
|
||||
cd frontend
|
||||
npm run tauri build
|
||||
|
||||
# Verify the signature
|
||||
Get-AuthenticodeSignature "./src-tauri/target/release/bundle/msi/Stirling-PDF_*.msi"
|
||||
```
|
||||
|
||||
### 2. GitHub Actions Testing
|
||||
|
||||
1. Push your changes to a branch
|
||||
2. Manually trigger the workflow:
|
||||
- Go to Actions → Build Tauri Applications
|
||||
- Click "Run workflow"
|
||||
- Select "windows" platform
|
||||
3. Check the build logs for:
|
||||
- ✅ Certificate import success
|
||||
- ✅ Build completion
|
||||
- ✅ Signature verification
|
||||
|
||||
### 3. Verifying Signed Binaries
|
||||
|
||||
After downloading the built artifacts:
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
Get-AuthenticodeSignature "Stirling-PDF-windows-x86_64.exe"
|
||||
Get-AuthenticodeSignature "Stirling-PDF-windows-x86_64.msi"
|
||||
```
|
||||
|
||||
Look for:
|
||||
- Status: `Valid`
|
||||
- Signer: Your organization name
|
||||
- Timestamp: Recent date/time
|
||||
|
||||
**Windows (GUI):**
|
||||
1. Right-click the .exe or .msi file
|
||||
2. Select "Properties"
|
||||
3. Go to "Digital Signatures" tab
|
||||
4. Verify signature details
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "HashMismatch" Status
|
||||
- Certificate doesn't match the binary
|
||||
- Possible file corruption during download
|
||||
- Re-download and verify
|
||||
|
||||
### "NotSigned" Status
|
||||
- Certificate wasn't imported correctly
|
||||
- Check GitHub secrets are set correctly
|
||||
- Verify base64 encoding is complete (no truncation)
|
||||
|
||||
### "UnknownError" Status
|
||||
- Timestamp server unreachable
|
||||
- Try alternative timestamp URL in tauri.conf.json
|
||||
- Check network connectivity in GitHub Actions
|
||||
|
||||
### SmartScreen Still Shows Warnings
|
||||
- Normal for OV certificates initially
|
||||
- Reputation builds over time with user downloads
|
||||
- Consider EV certificate for immediate reputation
|
||||
|
||||
### Certificate Not Found During Build
|
||||
- Verify `WINDOWS_CERTIFICATE` secret is set
|
||||
- Check base64 encoding is correct (no extra whitespace)
|
||||
- Ensure password is correct
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Never commit certificates to version control**
|
||||
- Keep .pfx files secure and backed up
|
||||
- Use GitHub secrets for CI/CD
|
||||
|
||||
2. **Rotate certificates before expiration**
|
||||
- Set calendar reminders
|
||||
- Update GitHub secrets with new certificate
|
||||
|
||||
3. **Use strong passwords**
|
||||
- Certificate password should be complex
|
||||
- Store securely (password manager)
|
||||
|
||||
4. **Monitor certificate usage**
|
||||
- Review GitHub Actions logs
|
||||
- Set up notifications for failed builds
|
||||
|
||||
5. **Limit access to secrets**
|
||||
- Only repository admins should access secrets
|
||||
- Audit secret access regularly
|
||||
|
||||
## Certificate Lifecycle
|
||||
|
||||
### Before Expiration
|
||||
1. Obtain new certificate from CA (typically annual renewal)
|
||||
2. Convert to .pfx format if needed
|
||||
3. Update `WINDOWS_CERTIFICATE` secret with new base64-encoded certificate
|
||||
4. Update `WINDOWS_CERTIFICATE_PASSWORD` if password changed
|
||||
5. Test build to verify new certificate works
|
||||
|
||||
### Expired Certificates
|
||||
- Signed binaries remain valid (timestamp proves signing time)
|
||||
- New builds will fail until certificate is renewed
|
||||
- Users can still install previously signed versions
|
||||
|
||||
## Cost Considerations
|
||||
|
||||
### Certificate Costs (Annual, as of 2024)
|
||||
- **OV Certificate**: $100-400/year
|
||||
- **EV Certificate**: $400-1000/year
|
||||
|
||||
### Choosing the Right Certificate
|
||||
- **Open source / early stage**: Start with OV
|
||||
- **Commercial / enterprise**: Consider EV for better trust
|
||||
- **Microsoft Store**: EV certificate required
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Tauri Windows Signing Documentation](https://v2.tauri.app/distribute/sign/windows/)
|
||||
- [Microsoft Code Signing Overview](https://docs.microsoft.com/windows/win32/seccrypto/cryptography-tools)
|
||||
- [DigiCert Code Signing Guide](https://www.digicert.com/signing/code-signing-certificates)
|
||||
- [Windows SmartScreen FAQ](https://support.microsoft.com/windows/smartscreen-faq)
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter issues with Windows code signing:
|
||||
1. Check GitHub Actions logs for detailed error messages
|
||||
2. Verify all secrets are set correctly
|
||||
3. Test certificate locally first (Windows environment required)
|
||||
4. Open an issue in the repository with relevant logs (remove sensitive data)
|
||||
@@ -0,0 +1,37 @@
|
||||
package stirling.software.common.annotations.api;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
* Combined annotation for Invite management controllers.
|
||||
* Includes @RestController, @RequestMapping("/api/v1/invite"), and OpenAPI @Tag.
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/invite")
|
||||
@Tag(
|
||||
name = "Invite",
|
||||
description =
|
||||
"""
|
||||
Invite-link generation and acceptance endpoints for onboarding new users.
|
||||
|
||||
Provides the ability to issue invitation tokens, send optional email invites,
|
||||
validate and accept invite links, and manage pending invitations for teams.
|
||||
|
||||
Typical use cases include:
|
||||
• Admin workflows for issuing time-limited invitations to external users
|
||||
• Self-service invite acceptance and team assignment
|
||||
• License limit enforcement when provisioning new accounts
|
||||
|
||||
Target users: administrators and automation scripts orchestrating user onboarding.
|
||||
""")
|
||||
public @interface InviteApi {}
|
||||
@@ -505,10 +505,19 @@ public class ApplicationProperties {
|
||||
public static class Ui {
|
||||
private String appNameNavbar;
|
||||
private List<String> languages;
|
||||
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
|
||||
|
||||
public String getAppNameNavbar() {
|
||||
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
|
||||
}
|
||||
|
||||
public String getLogoStyle() {
|
||||
// Validate and return either "modern" or "classic"
|
||||
if ("modern".equalsIgnoreCase(logoStyle)) {
|
||||
return "modern";
|
||||
}
|
||||
return "classic"; // default
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -8,4 +8,6 @@ public interface UserServiceInterface {
|
||||
long getTotalUsersCount();
|
||||
|
||||
boolean isCurrentUserAdmin();
|
||||
|
||||
boolean isCurrentUserFirstLogin();
|
||||
}
|
||||
|
||||
@@ -1,658 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.*;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public final class FormUtils {
|
||||
|
||||
private FormUtils() {}
|
||||
|
||||
public static boolean hasAnyRotatedPage(PDDocument document) {
|
||||
try {
|
||||
for (PDPage page : document.getPages()) {
|
||||
int rot = page.getRotation();
|
||||
int norm = ((rot % 360) + 360) % 360;
|
||||
if (norm != 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to inspect page rotations: {}", e.getMessage(), e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void copyAndTransformFormFields(
|
||||
PDDocument sourceDocument,
|
||||
PDDocument newDocument,
|
||||
int totalPages,
|
||||
int pagesPerSheet,
|
||||
int cols,
|
||||
int rows,
|
||||
float cellWidth,
|
||||
float cellHeight)
|
||||
throws IOException {
|
||||
|
||||
PDDocumentCatalog sourceCatalog = sourceDocument.getDocumentCatalog();
|
||||
PDAcroForm sourceAcroForm = sourceCatalog.getAcroForm();
|
||||
|
||||
if (sourceAcroForm == null || sourceAcroForm.getFields().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PDDocumentCatalog newCatalog = newDocument.getDocumentCatalog();
|
||||
PDAcroForm newAcroForm = new PDAcroForm(newDocument);
|
||||
newCatalog.setAcroForm(newAcroForm);
|
||||
|
||||
PDResources dr = new PDResources();
|
||||
PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
PDType1Font zapfDingbats = new PDType1Font(Standard14Fonts.FontName.ZAPF_DINGBATS);
|
||||
dr.put(COSName.getPDFName("Helv"), helvetica);
|
||||
dr.put(COSName.getPDFName("ZaDb"), zapfDingbats);
|
||||
newAcroForm.setDefaultResources(dr);
|
||||
newAcroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
|
||||
|
||||
// Do not mutate the source AcroForm; skip bad widgets during copy
|
||||
newAcroForm.setNeedAppearances(true);
|
||||
|
||||
Map<String, Integer> fieldNameCounters = new HashMap<>();
|
||||
|
||||
// Build widget -> field map once for efficient lookups
|
||||
Map<PDAnnotationWidget, PDField> widgetFieldMap = buildWidgetFieldMap(sourceAcroForm);
|
||||
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
PDPage sourcePage = sourceDocument.getPage(pageIndex);
|
||||
List<PDAnnotation> annotations = sourcePage.getAnnotations();
|
||||
|
||||
if (annotations.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int destinationPageIndex = pageIndex / pagesPerSheet;
|
||||
int adjustedPageIndex = pageIndex % pagesPerSheet;
|
||||
int rowIndex = adjustedPageIndex / cols;
|
||||
int colIndex = adjustedPageIndex % cols;
|
||||
|
||||
if (destinationPageIndex >= newDocument.getNumberOfPages()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
PDPage destinationPage = newDocument.getPage(destinationPageIndex);
|
||||
PDRectangle sourceRect = sourcePage.getMediaBox();
|
||||
|
||||
float scaleWidth = cellWidth / sourceRect.getWidth();
|
||||
float scaleHeight = cellHeight / sourceRect.getHeight();
|
||||
float scale = Math.min(scaleWidth, scaleHeight);
|
||||
|
||||
float x = colIndex * cellWidth + (cellWidth - sourceRect.getWidth() * scale) / 2;
|
||||
float y =
|
||||
destinationPage.getMediaBox().getHeight()
|
||||
- ((rowIndex + 1) * cellHeight
|
||||
- (cellHeight - sourceRect.getHeight() * scale) / 2);
|
||||
|
||||
copyBasicFormFields(
|
||||
sourceAcroForm,
|
||||
newAcroForm,
|
||||
sourcePage,
|
||||
destinationPage,
|
||||
x,
|
||||
y,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters,
|
||||
widgetFieldMap);
|
||||
}
|
||||
|
||||
// Refresh appearances to ensure widgets render correctly across viewers
|
||||
try {
|
||||
// Use reflection to avoid compile-time dependency on PDFBox version
|
||||
Method m = newAcroForm.getClass().getMethod("refreshAppearances");
|
||||
m.invoke(newAcroForm);
|
||||
} catch (NoSuchMethodException nsme) {
|
||||
log.warn(
|
||||
"AcroForm.refreshAppearances() not available in this PDFBox version; relying on NeedAppearances.");
|
||||
} catch (Throwable t) {
|
||||
log.warn("Failed to refresh field appearances via AcroForm: {}", t.getMessage(), t);
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyBasicFormFields(
|
||||
PDAcroForm sourceAcroForm,
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage sourcePage,
|
||||
PDPage destinationPage,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters,
|
||||
Map<PDAnnotationWidget, PDField> widgetFieldMap) {
|
||||
|
||||
try {
|
||||
List<PDAnnotation> sourceAnnotations = sourcePage.getAnnotations();
|
||||
List<PDAnnotation> destinationAnnotations = destinationPage.getAnnotations();
|
||||
|
||||
for (PDAnnotation annotation : sourceAnnotations) {
|
||||
if (annotation instanceof PDAnnotationWidget widgetAnnotation) {
|
||||
if (widgetAnnotation.getRectangle() == null) {
|
||||
continue;
|
||||
}
|
||||
PDField sourceField =
|
||||
widgetFieldMap != null ? widgetFieldMap.get(widgetAnnotation) : null;
|
||||
if (sourceField == null) {
|
||||
continue; // skip widgets without a matching field
|
||||
}
|
||||
if (sourceField instanceof PDTextField pdtextfield) {
|
||||
createSimpleTextField(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
pdtextfield,
|
||||
widgetAnnotation,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
} else if (sourceField instanceof PDCheckBox pdCheckBox) {
|
||||
createSimpleCheckBoxField(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
pdCheckBox,
|
||||
widgetAnnotation,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
} else if (sourceField instanceof PDRadioButton pdRadioButton) {
|
||||
createSimpleRadioButtonField(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
pdRadioButton,
|
||||
widgetAnnotation,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
} else if (sourceField instanceof PDComboBox pdComboBox) {
|
||||
createSimpleComboBoxField(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
pdComboBox,
|
||||
widgetAnnotation,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
} else if (sourceField instanceof PDListBox pdlistbox) {
|
||||
createSimpleListBoxField(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
pdlistbox,
|
||||
widgetAnnotation,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
} else if (sourceField instanceof PDSignatureField pdSignatureField) {
|
||||
createSimpleSignatureField(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
pdSignatureField,
|
||||
widgetAnnotation,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
} else if (sourceField instanceof PDPushButton pdPushButton) {
|
||||
createSimplePushButtonField(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
pdPushButton,
|
||||
widgetAnnotation,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to copy basic form fields for page {}: {}",
|
||||
pageIndex,
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void createSimpleTextField(
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
PDTextField sourceField,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters) {
|
||||
|
||||
try {
|
||||
PDTextField newTextField = new PDTextField(newAcroForm);
|
||||
newTextField.setDefaultAppearance("/Helv 12 Tf 0 g");
|
||||
|
||||
boolean initialized =
|
||||
initializeFieldWithWidget(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
newTextField,
|
||||
sourceField.getPartialName(),
|
||||
"textField",
|
||||
sourceWidget,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
|
||||
if (!initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceField.getValueAsString() != null) {
|
||||
newTextField.setValue(sourceField.getValueAsString());
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to create text field '{}': {}",
|
||||
sourceField.getPartialName(),
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void createSimpleCheckBoxField(
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
PDCheckBox sourceField,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters) {
|
||||
|
||||
try {
|
||||
PDCheckBox newCheckBox = new PDCheckBox(newAcroForm);
|
||||
|
||||
boolean initialized =
|
||||
initializeFieldWithWidget(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
newCheckBox,
|
||||
sourceField.getPartialName(),
|
||||
"checkBox",
|
||||
sourceWidget,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
|
||||
if (!initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceField.isChecked()) {
|
||||
newCheckBox.check();
|
||||
} else {
|
||||
newCheckBox.unCheck();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to create checkbox field '{}': {}",
|
||||
sourceField.getPartialName(),
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void createSimpleRadioButtonField(
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
PDRadioButton sourceField,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters) {
|
||||
|
||||
try {
|
||||
PDRadioButton newRadioButton = new PDRadioButton(newAcroForm);
|
||||
|
||||
boolean initialized =
|
||||
initializeFieldWithWidget(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
newRadioButton,
|
||||
sourceField.getPartialName(),
|
||||
"radioButton",
|
||||
sourceWidget,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
|
||||
if (!initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceField.getExportValues() != null) {
|
||||
newRadioButton.setExportValues(sourceField.getExportValues());
|
||||
}
|
||||
if (sourceField.getValue() != null) {
|
||||
newRadioButton.setValue(sourceField.getValue());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to create radio button field '{}': {}",
|
||||
sourceField.getPartialName(),
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void createSimpleComboBoxField(
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
PDComboBox sourceField,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters) {
|
||||
|
||||
try {
|
||||
PDComboBox newComboBox = new PDComboBox(newAcroForm);
|
||||
|
||||
boolean initialized =
|
||||
initializeFieldWithWidget(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
newComboBox,
|
||||
sourceField.getPartialName(),
|
||||
"comboBox",
|
||||
sourceWidget,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
|
||||
if (!initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceField.getOptions() != null) {
|
||||
newComboBox.setOptions(sourceField.getOptions());
|
||||
}
|
||||
if (sourceField.getValue() != null && !sourceField.getValue().isEmpty()) {
|
||||
newComboBox.setValue(sourceField.getValue());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to create combo box field '{}': {}",
|
||||
sourceField.getPartialName(),
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void createSimpleListBoxField(
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
PDListBox sourceField,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters) {
|
||||
|
||||
try {
|
||||
PDListBox newListBox = new PDListBox(newAcroForm);
|
||||
|
||||
boolean initialized =
|
||||
initializeFieldWithWidget(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
newListBox,
|
||||
sourceField.getPartialName(),
|
||||
"listBox",
|
||||
sourceWidget,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
|
||||
if (!initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceField.getOptions() != null) {
|
||||
newListBox.setOptions(sourceField.getOptions());
|
||||
}
|
||||
if (sourceField.getValue() != null && !sourceField.getValue().isEmpty()) {
|
||||
newListBox.setValue(sourceField.getValue());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to create list box field '{}': {}",
|
||||
sourceField.getPartialName(),
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void createSimpleSignatureField(
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
PDSignatureField sourceField,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters) {
|
||||
|
||||
try {
|
||||
PDSignatureField newSignatureField = new PDSignatureField(newAcroForm);
|
||||
|
||||
boolean initialized =
|
||||
initializeFieldWithWidget(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
newSignatureField,
|
||||
sourceField.getPartialName(),
|
||||
"signature",
|
||||
sourceWidget,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
|
||||
if (!initialized) {
|
||||
return;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to create signature field '{}': {}",
|
||||
sourceField.getPartialName(),
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void createSimplePushButtonField(
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
PDPushButton sourceField,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters) {
|
||||
|
||||
try {
|
||||
PDPushButton newPushButton = new PDPushButton(newAcroForm);
|
||||
|
||||
boolean initialized =
|
||||
initializeFieldWithWidget(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
newPushButton,
|
||||
sourceField.getPartialName(),
|
||||
"pushButton",
|
||||
sourceWidget,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to create push button field '{}': {}",
|
||||
sourceField.getPartialName(),
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private static <T extends PDTerminalField> boolean initializeFieldWithWidget(
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
T newField,
|
||||
String originalName,
|
||||
String fallbackName,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters) {
|
||||
|
||||
String baseName = (originalName != null) ? originalName : fallbackName;
|
||||
String newFieldName = generateUniqueFieldName(baseName, pageIndex, fieldNameCounters);
|
||||
newField.setPartialName(newFieldName);
|
||||
|
||||
PDAnnotationWidget newWidget = new PDAnnotationWidget();
|
||||
PDRectangle sourceRect = sourceWidget.getRectangle();
|
||||
if (sourceRect == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
float newX = (sourceRect.getLowerLeftX() * scale) + offsetX;
|
||||
float newY = (sourceRect.getLowerLeftY() * scale) + offsetY;
|
||||
float newWidth = sourceRect.getWidth() * scale;
|
||||
float newHeight = sourceRect.getHeight() * scale;
|
||||
newWidget.setRectangle(new PDRectangle(newX, newY, newWidth, newHeight));
|
||||
newWidget.setPage(destinationPage);
|
||||
|
||||
newField.getWidgets().add(newWidget);
|
||||
newWidget.setParent(newField);
|
||||
newAcroForm.getFields().add(newField);
|
||||
destinationAnnotations.add(newWidget);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static String generateUniqueFieldName(
|
||||
String originalName, int pageIndex, Map<String, Integer> fieldNameCounters) {
|
||||
String baseName = "page" + pageIndex + "_" + originalName;
|
||||
|
||||
Integer counter = fieldNameCounters.get(baseName);
|
||||
if (counter == null) {
|
||||
counter = 0;
|
||||
} else {
|
||||
counter++;
|
||||
}
|
||||
fieldNameCounters.put(baseName, counter);
|
||||
|
||||
return counter == 0 ? baseName : baseName + "_" + counter;
|
||||
}
|
||||
|
||||
private static Map<PDAnnotationWidget, PDField> buildWidgetFieldMap(PDAcroForm acroForm) {
|
||||
Map<PDAnnotationWidget, PDField> map = new HashMap<>();
|
||||
if (acroForm == null) {
|
||||
return map;
|
||||
}
|
||||
try {
|
||||
for (PDField field : acroForm.getFieldTree()) {
|
||||
List<PDAnnotationWidget> widgets = field.getWidgets();
|
||||
if (widgets != null) {
|
||||
for (PDAnnotationWidget w : widgets) {
|
||||
if (w != null) {
|
||||
map.put(w, field);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to build widget->field map: {}", e.getMessage(), e);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ public class GeneralUtils {
|
||||
Set.of(
|
||||
"OCR images.json",
|
||||
"Prepare-pdfs-for-email.json",
|
||||
"Pre-publish-sanitization.json",
|
||||
"split-rotate-auto-rename.json");
|
||||
|
||||
private final String DEFAULT_WEBUI_CONFIGS_DIR = "defaultWebUIConfigs";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.regex.PatternSyntaxException;
|
||||
@@ -241,6 +242,11 @@ public final class RegexPatternUtils {
|
||||
return getPattern("\\s+");
|
||||
}
|
||||
|
||||
/** Pattern for matching punctuation characters */
|
||||
public Pattern getPunctuationPattern() {
|
||||
return getPattern("[\\p{Punct}]+");
|
||||
}
|
||||
|
||||
/** Pattern for matching newlines (Windows and Unix style) */
|
||||
public Pattern getNewlinesPattern() {
|
||||
return getPattern("\\r?\\n");
|
||||
@@ -286,6 +292,24 @@ public final class RegexPatternUtils {
|
||||
return getPattern("[^a-zA-Z0-9 ]");
|
||||
}
|
||||
|
||||
/** Pattern for removing bracketed indices like [0], [Child], etc. in field names */
|
||||
public Pattern getFormFieldBracketPattern() {
|
||||
return getPattern("\\[[^\\]]*\\]");
|
||||
}
|
||||
|
||||
/** Pattern that replaces underscores or hyphens with spaces */
|
||||
public Pattern getUnderscoreHyphenPattern() {
|
||||
return getPattern("[-_]+");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pattern that matches camelCase or alpha-numeric boundaries to allow inserting spaces.
|
||||
* Examples: firstName -> first Name, field1 -> field 1, A5Size -> A5 Size
|
||||
*/
|
||||
public Pattern getCamelCaseBoundaryPattern() {
|
||||
return getPattern("(?<=[a-z])(?=[A-Z])|(?<=[A-Za-z])(?=\\d)|(?<=\\d)(?=[A-Za-z])");
|
||||
}
|
||||
|
||||
/** Pattern for removing angle brackets */
|
||||
public Pattern getAngleBracketsPattern() {
|
||||
return getPattern("[<>]");
|
||||
@@ -335,6 +359,26 @@ public final class RegexPatternUtils {
|
||||
return getPattern("[1-9][0-9]{0,2}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pattern for very simple generic field tokens such as "field", "text", "checkbox" with
|
||||
* optional numeric suffix (e.g. "field 1"). Case-insensitive.
|
||||
*/
|
||||
public Pattern getGenericFieldNamePattern() {
|
||||
return getPattern(
|
||||
"^(field|text|checkbox|radio|button|signature|name|value|option|select|choice)(\\s*\\d+)?$",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
}
|
||||
|
||||
/** Pattern for short identifiers like t1, f2, a10 etc. */
|
||||
public Pattern getSimpleFormFieldPattern() {
|
||||
return getPattern("^[A-Za-z]{1,2}\\s*\\d{1,3}$");
|
||||
}
|
||||
|
||||
/** Pattern for optional leading 't' followed by digits, e.g., t1, 1, t 12. */
|
||||
public Pattern getOptionalTNumericPattern() {
|
||||
return getPattern("^(?:t\\s*)?\\d+$", Pattern.CASE_INSENSITIVE);
|
||||
}
|
||||
|
||||
/** Pattern for validating mathematical expressions */
|
||||
public Pattern getMathExpressionPattern() {
|
||||
return getPattern("[0-9n+\\-*/() ]+");
|
||||
@@ -467,6 +511,11 @@ public final class RegexPatternUtils {
|
||||
return getPattern("/");
|
||||
}
|
||||
|
||||
/** Supported logical types when creating new fields programmatically */
|
||||
public Set<String> getSupportedNewFieldTypes() {
|
||||
return Set.of("text", "checkbox", "combobox", "listbox", "radio", "button", "signature");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-compile commonly used patterns for immediate availability. This eliminates first-call
|
||||
* compilation overhead for frequent patterns.
|
||||
|
||||
@@ -75,6 +75,9 @@ public class RequestUriUtils {
|
||||
|| trimmedUri.startsWith("/api/v1/auth/login")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/logout")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
|
||||
// enableLogin)
|
||||
|| trimmedUri.startsWith("/v1/api-docs")
|
||||
|| trimmedUri.startsWith("/api/v1/invite/validate")
|
||||
|| trimmedUri.startsWith("/api/v1/invite/accept")
|
||||
|
||||
+9
@@ -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(
|
||||
|
||||
@@ -12,6 +12,8 @@ import java.util.Properties;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.web.context.WebServerInitializedEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@@ -198,6 +200,14 @@ public class SPDFApplication {
|
||||
// }
|
||||
}
|
||||
|
||||
@EventListener
|
||||
public void onWebServerInitialized(WebServerInitializedEvent event) {
|
||||
int actualPort = event.getWebServer().getPort();
|
||||
serverPortStatic = String.valueOf(actualPort);
|
||||
// Log the actual runtime port for Tauri to parse
|
||||
log.info("Stirling-PDF running on port: {}", actualPort);
|
||||
}
|
||||
|
||||
private static void printStartupLogs() {
|
||||
log.info("Stirling-PDF Started.");
|
||||
String url = baseUrlStatic + ":" + getStaticPort() + contextPathStatic;
|
||||
|
||||
@@ -18,11 +18,37 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
@Slf4j
|
||||
public class EndpointConfiguration {
|
||||
|
||||
public enum DisableReason {
|
||||
CONFIG,
|
||||
DEPENDENCY,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
public static class EndpointAvailability {
|
||||
private final boolean enabled;
|
||||
private final DisableReason reason;
|
||||
|
||||
public EndpointAvailability(boolean enabled, DisableReason reason) {
|
||||
this.enabled = enabled;
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public DisableReason getReason() {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
|
||||
private static final String REMOVE_BLANKS = "remove-blanks";
|
||||
private final ApplicationProperties applicationProperties;
|
||||
@Getter private Map<String, Boolean> endpointStatuses = new ConcurrentHashMap<>();
|
||||
private Map<String, Set<String>> endpointGroups = new ConcurrentHashMap<>();
|
||||
private Set<String> disabledGroups = new HashSet<>();
|
||||
private Map<String, DisableReason> endpointDisableReasons = new ConcurrentHashMap<>();
|
||||
private Map<String, DisableReason> groupDisableReasons = new ConcurrentHashMap<>();
|
||||
private Map<String, Set<String>> endpointAlternatives = new ConcurrentHashMap<>();
|
||||
private final boolean runningProOrHigher;
|
||||
|
||||
@@ -35,16 +61,31 @@ public class EndpointConfiguration {
|
||||
processEnvironmentConfigs();
|
||||
}
|
||||
|
||||
private String normalizeEndpoint(String endpoint) {
|
||||
if (endpoint == null) {
|
||||
return null;
|
||||
}
|
||||
return endpoint.startsWith("/") ? endpoint.substring(1) : endpoint;
|
||||
}
|
||||
|
||||
public void enableEndpoint(String endpoint) {
|
||||
endpointStatuses.put(endpoint, true);
|
||||
log.debug("Enabled endpoint: {}", endpoint);
|
||||
String normalized = normalizeEndpoint(endpoint);
|
||||
endpointStatuses.put(normalized, true);
|
||||
endpointDisableReasons.remove(normalized);
|
||||
log.debug("Enabled endpoint: {}", normalized);
|
||||
}
|
||||
|
||||
public void disableEndpoint(String endpoint) {
|
||||
if (!Boolean.FALSE.equals(endpointStatuses.get(endpoint))) {
|
||||
log.debug("Disabling endpoint: {}", endpoint);
|
||||
disableEndpoint(endpoint, DisableReason.CONFIG);
|
||||
}
|
||||
|
||||
public void disableEndpoint(String endpoint, DisableReason reason) {
|
||||
String normalized = normalizeEndpoint(endpoint);
|
||||
if (!Boolean.FALSE.equals(endpointStatuses.get(normalized))) {
|
||||
log.debug("Disabling endpoint: {}", normalized);
|
||||
}
|
||||
endpointStatuses.put(endpoint, false);
|
||||
endpointStatuses.put(normalized, false);
|
||||
endpointDisableReasons.put(normalized, reason);
|
||||
}
|
||||
|
||||
public boolean isEndpointEnabled(String endpoint) {
|
||||
@@ -150,6 +191,10 @@ public class EndpointConfiguration {
|
||||
}
|
||||
|
||||
public void disableGroup(String group) {
|
||||
disableGroup(group, DisableReason.CONFIG);
|
||||
}
|
||||
|
||||
public void disableGroup(String group, DisableReason reason) {
|
||||
if (disabledGroups.add(group)) {
|
||||
if (isToolGroup(group)) {
|
||||
log.debug(
|
||||
@@ -161,11 +206,12 @@ public class EndpointConfiguration {
|
||||
group);
|
||||
}
|
||||
}
|
||||
groupDisableReasons.put(group, reason);
|
||||
// Only cascade to endpoints for *functional* groups
|
||||
if (!isToolGroup(group)) {
|
||||
Set<String> endpoints = endpointGroups.get(group);
|
||||
if (endpoints != null) {
|
||||
endpoints.forEach(this::disableEndpoint);
|
||||
endpoints.forEach(endpoint -> disableEndpoint(endpoint, reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,12 +220,39 @@ public class EndpointConfiguration {
|
||||
if (disabledGroups.remove(group)) {
|
||||
log.debug("Enabling group: {}", group);
|
||||
}
|
||||
groupDisableReasons.remove(group);
|
||||
Set<String> endpoints = endpointGroups.get(group);
|
||||
if (endpoints != null) {
|
||||
endpoints.forEach(this::enableEndpoint);
|
||||
}
|
||||
}
|
||||
|
||||
public EndpointAvailability getEndpointAvailability(String endpoint) {
|
||||
boolean enabled = isEndpointEnabled(endpoint);
|
||||
DisableReason reason = enabled ? null : determineDisableReason(endpoint);
|
||||
return new EndpointAvailability(enabled, reason);
|
||||
}
|
||||
|
||||
private DisableReason determineDisableReason(String endpoint) {
|
||||
String normalized = normalizeEndpoint(endpoint);
|
||||
if (Boolean.FALSE.equals(endpointStatuses.get(normalized))) {
|
||||
return endpointDisableReasons.getOrDefault(normalized, DisableReason.CONFIG);
|
||||
}
|
||||
|
||||
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
|
||||
String group = entry.getKey();
|
||||
Set<String> endpoints = entry.getValue();
|
||||
if (!disabledGroups.contains(group) || endpoints == null) {
|
||||
continue;
|
||||
}
|
||||
if (endpoints.contains(normalized)) {
|
||||
return groupDisableReasons.getOrDefault(group, DisableReason.CONFIG);
|
||||
}
|
||||
}
|
||||
|
||||
return DisableReason.UNKNOWN;
|
||||
}
|
||||
|
||||
public Set<String> getDisabledGroups() {
|
||||
return new HashSet<>(disabledGroups);
|
||||
}
|
||||
@@ -261,6 +334,8 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Convert", "pdf-to-csv");
|
||||
addEndpointToGroup("Convert", "pdf-to-markdown");
|
||||
addEndpointToGroup("Convert", "eml-to-pdf");
|
||||
addEndpointToGroup("Convert", "cbz-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-cbz");
|
||||
|
||||
// Adding endpoints to "Security" group
|
||||
addEndpointToGroup("Security", "add-password");
|
||||
@@ -294,6 +369,12 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Other", "replace-and-invert-color-pdf");
|
||||
addEndpointToGroup("Other", "multi-tool");
|
||||
|
||||
// Adding form-related endpoints to "Other" group
|
||||
addEndpointToGroup("Other", "fields");
|
||||
addEndpointToGroup("Other", "modify-fields");
|
||||
addEndpointToGroup("Other", "delete-fields");
|
||||
addEndpointToGroup("Other", "fill");
|
||||
|
||||
// Adding endpoints to "Advance" group
|
||||
addEndpointToGroup("Advance", "adjust-contrast");
|
||||
addEndpointToGroup("Advance", "compress-pdf");
|
||||
@@ -388,6 +469,8 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "pdf-to-markdown");
|
||||
addEndpointToGroup("Java", "add-attachments");
|
||||
addEndpointToGroup("Java", "compress-pdf");
|
||||
addEndpointToGroup("Java", "cbz-to-pdf");
|
||||
addEndpointToGroup("Java", "pdf-to-cbz");
|
||||
addEndpointToGroup("rar", "pdf-to-cbr");
|
||||
|
||||
// Javascript
|
||||
@@ -403,8 +486,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");
|
||||
|
||||
@@ -12,6 +12,7 @@ import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
|
||||
@@ -97,7 +98,7 @@ public class ExternalAppDepConfig {
|
||||
if (affectedGroups != null) {
|
||||
for (String group : affectedGroups) {
|
||||
List<String> affectedFeatures = getAffectedFeatures(group);
|
||||
endpointConfiguration.disableGroup(group);
|
||||
endpointConfiguration.disableGroup(group, DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"Missing dependency: {} - Disabling group: {} (Affected features: {})",
|
||||
command,
|
||||
@@ -127,8 +128,8 @@ public class ExternalAppDepConfig {
|
||||
if (!pythonAvailable) {
|
||||
List<String> pythonFeatures = getAffectedFeatures("Python");
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("Python");
|
||||
endpointConfiguration.disableGroup("OpenCV");
|
||||
endpointConfiguration.disableGroup("Python", DisableReason.DEPENDENCY);
|
||||
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"Missing dependency: Python - Disabling Python features: {} and OpenCV features: {}",
|
||||
String.join(", ", pythonFeatures),
|
||||
@@ -146,14 +147,14 @@ public class ExternalAppDepConfig {
|
||||
int exitCode = process.waitFor();
|
||||
if (exitCode != 0) {
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"OpenCV not available in Python - Disabling OpenCV features: {}",
|
||||
String.join(", ", openCVFeatures));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"Error checking OpenCV: {} - Disabling OpenCV features: {}",
|
||||
e.getMessage(),
|
||||
|
||||
@@ -28,6 +28,8 @@ public class InitialSetup {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
private static boolean isNewServer = false;
|
||||
|
||||
@PostConstruct
|
||||
public void init() throws IOException {
|
||||
initUUIDKey();
|
||||
@@ -88,6 +90,13 @@ public class InitialSetup {
|
||||
}
|
||||
|
||||
public void initSetAppVersion() throws IOException {
|
||||
// Check if this is a new server before setting the version
|
||||
String existingVersion = applicationProperties.getAutomaticallyGenerated().getAppVersion();
|
||||
isNewServer =
|
||||
existingVersion == null
|
||||
|| existingVersion.isEmpty()
|
||||
|| existingVersion.equals("0.0.0");
|
||||
|
||||
String appVersion = "0.0.0";
|
||||
Resource resource = new ClassPathResource("version.properties");
|
||||
Properties props = new Properties();
|
||||
@@ -99,4 +108,8 @@ public class InitialSetup {
|
||||
GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion);
|
||||
applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion);
|
||||
}
|
||||
|
||||
public static boolean isNewServer() {
|
||||
return isNewServer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.servlet.Filter;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.ServletRequest;
|
||||
import jakarta.servlet.ServletResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.service.WeeklyActiveUsersService;
|
||||
|
||||
/**
|
||||
* Filter to track browser IDs for Weekly Active Users (WAU) counting. Only active when security is
|
||||
* disabled (no-login mode).
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "security.enableLogin", havingValue = "false")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class WAUTrackingFilter implements Filter {
|
||||
|
||||
private final WeeklyActiveUsersService wauService;
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
|
||||
if (request instanceof HttpServletRequest httpRequest) {
|
||||
// Extract browser ID from header
|
||||
String browserId = httpRequest.getHeader("X-Browser-Id");
|
||||
|
||||
if (browserId != null && !browserId.trim().isEmpty()) {
|
||||
// Record browser access
|
||||
wauService.recordBrowserAccess(browserId);
|
||||
}
|
||||
}
|
||||
|
||||
// Continue the filter chain
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,24 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
"tauri://localhost",
|
||||
"http://tauri.localhost",
|
||||
"https://tauri.localhost")
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
||||
.allowedHeaders("*")
|
||||
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
||||
.allowedHeaders(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-API-KEY",
|
||||
"X-CSRF-TOKEN",
|
||||
"X-XSRF-TOKEN",
|
||||
"X-Browser-Id")
|
||||
.exposedHeaders(
|
||||
"WWW-Authenticate",
|
||||
"X-Total-Count",
|
||||
"X-Page-Number",
|
||||
"X-Page-Size",
|
||||
"Content-Disposition",
|
||||
"Content-Type")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
} else if (hasConfiguredOrigins) {
|
||||
@@ -63,13 +79,53 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
.toArray(new String[0]);
|
||||
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(allowedOrigins)
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
||||
.allowedHeaders("*")
|
||||
.allowedOriginPatterns(allowedOrigins)
|
||||
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
||||
.allowedHeaders(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-API-KEY",
|
||||
"X-CSRF-TOKEN",
|
||||
"X-XSRF-TOKEN",
|
||||
"X-Browser-Id")
|
||||
.exposedHeaders(
|
||||
"WWW-Authenticate",
|
||||
"X-Total-Count",
|
||||
"X-Page-Number",
|
||||
"X-Page-Size",
|
||||
"Content-Disposition",
|
||||
"Content-Type")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
} else {
|
||||
// Default to allowing all origins when nothing is configured
|
||||
logger.info(
|
||||
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
|
||||
registry.addMapping("/**")
|
||||
.allowedOriginPatterns("*")
|
||||
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
||||
.allowedHeaders(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-API-KEY",
|
||||
"X-CSRF-TOKEN",
|
||||
"X-XSRF-TOKEN",
|
||||
"X-Browser-Id")
|
||||
.exposedHeaders(
|
||||
"WWW-Authenticate",
|
||||
"X-Total-Count",
|
||||
"X-Page-Number",
|
||||
"X-Page-Size",
|
||||
"Content-Disposition",
|
||||
"Content-Type")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
}
|
||||
// If no origins are configured and not in Tauri mode, CORS is not enabled (secure by
|
||||
// default)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-6
@@ -29,7 +29,6 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.JsonDataResponse;
|
||||
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.model.api.EditTableOfContentsRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
@@ -49,13 +48,12 @@ public class EditTableOfContentsController {
|
||||
@AutoJobPostMapping(
|
||||
value = "/extract-bookmarks",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@JsonDataResponse
|
||||
@Operation(
|
||||
summary = "Extract PDF Bookmarks",
|
||||
description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
|
||||
@ResponseBody
|
||||
public List<Map<String, Object>> extractBookmarks(@RequestParam("file") MultipartFile file)
|
||||
throws Exception {
|
||||
public ResponseEntity<List<Map<String, Object>>> extractBookmarks(
|
||||
@RequestParam("file") MultipartFile file) throws Exception {
|
||||
PDDocument document = null;
|
||||
try {
|
||||
document = pdfDocumentFactory.load(file);
|
||||
@@ -63,10 +61,10 @@ public class EditTableOfContentsController {
|
||||
|
||||
if (outline == null) {
|
||||
log.info("No outline/bookmarks found in PDF");
|
||||
return new ArrayList<>();
|
||||
return ResponseEntity.ok(new ArrayList<>());
|
||||
}
|
||||
|
||||
return extractBookmarkItems(document, outline);
|
||||
return ResponseEntity.ok(extractBookmarkItems(document, outline));
|
||||
} finally {
|
||||
if (document != null) {
|
||||
document.close();
|
||||
|
||||
-21
@@ -26,7 +26,6 @@ import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.FormUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@@ -137,26 +136,6 @@ public class MultiPageLayoutController {
|
||||
|
||||
contentStream.close();
|
||||
|
||||
// If any source page is rotated, skip form copying/transformation entirely
|
||||
boolean hasRotation = FormUtils.hasAnyRotatedPage(sourceDocument);
|
||||
if (hasRotation) {
|
||||
log.info("Source document has rotated pages; skipping form field copying.");
|
||||
} else {
|
||||
try {
|
||||
FormUtils.copyAndTransformFormFields(
|
||||
sourceDocument,
|
||||
newDocument,
|
||||
totalPages,
|
||||
pagesPerSheet,
|
||||
cols,
|
||||
rows,
|
||||
cellWidth,
|
||||
cellHeight);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to copy and transform form fields: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
sourceDocument.close();
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
|
||||
+66
-1
@@ -1,6 +1,7 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.context.ApplicationContext;
|
||||
@@ -10,7 +11,14 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
|
||||
import stirling.software.SPDF.config.InitialSetup;
|
||||
import stirling.software.common.annotations.api.ConfigApi;
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
@@ -19,6 +27,7 @@ import stirling.software.common.service.UserServiceInterface;
|
||||
|
||||
@ConfigApi
|
||||
@Hidden
|
||||
@Slf4j
|
||||
public class ConfigController {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
@@ -58,9 +67,15 @@ public class ConfigController {
|
||||
// Extract values from ApplicationProperties
|
||||
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
|
||||
configData.put("languages", applicationProperties.getUi().getLanguages());
|
||||
configData.put("logoStyle", applicationProperties.getUi().getLogoStyle());
|
||||
|
||||
// Security settings
|
||||
configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin());
|
||||
// enableLogin requires both the config flag AND proprietary features to be loaded
|
||||
// If userService is null, proprietary module isn't loaded
|
||||
// (DISABLE_ADDITIONAL_FEATURES=true or DOCKER_ENABLE_SECURITY=false)
|
||||
boolean enableLogin =
|
||||
applicationProperties.getSecurity().getEnableLogin() && userService != null;
|
||||
configData.put("enableLogin", enableLogin);
|
||||
|
||||
// Mail settings - check both SMTP enabled AND invites enabled
|
||||
boolean smtpEnabled = applicationProperties.getMail().isEnabled();
|
||||
@@ -78,6 +93,22 @@ public class ConfigController {
|
||||
}
|
||||
configData.put("isAdmin", isAdmin);
|
||||
|
||||
// Check if this is a new server (version was 0.0.0 before initialization)
|
||||
configData.put("isNewServer", InitialSetup.isNewServer());
|
||||
|
||||
// Check if the current user is a first-time user
|
||||
boolean isNewUser =
|
||||
false; // Default to false when security is disabled or user not found
|
||||
if (userService != null) {
|
||||
try {
|
||||
isNewUser = userService.isCurrentUserFirstLogin();
|
||||
} catch (Exception e) {
|
||||
// If there's an error, assume not new user for safety
|
||||
isNewUser = false;
|
||||
}
|
||||
}
|
||||
configData.put("isNewUser", isNewUser);
|
||||
|
||||
// System settings
|
||||
configData.put(
|
||||
"enableAlphaFunctionality",
|
||||
@@ -128,6 +159,25 @@ public class ConfigController {
|
||||
// EE features not available, continue without them
|
||||
}
|
||||
|
||||
// Add version and machine info for update checking
|
||||
try {
|
||||
if (applicationContext.containsBean("appVersion")) {
|
||||
configData.put(
|
||||
"appVersion", applicationContext.getBean("appVersion", String.class));
|
||||
}
|
||||
if (applicationContext.containsBean("machineType")) {
|
||||
configData.put(
|
||||
"machineType", applicationContext.getBean("machineType", String.class));
|
||||
}
|
||||
if (applicationContext.containsBean("activeSecurity")) {
|
||||
configData.put(
|
||||
"activeSecurity",
|
||||
applicationContext.getBean("activeSecurity", Boolean.class));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Version/machine info not available
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(configData);
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -155,4 +205,19 @@ public class ConfigController {
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@GetMapping("/endpoints-availability")
|
||||
public ResponseEntity<Map<String, EndpointAvailability>> getEndpointAvailability(
|
||||
@RequestParam(name = "endpoints")
|
||||
@Size(min = 1, max = 100, message = "Must provide between 1 and 100 endpoints")
|
||||
List<@NotBlank String> endpoints) {
|
||||
Map<String, EndpointAvailability> result = new HashMap<>();
|
||||
for (String endpoint : endpoints) {
|
||||
String trimmedEndpoint = endpoint.trim();
|
||||
result.put(
|
||||
trimmedEndpoint,
|
||||
endpointConfiguration.getEndpointAvailability(trimmedEndpoint));
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointInspector;
|
||||
import stirling.software.SPDF.config.StartupApplicationListener;
|
||||
import stirling.software.SPDF.service.WeeklyActiveUsersService;
|
||||
import stirling.software.common.annotations.api.InfoApi;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@@ -34,6 +35,7 @@ public class MetricsController {
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final MeterRegistry meterRegistry;
|
||||
private final EndpointInspector endpointInspector;
|
||||
private final Optional<WeeklyActiveUsersService> wauService;
|
||||
private boolean metricsEnabled;
|
||||
|
||||
@PostConstruct
|
||||
@@ -352,6 +354,36 @@ public class MetricsController {
|
||||
return ResponseEntity.ok(formatDuration(uptime));
|
||||
}
|
||||
|
||||
@GetMapping("/wau")
|
||||
@Operation(
|
||||
summary = "Weekly Active Users statistics",
|
||||
description =
|
||||
"Returns WAU (Weekly Active Users) count and total unique browsers. "
|
||||
+ "Only available when security is disabled (no-login mode). "
|
||||
+ "Tracks unique browsers via client-generated UUID in localStorage.")
|
||||
public ResponseEntity<?> getWeeklyActiveUsers() {
|
||||
if (!metricsEnabled) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
|
||||
}
|
||||
|
||||
// Check if WAU service is available (only when security.enableLogin=false)
|
||||
if (wauService.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(
|
||||
"WAU tracking is only available when security is disabled (no-login mode)");
|
||||
}
|
||||
|
||||
WeeklyActiveUsersService service = wauService.get();
|
||||
|
||||
Map<String, Object> wauStats = new HashMap<>();
|
||||
wauStats.put("weeklyActiveUsers", service.getWeeklyActiveUsers());
|
||||
wauStats.put("totalUniqueBrowsers", service.getTotalUniqueBrowsers());
|
||||
wauStats.put("daysOnline", service.getDaysOnline());
|
||||
wauStats.put("trackingSince", service.getStartTime().toString());
|
||||
|
||||
return ResponseEntity.ok(wauStats);
|
||||
}
|
||||
|
||||
private String formatDuration(Duration duration) {
|
||||
long days = duration.toDays();
|
||||
long hours = duration.toHoursPart();
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Service for tracking Weekly Active Users (WAU) in no-login mode. Uses in-memory storage with
|
||||
* automatic cleanup of old entries.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class WeeklyActiveUsersService {
|
||||
|
||||
// Map of browser ID -> last seen timestamp
|
||||
private final Map<String, Instant> activeBrowsers = new ConcurrentHashMap<>();
|
||||
|
||||
// Track total unique browsers seen (overall)
|
||||
private long totalUniqueBrowsers = 0;
|
||||
|
||||
// Application start time
|
||||
private final Instant startTime = Instant.now();
|
||||
|
||||
/**
|
||||
* Records a browser access with the current timestamp
|
||||
*
|
||||
* @param browserId Unique browser identifier from X-Browser-Id header
|
||||
*/
|
||||
public void recordBrowserAccess(String browserId) {
|
||||
if (browserId == null || browserId.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean isNewBrowser = !activeBrowsers.containsKey(browserId);
|
||||
activeBrowsers.put(browserId, Instant.now());
|
||||
|
||||
if (isNewBrowser) {
|
||||
totalUniqueBrowsers++;
|
||||
log.debug("New browser recorded: {} (Total: {})", browserId, totalUniqueBrowsers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the count of unique browsers seen in the last 7 days
|
||||
*
|
||||
* @return Weekly Active Users count
|
||||
*/
|
||||
public long getWeeklyActiveUsers() {
|
||||
cleanupOldEntries();
|
||||
return activeBrowsers.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total count of unique browsers ever seen
|
||||
*
|
||||
* @return Total unique browsers count
|
||||
*/
|
||||
public long getTotalUniqueBrowsers() {
|
||||
return totalUniqueBrowsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of days the service has been running
|
||||
*
|
||||
* @return Days online
|
||||
*/
|
||||
public long getDaysOnline() {
|
||||
return ChronoUnit.DAYS.between(startTime, Instant.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the timestamp when tracking started
|
||||
*
|
||||
* @return Start time
|
||||
*/
|
||||
public Instant getStartTime() {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
/** Removes entries older than 7 days */
|
||||
private void cleanupOldEntries() {
|
||||
Instant sevenDaysAgo = Instant.now().minus(7, ChronoUnit.DAYS);
|
||||
activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo));
|
||||
}
|
||||
|
||||
/** Manual cleanup trigger (can be called by scheduled task if needed) */
|
||||
public void performCleanup() {
|
||||
int sizeBefore = activeBrowsers.size();
|
||||
cleanupOldEntries();
|
||||
int sizeAfter = activeBrowsers.size();
|
||||
|
||||
if (sizeBefore != sizeAfter) {
|
||||
log.debug("Cleaned up {} old browser entries", sizeBefore - sizeAfter);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,7 @@ system:
|
||||
|
||||
ui:
|
||||
appNameNavbar: '' # name displayed on the navigation bar
|
||||
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
|
||||
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
|
||||
|
||||
endpoints:
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
--md-sys-color-surface-3: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.11) 5%);
|
||||
--md-sys-color-surface-4: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.12) 5%);
|
||||
--md-sys-color-surface-5: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.14) 5%);
|
||||
/* Clear button disabled text color (default/light) */
|
||||
--spdf-clear-disabled-text: var(--md-sys-color-primary);
|
||||
/* Icon fill */
|
||||
--md-sys-icon-fill-0: 'FILL' 0, 'wght' 500;
|
||||
--md-sys-icon-fill-1: 'FILL' 1, 'wght' 500;
|
||||
@@ -25,6 +27,12 @@
|
||||
--md-sys-elevation-5: 0px 8px 10px -6px rgb(var(--md-elevation-shadow-color), 0.2), 0px 16px 24px 2px rgb(var(--md-elevation-shadow-color), 0.14), 0px 6px 30px 5px rgb(var(--md-elevation-shadow-color), 0.12);
|
||||
}
|
||||
|
||||
/* Dark theme overrides */
|
||||
.dark-theme {
|
||||
/* In dark mode, use a neutral grey for disabled Clear button text */
|
||||
--spdf-clear-disabled-text: var(--mantine-color-gray-5, #9e9e9e);
|
||||
}
|
||||
|
||||
.fill {
|
||||
font-variation-settings: var(--md-sys-icon-fill-1);
|
||||
}
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "Pre-publish-sanitization",
|
||||
"pipeline": [
|
||||
{
|
||||
"operation": "/api/v1/security/sanitize-pdf",
|
||||
"parameters": {
|
||||
"removeJavaScript": true,
|
||||
"removeEmbeddedFiles": true,
|
||||
"removeXMPMetadata": true,
|
||||
"removeMetadata": true,
|
||||
"removeLinks": true,
|
||||
"removeFonts": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"operation": "/api/v1/misc/flatten",
|
||||
"parameters": {
|
||||
"flattenOnlyForms": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"operation": "/api/v1/general/remove-annotations",
|
||||
"parameters": {}
|
||||
},
|
||||
{
|
||||
"operation": "/api/v1/misc/update-metadata",
|
||||
"parameters": {
|
||||
"deleteAll": true,
|
||||
"author": "",
|
||||
"creationDate": "",
|
||||
"creator": "",
|
||||
"keywords": "",
|
||||
"modificationDate": "",
|
||||
"producer": "",
|
||||
"subject": "",
|
||||
"title": "",
|
||||
"trapped": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"operation": "/api/v1/misc/compress-pdf",
|
||||
"parameters": {
|
||||
"optimizeLevel": 3,
|
||||
"expectedOutputSize": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"_examples": {
|
||||
"outputDir": "{outputFolder}/{folderName}",
|
||||
"outputFileName": "{filename}-{pipelineName}-{date}-{time}"
|
||||
},
|
||||
"outputDir": "{outputFolder}",
|
||||
"outputFileName": "pre_publish_{filename}.PDF"
|
||||
}
|
||||
+21
-4
@@ -24,6 +24,7 @@ import org.mockito.ArgumentMatchers;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
@@ -86,9 +87,13 @@ class EditTableOfContentsControllerTest {
|
||||
when(mockOutlineItem.getNextSibling()).thenReturn(null);
|
||||
|
||||
// When
|
||||
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
|
||||
ResponseEntity<List<Map<String, Object>>> response =
|
||||
editTableOfContentsController.extractBookmarks(mockFile);
|
||||
|
||||
// Then
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
List<Map<String, Object>> result = response.getBody();
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.size());
|
||||
|
||||
@@ -108,9 +113,13 @@ class EditTableOfContentsControllerTest {
|
||||
when(mockCatalog.getDocumentOutline()).thenReturn(null);
|
||||
|
||||
// When
|
||||
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
|
||||
ResponseEntity<List<Map<String, Object>>> response =
|
||||
editTableOfContentsController.extractBookmarks(mockFile);
|
||||
|
||||
// Then
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
List<Map<String, Object>> result = response.getBody();
|
||||
assertNotNull(result);
|
||||
assertTrue(result.isEmpty());
|
||||
verify(mockDocument).close();
|
||||
@@ -142,9 +151,13 @@ class EditTableOfContentsControllerTest {
|
||||
when(childItem.getNextSibling()).thenReturn(null);
|
||||
|
||||
// When
|
||||
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
|
||||
ResponseEntity<List<Map<String, Object>>> response =
|
||||
editTableOfContentsController.extractBookmarks(mockFile);
|
||||
|
||||
// Then
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
List<Map<String, Object>> result = response.getBody();
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.size());
|
||||
|
||||
@@ -178,9 +191,13 @@ class EditTableOfContentsControllerTest {
|
||||
when(mockOutlineItem.getNextSibling()).thenReturn(null);
|
||||
|
||||
// When
|
||||
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
|
||||
ResponseEntity<List<Map<String, Object>>> response =
|
||||
editTableOfContentsController.extractBookmarks(mockFile);
|
||||
|
||||
// Then
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
List<Map<String, Object>> result = response.getBody();
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.size());
|
||||
|
||||
|
||||
+5
@@ -116,6 +116,10 @@ public class ProprietaryUIDataController {
|
||||
LoginData data = new LoginData();
|
||||
Map<String, String> providerList = new HashMap<>();
|
||||
Security securityProps = applicationProperties.getSecurity();
|
||||
|
||||
// Add enableLogin flag so frontend doesn't need to call /app-config
|
||||
data.setEnableLogin(securityProps.getEnableLogin());
|
||||
|
||||
OAUTH2 oauth = securityProps.getOauth2();
|
||||
|
||||
if (oauth != null && oauth.getEnabled()) {
|
||||
@@ -448,6 +452,7 @@ public class ProprietaryUIDataController {
|
||||
|
||||
@Data
|
||||
public static class LoginData {
|
||||
private Boolean enableLogin;
|
||||
private Map<String, String> providerList;
|
||||
private String loginMethod;
|
||||
private boolean altLogin;
|
||||
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
package stirling.software.proprietary.controller.api.form;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.proprietary.util.FormUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/form")
|
||||
@Tag(name = "Forms", description = "PDF form APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class FormFillController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private static ResponseEntity<byte[]> saveDocument(PDDocument document, String baseName)
|
||||
throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), baseName + ".pdf");
|
||||
}
|
||||
|
||||
private static String buildBaseName(MultipartFile file, String suffix) {
|
||||
String original = Filenames.toSimpleFileName(file.getOriginalFilename());
|
||||
if (original == null || original.isBlank()) {
|
||||
original = "document";
|
||||
}
|
||||
if (!original.toLowerCase().endsWith(".pdf")) {
|
||||
return original + "_" + suffix;
|
||||
}
|
||||
String withoutExtension = original.substring(0, original.length() - 4);
|
||||
return withoutExtension + "_" + suffix;
|
||||
}
|
||||
|
||||
private static void requirePdf(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.fileFormatRequired", "{0} must be in PDF format", "file");
|
||||
}
|
||||
}
|
||||
|
||||
private static String decodePart(byte[] payload) {
|
||||
if (payload == null || payload.length == 0) {
|
||||
return null;
|
||||
}
|
||||
return new String(payload, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Inspect PDF form fields",
|
||||
description = "Returns metadata describing each field in the provided PDF form")
|
||||
public ResponseEntity<FormUtils.FormFieldExtraction> listFields(
|
||||
@Parameter(
|
||||
description = "The input PDF file",
|
||||
required = true,
|
||||
content =
|
||||
@Content(
|
||||
mediaType = MediaType.APPLICATION_PDF_VALUE,
|
||||
schema = @Schema(type = "string", format = "binary")))
|
||||
@RequestParam("file")
|
||||
MultipartFile file)
|
||||
throws IOException {
|
||||
|
||||
requirePdf(file);
|
||||
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
|
||||
FormUtils.FormFieldExtraction extraction =
|
||||
FormUtils.extractFieldsWithTemplate(document);
|
||||
return ResponseEntity.ok(extraction);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Modify existing form fields",
|
||||
description =
|
||||
"Updates existing fields in the provided PDF and returns the updated file")
|
||||
public ResponseEntity<byte[]> modifyFields(
|
||||
@Parameter(
|
||||
description = "The input PDF file",
|
||||
required = true,
|
||||
content =
|
||||
@Content(
|
||||
mediaType = MediaType.APPLICATION_PDF_VALUE,
|
||||
schema = @Schema(type = "string", format = "binary")))
|
||||
@RequestParam("file")
|
||||
MultipartFile file,
|
||||
@RequestPart(value = "updates", required = false) byte[] updatesPayload)
|
||||
throws IOException {
|
||||
|
||||
String rawUpdates = decodePart(updatesPayload);
|
||||
List<FormUtils.ModifyFormFieldDefinition> modifications =
|
||||
FormPayloadParser.parseModificationDefinitions(objectMapper, rawUpdates);
|
||||
if (modifications.isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.dataRequired",
|
||||
"{0} must contain at least one definition",
|
||||
"updates payload");
|
||||
}
|
||||
|
||||
return processSingleFile(
|
||||
file, "updated", document -> FormUtils.modifyFormFields(document, modifications));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/delete-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Delete form fields",
|
||||
description = "Removes the specified fields from the PDF and returns the updated file")
|
||||
public ResponseEntity<byte[]> deleteFields(
|
||||
@Parameter(
|
||||
description = "The input PDF file",
|
||||
required = true,
|
||||
content =
|
||||
@Content(
|
||||
mediaType = MediaType.APPLICATION_PDF_VALUE,
|
||||
schema = @Schema(type = "string", format = "binary")))
|
||||
@RequestParam("file")
|
||||
MultipartFile file,
|
||||
@Parameter(
|
||||
description =
|
||||
"JSON array of field names or objects with a name property,"
|
||||
+ " matching the /fields response format",
|
||||
example = "[{\"name\":\"Field1\"}]")
|
||||
@RequestPart(value = "names", required = false)
|
||||
byte[] namesPayload)
|
||||
throws IOException {
|
||||
|
||||
String rawNames = decodePart(namesPayload);
|
||||
List<String> names = FormPayloadParser.parseNameList(objectMapper, rawNames);
|
||||
if (names.isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.dataRequired", "{0} must contain at least one value", "names payload");
|
||||
}
|
||||
|
||||
return processSingleFile(
|
||||
file, "updated", document -> FormUtils.deleteFormFields(document, names));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/fill", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Fill PDF form fields",
|
||||
description =
|
||||
"Populates the supplied PDF form using values from the provided JSON payload"
|
||||
+ " and returns the filled PDF")
|
||||
public ResponseEntity<byte[]> fillForm(
|
||||
@Parameter(
|
||||
description = "The input PDF file",
|
||||
required = true,
|
||||
content =
|
||||
@Content(
|
||||
mediaType = MediaType.APPLICATION_PDF_VALUE,
|
||||
schema = @Schema(type = "string", format = "binary")))
|
||||
@RequestParam("file")
|
||||
MultipartFile file,
|
||||
@Parameter(
|
||||
description = "JSON object of field-value pairs to apply",
|
||||
example = "{\"field\":\"value\"}")
|
||||
@RequestPart(value = "data", required = false)
|
||||
byte[] valuesPayload,
|
||||
@RequestParam(value = "flatten", defaultValue = "false") boolean flatten)
|
||||
throws IOException {
|
||||
|
||||
String rawValues = decodePart(valuesPayload);
|
||||
Map<String, Object> values = FormPayloadParser.parseValueMap(objectMapper, rawValues);
|
||||
|
||||
return processSingleFile(
|
||||
file,
|
||||
"filled",
|
||||
document -> FormUtils.applyFieldValues(document, values, flatten, true));
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> processSingleFile(
|
||||
MultipartFile file, String suffix, DocumentProcessor processor) throws IOException {
|
||||
requirePdf(file);
|
||||
|
||||
String baseName = buildBaseName(file, suffix);
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
processor.accept(document);
|
||||
return saveDocument(document, baseName);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface DocumentProcessor {
|
||||
void accept(PDDocument document) throws IOException;
|
||||
}
|
||||
}
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
package stirling.software.proprietary.controller.api.form;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.proprietary.util.FormUtils;
|
||||
|
||||
final class FormPayloadParser {
|
||||
|
||||
private static final String KEY_FIELDS = "fields";
|
||||
private static final String KEY_NAME = "name";
|
||||
private static final String KEY_TARGET_NAME = "targetName";
|
||||
private static final String KEY_FIELD_NAME = "fieldName";
|
||||
private static final String KEY_FIELD = "field";
|
||||
private static final String KEY_VALUE = "value";
|
||||
private static final String KEY_DEFAULT_VALUE = "defaultValue";
|
||||
|
||||
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
|
||||
private static final TypeReference<List<FormUtils.ModifyFormFieldDefinition>>
|
||||
MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {};
|
||||
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
|
||||
|
||||
private FormPayloadParser() {}
|
||||
|
||||
static Map<String, Object> parseValueMap(ObjectMapper objectMapper, String json)
|
||||
throws IOException {
|
||||
if (json == null || json.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
JsonNode root;
|
||||
try {
|
||||
root = objectMapper.readTree(json);
|
||||
} catch (IOException e) {
|
||||
// Fallback to legacy direct map parse (will throw again if invalid)
|
||||
return objectMapper.readValue(json, MAP_TYPE);
|
||||
}
|
||||
if (root == null || root.isNull()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
// 1. If payload already a flat object with no special wrapping, keep legacy behavior
|
||||
if (root.isObject()) {
|
||||
// a) Prefer explicit 'template' object if present (new combined /fields response)
|
||||
JsonNode templateNode = root.get("template");
|
||||
if (templateNode != null && templateNode.isObject()) {
|
||||
return objectToLinkedMap(templateNode);
|
||||
}
|
||||
// b) Accept an inline 'fields' array of field definitions (build map from them)
|
||||
JsonNode fieldsNode = root.get(KEY_FIELDS);
|
||||
if (fieldsNode != null && fieldsNode.isArray()) {
|
||||
Map<String, Object> record = extractFieldInfoArray(fieldsNode);
|
||||
if (!record.isEmpty()) {
|
||||
return record;
|
||||
}
|
||||
}
|
||||
// c) Fallback: treat entire object as the value map (legacy behavior)
|
||||
return objectToLinkedMap(root);
|
||||
}
|
||||
|
||||
// 2. If an array was supplied to /fill (non-standard), treat first element as record
|
||||
if (root.isArray()) {
|
||||
if (root.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
JsonNode first = root.get(0);
|
||||
if (first != null && first.isObject()) {
|
||||
if (first.has(KEY_NAME) || first.has(KEY_VALUE) || first.has(KEY_DEFAULT_VALUE)) {
|
||||
return extractFieldInfoArray(root);
|
||||
}
|
||||
return objectToLinkedMap(first);
|
||||
}
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
// 3. Anything else: fallback to strict map parse
|
||||
return objectMapper.readValue(json, MAP_TYPE);
|
||||
}
|
||||
|
||||
static List<FormUtils.ModifyFormFieldDefinition> parseModificationDefinitions(
|
||||
ObjectMapper objectMapper, String json) throws IOException {
|
||||
if (json == null || json.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE);
|
||||
}
|
||||
|
||||
static List<String> parseNameList(ObjectMapper objectMapper, String json) throws IOException {
|
||||
if (json == null || json.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
final JsonNode root = objectMapper.readTree(json);
|
||||
if (root == null || root.isNull()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
final Set<String> names = new LinkedHashSet<>();
|
||||
|
||||
if (root.isArray()) {
|
||||
collectNames(root, names);
|
||||
} else if (root.isObject()) {
|
||||
if (root.has(KEY_FIELDS) && root.get(KEY_FIELDS).isArray()) {
|
||||
collectNames(root.get(KEY_FIELDS), names);
|
||||
} else {
|
||||
final String single = extractName(root);
|
||||
if (nonBlank(single)) {
|
||||
names.add(single);
|
||||
}
|
||||
}
|
||||
} else if (root.isTextual()) {
|
||||
final String single = trimToNull(root.asText());
|
||||
if (single != null) {
|
||||
names.add(single);
|
||||
}
|
||||
}
|
||||
|
||||
if (!names.isEmpty()) {
|
||||
return List.copyOf(names);
|
||||
}
|
||||
|
||||
try {
|
||||
return objectMapper.readValue(json, STRING_LIST_TYPE);
|
||||
} catch (IOException e) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid {0} format: {1}",
|
||||
"names payload",
|
||||
"expected array of strings or objects with 'name'-like properties");
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> extractFieldInfoArray(JsonNode fieldsNode) {
|
||||
final Map<String, Object> record = new LinkedHashMap<>();
|
||||
if (fieldsNode == null || fieldsNode.isNull() || !fieldsNode.isArray()) {
|
||||
return record;
|
||||
}
|
||||
|
||||
for (JsonNode fieldNode : fieldsNode) {
|
||||
if (fieldNode == null || !fieldNode.isObject()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final String name = extractName(fieldNode);
|
||||
if (!nonBlank(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonNode valueNode = fieldNode.get(KEY_VALUE);
|
||||
if ((valueNode == null || valueNode.isNull())
|
||||
&& fieldNode.hasNonNull(KEY_DEFAULT_VALUE)) {
|
||||
valueNode = fieldNode.get(KEY_DEFAULT_VALUE);
|
||||
}
|
||||
|
||||
final String normalized = normalizeFieldValue(valueNode);
|
||||
record.put(name, normalized == null ? "" : normalized);
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
private static String normalizeFieldValue(JsonNode valueNode) {
|
||||
if (valueNode == null || valueNode.isNull()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (valueNode.isArray()) {
|
||||
final List<String> values = new ArrayList<>();
|
||||
for (JsonNode element : valueNode) {
|
||||
final String text = coerceScalarToString(element);
|
||||
if (text != null) {
|
||||
values.add(text);
|
||||
}
|
||||
}
|
||||
return String.join(",", values);
|
||||
}
|
||||
|
||||
if (valueNode.isObject()) {
|
||||
// Preserve object as JSON string
|
||||
return valueNode.toString();
|
||||
}
|
||||
|
||||
// Scalar (text/number/boolean)
|
||||
return coerceScalarToString(valueNode);
|
||||
}
|
||||
|
||||
private static String coerceScalarToString(JsonNode node) {
|
||||
if (node == null || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
if (node.isTextual()) {
|
||||
return trimToEmpty(node.asText());
|
||||
}
|
||||
if (node.isNumber()) {
|
||||
return node.numberValue().toString();
|
||||
}
|
||||
if (node.isBoolean()) {
|
||||
return Boolean.toString(node.booleanValue());
|
||||
}
|
||||
// Fallback for other scalar-like nodes
|
||||
return trimToEmpty(node.asText());
|
||||
}
|
||||
|
||||
private static void collectNames(JsonNode arrayNode, Set<String> sink) {
|
||||
if (arrayNode == null || !arrayNode.isArray()) {
|
||||
return;
|
||||
}
|
||||
for (JsonNode node : arrayNode) {
|
||||
final String name = extractName(node);
|
||||
if (nonBlank(name)) {
|
||||
sink.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractName(JsonNode node) {
|
||||
if (node == null || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.isTextual()) {
|
||||
return trimToNull(node.asText());
|
||||
}
|
||||
|
||||
if (node.isObject()) {
|
||||
final String direct = textProperty(node, KEY_NAME, KEY_TARGET_NAME, KEY_FIELD_NAME);
|
||||
if (nonBlank(direct)) {
|
||||
return direct;
|
||||
}
|
||||
final JsonNode field = node.get(KEY_FIELD);
|
||||
if (field != null && field.isObject()) {
|
||||
final String nested =
|
||||
textProperty(field, KEY_NAME, KEY_TARGET_NAME, KEY_FIELD_NAME);
|
||||
if (nonBlank(nested)) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String textProperty(JsonNode node, String... keys) {
|
||||
for (String key : keys) {
|
||||
final JsonNode valueNode = node.get(key);
|
||||
final String value = coerceScalarToString(valueNode);
|
||||
if (nonBlank(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Map<String, Object> objectToLinkedMap(JsonNode objectNode) {
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
objectNode
|
||||
.fieldNames()
|
||||
.forEachRemaining(
|
||||
key -> {
|
||||
final JsonNode v = objectNode.get(key);
|
||||
if (v == null || v.isNull()) {
|
||||
result.put(key, null);
|
||||
} else if (v.isTextual() || v.isNumber() || v.isBoolean()) {
|
||||
result.put(key, coerceScalarToString(v));
|
||||
} else {
|
||||
result.put(key, v.toString());
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private static boolean nonBlank(String s) {
|
||||
return s != null && !s.isBlank();
|
||||
}
|
||||
|
||||
private static String trimToNull(String s) {
|
||||
if (s == null) return null;
|
||||
final String t = s.trim();
|
||||
return t.isEmpty() ? null : t;
|
||||
}
|
||||
|
||||
private static String trimToEmpty(String s) {
|
||||
return s == null ? "" : s.trim();
|
||||
}
|
||||
}
|
||||
+41
-49
@@ -129,61 +129,53 @@ public class SecurityConfiguration {
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
// Read CORS allowed origins from settings
|
||||
if (applicationProperties.getSystem() != null
|
||||
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
|
||||
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
|
||||
List<String> configuredOrigins = null;
|
||||
if (applicationProperties.getSystem() != null) {
|
||||
configuredOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
|
||||
}
|
||||
|
||||
List<String> allowedOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
|
||||
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
|
||||
// Use setAllowedOriginPatterns for better wildcard and port support
|
||||
cfg.setAllowedOriginPatterns(allowedOrigins);
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
if (configuredOrigins != null && !configuredOrigins.isEmpty()) {
|
||||
cfg.setAllowedOriginPatterns(configuredOrigins);
|
||||
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;
|
||||
configuredOrigins);
|
||||
} else {
|
||||
// No CORS origins configured - return null to disable CORS processing entirely
|
||||
// This avoids empty CORS policy that unexpectedly rejects preflights
|
||||
// Default to allowing all origins when nothing is configured
|
||||
cfg.setAllowedOriginPatterns(List.of("*"));
|
||||
log.info(
|
||||
"CORS is disabled - no allowed origins configured in settings.yml (system.corsAllowedOrigins)");
|
||||
return null;
|
||||
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
|
||||
}
|
||||
|
||||
// Explicitly configure supported HTTP methods (include OPTIONS for preflight)
|
||||
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||
|
||||
cfg.setAllowedHeaders(
|
||||
List.of(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-API-KEY",
|
||||
"X-CSRF-TOKEN",
|
||||
"X-XSRF-TOKEN"));
|
||||
|
||||
cfg.setExposedHeaders(
|
||||
List.of(
|
||||
"WWW-Authenticate",
|
||||
"X-Total-Count",
|
||||
"X-Page-Number",
|
||||
"X-Page-Size",
|
||||
"Content-Disposition",
|
||||
"Content-Type"));
|
||||
|
||||
cfg.setAllowCredentials(true);
|
||||
cfg.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", cfg);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+6
-1
@@ -113,7 +113,12 @@ public class LicenseKeyChecker {
|
||||
|
||||
public void updateLicenseKey(String newKey) throws IOException {
|
||||
applicationProperties.getPremium().setKey(newKey);
|
||||
GeneralUtils.saveKeyToSettings("EnterpriseEdition.key", newKey);
|
||||
GeneralUtils.saveKeyToSettings("premium.key", newKey);
|
||||
evaluateLicense();
|
||||
synchronizeLicenseSettings();
|
||||
}
|
||||
|
||||
public void resyncLicense() {
|
||||
evaluateLicense();
|
||||
synchronizeLicenseSettings();
|
||||
}
|
||||
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
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.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier;
|
||||
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
|
||||
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
|
||||
|
||||
/**
|
||||
* Admin controller for license management. Provides installation ID for Stripe checkout metadata
|
||||
* and endpoints for managing license keys.
|
||||
*/
|
||||
@RestController
|
||||
@Slf4j
|
||||
@RequestMapping("/api/v1/admin")
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@Tag(name = "Admin License Management", description = "Admin-only License Management APIs")
|
||||
public class AdminLicenseController {
|
||||
|
||||
@Autowired(required = false)
|
||||
private LicenseKeyChecker licenseKeyChecker;
|
||||
|
||||
@Autowired(required = false)
|
||||
private KeygenLicenseVerifier keygenLicenseVerifier;
|
||||
|
||||
@Autowired private ApplicationProperties applicationProperties;
|
||||
|
||||
/**
|
||||
* Get the installation ID (machine fingerprint) for this self-hosted instance. This ID is used
|
||||
* as metadata in Stripe checkout to link licenses to specific installations.
|
||||
*
|
||||
* @return Map containing the installation ID
|
||||
*/
|
||||
@GetMapping("/installation-id")
|
||||
@Operation(
|
||||
summary = "Get installation ID",
|
||||
description =
|
||||
"Returns the unique installation ID (MAC-based fingerprint) for this"
|
||||
+ " self-hosted instance")
|
||||
public ResponseEntity<Map<String, String>> getInstallationId() {
|
||||
try {
|
||||
String installationId = GeneralUtils.generateMachineFingerprint();
|
||||
log.info("Admin requested installation ID: {}", installationId);
|
||||
return ResponseEntity.ok(Map.of("installationId", installationId));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate installation ID", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("error", "Failed to generate installation ID"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save and activate a license key. This endpoint accepts a license key from the frontend (e.g.,
|
||||
* after Stripe checkout) and activates it on the backend.
|
||||
*
|
||||
* @param request Map containing the license key
|
||||
* @return Response with success status, license type, and whether restart is required
|
||||
*/
|
||||
@PostMapping("/license-key")
|
||||
@Operation(
|
||||
summary = "Save and activate license key",
|
||||
description =
|
||||
"Accepts a license key and activates it on the backend. Returns the activated"
|
||||
+ " license type.")
|
||||
public ResponseEntity<Map<String, Object>> saveLicenseKey(
|
||||
@RequestBody Map<String, String> request) {
|
||||
String licenseKey = request.get("licenseKey");
|
||||
|
||||
// Reject null but allow empty string to clear license
|
||||
if (licenseKey == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "error", "License key is required"));
|
||||
}
|
||||
|
||||
try {
|
||||
if (licenseKeyChecker == null) {
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("success", false, "error", "License checker not available"));
|
||||
}
|
||||
// assume premium enabled when setting license key
|
||||
applicationProperties.getPremium().setEnabled(true);
|
||||
|
||||
// Use existing LicenseKeyChecker to update and validate license
|
||||
// Empty string will be evaluated as NORMAL license (free tier)
|
||||
licenseKeyChecker.updateLicenseKey(licenseKey.trim());
|
||||
|
||||
// Get current license status
|
||||
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
|
||||
|
||||
// Auto-enable premium features if license is valid
|
||||
if (license != License.NORMAL) {
|
||||
GeneralUtils.saveKeyToSettings("premium.enabled", true);
|
||||
// Enable premium features
|
||||
|
||||
// Save maxUsers from license metadata
|
||||
Integer maxUsers = applicationProperties.getPremium().getMaxUsers();
|
||||
if (maxUsers != null) {
|
||||
GeneralUtils.saveKeyToSettings("premium.maxUsers", maxUsers);
|
||||
}
|
||||
} else {
|
||||
GeneralUtils.saveKeyToSettings("premium.enabled", false);
|
||||
log.info("License key is not valid for premium features: type={}", license.name());
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("licenseType", license.name());
|
||||
response.put("enabled", applicationProperties.getPremium().isEnabled());
|
||||
response.put("maxUsers", applicationProperties.getPremium().getMaxUsers());
|
||||
response.put("requiresRestart", false); // Dynamic evaluation works
|
||||
response.put("message", "License key saved and activated");
|
||||
|
||||
log.info("License key saved and activated: type={}", license.name());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to save license key", e);
|
||||
return ResponseEntity.badRequest()
|
||||
.body(
|
||||
Map.of(
|
||||
"success",
|
||||
false,
|
||||
"error",
|
||||
"Failed to activate license: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resync the current license with Keygen. This endpoint re-validates the existing license key
|
||||
* and updates the max users setting. Used after subscription upgrades to sync the new license
|
||||
* limits.
|
||||
*
|
||||
* @return Response with updated license information
|
||||
*/
|
||||
@PostMapping("/license/resync")
|
||||
@Operation(
|
||||
summary = "Resync license with Keygen",
|
||||
description =
|
||||
"Re-validates the existing license key with Keygen and updates local settings."
|
||||
+ " Used after subscription upgrades.")
|
||||
public ResponseEntity<Map<String, Object>> resyncLicense() {
|
||||
try {
|
||||
if (licenseKeyChecker == null) {
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("success", false, "error", "License checker not available"));
|
||||
}
|
||||
|
||||
String currentKey = applicationProperties.getPremium().getKey();
|
||||
if (currentKey == null || currentKey.trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "error", "No license key configured"));
|
||||
}
|
||||
|
||||
log.info("Resyncing license with Keygen");
|
||||
|
||||
// Re-validate license and sync settings
|
||||
licenseKeyChecker.resyncLicense();
|
||||
|
||||
// Get updated license status
|
||||
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
|
||||
ApplicationProperties.Premium premium = applicationProperties.getPremium();
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("licenseType", license.name());
|
||||
response.put("enabled", premium.isEnabled());
|
||||
response.put("maxUsers", premium.getMaxUsers());
|
||||
response.put("message", "License resynced successfully");
|
||||
|
||||
log.info(
|
||||
"License resynced: type={}, maxUsers={}",
|
||||
license.name(),
|
||||
premium.getMaxUsers());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to resync license", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(
|
||||
Map.of(
|
||||
"success",
|
||||
false,
|
||||
"error",
|
||||
"Failed to resync license: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about the current license key status, including license type, enabled status,
|
||||
* and max users.
|
||||
*
|
||||
* @return Map containing license information
|
||||
*/
|
||||
@GetMapping("/license-info")
|
||||
@Operation(
|
||||
summary = "Get license information",
|
||||
description =
|
||||
"Returns information about the current license including type, enabled status,"
|
||||
+ " and max users")
|
||||
public ResponseEntity<Map<String, Object>> getLicenseInfo() {
|
||||
try {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
if (licenseKeyChecker != null) {
|
||||
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
|
||||
response.put("licenseType", license.name());
|
||||
} else {
|
||||
response.put("licenseType", License.NORMAL.name());
|
||||
}
|
||||
|
||||
ApplicationProperties.Premium premium = applicationProperties.getPremium();
|
||||
response.put("enabled", premium.isEnabled());
|
||||
response.put("maxUsers", premium.getMaxUsers());
|
||||
response.put("hasKey", premium.getKey() != null && !premium.getKey().trim().isEmpty());
|
||||
|
||||
// Include license key for upgrades (admin-only endpoint)
|
||||
if (premium.getKey() != null && !premium.getKey().trim().isEmpty()) {
|
||||
response.put("licenseKey", premium.getKey());
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to get license info", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("error", "Failed to retrieve license information"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -299,6 +299,16 @@ public class AdminSettingsController {
|
||||
+ String.join(", ", VALID_SECTION_NAMES));
|
||||
}
|
||||
|
||||
// Auto-enable premium features if license key is provided
|
||||
if ("premium".equalsIgnoreCase(sectionName) && sectionData.containsKey("key")) {
|
||||
Object keyValue = sectionData.get("key");
|
||||
if (keyValue != null && !keyValue.toString().trim().isEmpty()) {
|
||||
// Automatically set enabled to true when a key is provided
|
||||
sectionData.put("enabled", true);
|
||||
log.info("Auto-enabling premium features because license key was provided");
|
||||
}
|
||||
}
|
||||
|
||||
int updatedCount = 0;
|
||||
for (Map.Entry<String, Object> entry : sectionData.entrySet()) {
|
||||
String propertyKey = entry.getKey();
|
||||
|
||||
+2
-4
@@ -15,7 +15,7 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.UserApi;
|
||||
import stirling.software.common.annotations.api.InviteApi;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
@@ -26,11 +26,9 @@ import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@UserApi
|
||||
@InviteApi
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/invite")
|
||||
public class InviteLinkController {
|
||||
|
||||
private final InviteTokenRepository inviteTokenRepository;
|
||||
|
||||
+27
@@ -742,4 +742,31 @@ public class UserController {
|
||||
return errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/complete-initial-setup")
|
||||
public ResponseEntity<?> completeInitialSetup() {
|
||||
try {
|
||||
String username = userService.getCurrentUsername();
|
||||
if (username == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body("User not authenticated");
|
||||
}
|
||||
|
||||
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
|
||||
if (userOpt.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User not found");
|
||||
}
|
||||
|
||||
User user = userOpt.get();
|
||||
user.setHasCompletedInitialSetup(true);
|
||||
userRepository.save(user);
|
||||
|
||||
log.info("User {} completed initial setup", username);
|
||||
return ResponseEntity.ok().body(Map.of("success", true));
|
||||
} catch (Exception e) {
|
||||
log.error("Error completing initial setup", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("Failed to complete initial setup");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -107,7 +107,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
}
|
||||
}
|
||||
|
||||
// If we still don't have any authentication, check if it's a public endpoint. If not, deny the request
|
||||
// If we still don't have any authentication, 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();
|
||||
@@ -222,7 +223,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
|| trimmedUri.startsWith("/saml2")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/login")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/logout");
|
||||
|| trimmedUri.startsWith("/api/v1/auth/logout")
|
||||
|| trimmedUri.startsWith("/api/v1/proprietary/ui-data/login");
|
||||
}
|
||||
|
||||
private enum UserLoginType {
|
||||
|
||||
@@ -56,6 +56,9 @@ public class User implements UserDetails, Serializable {
|
||||
@Column(name = "isFirstLogin")
|
||||
private Boolean isFirstLogin = false;
|
||||
|
||||
@Column(name = "hasCompletedInitialSetup")
|
||||
private Boolean hasCompletedInitialSetup = false;
|
||||
|
||||
@Column(name = "roleName")
|
||||
private String roleName;
|
||||
|
||||
@@ -103,6 +106,14 @@ public class User implements UserDetails, Serializable {
|
||||
this.isFirstLogin = isFirstLogin;
|
||||
}
|
||||
|
||||
public boolean hasCompletedInitialSetup() {
|
||||
return hasCompletedInitialSetup != null && hasCompletedInitialSetup;
|
||||
}
|
||||
|
||||
public void setHasCompletedInitialSetup(boolean hasCompletedInitialSetup) {
|
||||
this.hasCompletedInitialSetup = hasCompletedInitialSetup;
|
||||
}
|
||||
|
||||
public void setAuthenticationType(AuthenticationType authenticationType) {
|
||||
this.authenticationType = authenticationType.toString().toLowerCase();
|
||||
}
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
|
||||
public class JwtService implements JwtServiceInterface {
|
||||
|
||||
private static final String ISSUER = "https://stirling.com";
|
||||
private static final long EXPIRATION = 3600000;
|
||||
private static final long EXPIRATION = 43200000;
|
||||
|
||||
private final KeyPersistenceServiceInterface keyPersistenceService;
|
||||
private final boolean v2Enabled;
|
||||
|
||||
+15
@@ -663,6 +663,21 @@ public class UserService implements UserServiceInterface {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean isCurrentUserFirstLogin() {
|
||||
try {
|
||||
String username = getCurrentUsername();
|
||||
if (username != null) {
|
||||
Optional<User> userOpt = findByUsernameIgnoreCase(username);
|
||||
if (userOpt.isPresent()) {
|
||||
return !userOpt.get().hasCompletedInitialSetup();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Error checking first login status", e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void syncCustomApiUser(String customApiKey) {
|
||||
if (customApiKey == null || customApiKey.trim().isBlank()) {
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
package stirling.software.proprietary.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
public class FormCopyUtils {
|
||||
|
||||
public boolean hasAnyRotatedPage(PDDocument document) {
|
||||
try {
|
||||
for (PDPage page : document.getPages()) {
|
||||
int rot = page.getRotation();
|
||||
int norm = ((rot % 360) + 360) % 360;
|
||||
if (norm != 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to inspect page rotations: {}", e.getMessage(), e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void copyAndTransformFormFields(
|
||||
PDDocument sourceDocument,
|
||||
PDDocument newDocument,
|
||||
int totalPages,
|
||||
int pagesPerSheet,
|
||||
int cols,
|
||||
int rows,
|
||||
float cellWidth,
|
||||
float cellHeight)
|
||||
throws IOException {
|
||||
|
||||
PDDocumentCatalog sourceCatalog = sourceDocument.getDocumentCatalog();
|
||||
PDAcroForm sourceAcroForm = sourceCatalog.getAcroForm();
|
||||
|
||||
if (sourceAcroForm == null || sourceAcroForm.getFields().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PDDocumentCatalog newCatalog = newDocument.getDocumentCatalog();
|
||||
PDAcroForm newAcroForm = new PDAcroForm(newDocument);
|
||||
newCatalog.setAcroForm(newAcroForm);
|
||||
|
||||
PDResources dr = new PDResources();
|
||||
PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
PDType1Font zapfDingbats = new PDType1Font(Standard14Fonts.FontName.ZAPF_DINGBATS);
|
||||
dr.put(COSName.getPDFName("Helv"), helvetica);
|
||||
dr.put(COSName.getPDFName("ZaDb"), zapfDingbats);
|
||||
newAcroForm.setDefaultResources(dr);
|
||||
newAcroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
|
||||
|
||||
// Temporarily set NeedAppearances to true during field creation
|
||||
newAcroForm.setNeedAppearances(true);
|
||||
|
||||
Map<String, Integer> fieldNameCounters = new HashMap<>();
|
||||
|
||||
// Build widget -> field map once for efficient lookups
|
||||
Map<PDAnnotationWidget, PDField> widgetFieldMap = buildWidgetFieldMap(sourceAcroForm);
|
||||
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
PDPage sourcePage = sourceDocument.getPage(pageIndex);
|
||||
List<PDAnnotation> annotations = sourcePage.getAnnotations();
|
||||
|
||||
if (annotations.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int destinationPageIndex = pageIndex / pagesPerSheet;
|
||||
int adjustedPageIndex = pageIndex % pagesPerSheet;
|
||||
int rowIndex = adjustedPageIndex / cols;
|
||||
int colIndex = adjustedPageIndex % cols;
|
||||
|
||||
if (rowIndex >= rows) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (destinationPageIndex >= newDocument.getNumberOfPages()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
PDPage destinationPage = newDocument.getPage(destinationPageIndex);
|
||||
PDRectangle sourceRect = sourcePage.getMediaBox();
|
||||
|
||||
float scaleWidth = cellWidth / sourceRect.getWidth();
|
||||
float scaleHeight = cellHeight / sourceRect.getHeight();
|
||||
float scale = Math.min(scaleWidth, scaleHeight);
|
||||
|
||||
float x = colIndex * cellWidth + (cellWidth - sourceRect.getWidth() * scale) / 2;
|
||||
float y =
|
||||
destinationPage.getMediaBox().getHeight()
|
||||
- ((rowIndex + 1) * cellHeight
|
||||
- (cellHeight - sourceRect.getHeight() * scale) / 2);
|
||||
|
||||
copyBasicFormFields(
|
||||
sourceAcroForm,
|
||||
newAcroForm,
|
||||
sourcePage,
|
||||
destinationPage,
|
||||
x,
|
||||
y,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters,
|
||||
widgetFieldMap);
|
||||
}
|
||||
|
||||
// Generate appearance streams and embed them authoritatively
|
||||
boolean appearancesGenerated = false;
|
||||
try {
|
||||
newAcroForm.refreshAppearances();
|
||||
appearancesGenerated = true;
|
||||
} catch (NoSuchMethodError nsme) {
|
||||
log.warn(
|
||||
"AcroForm.refreshAppearances() not available in this PDFBox version; "
|
||||
+ "leaving NeedAppearances=true for viewer-side rendering.");
|
||||
} catch (Exception t) {
|
||||
log.warn(
|
||||
"Failed to refresh field appearances via AcroForm: {}. "
|
||||
+ "Leaving NeedAppearances=true as fallback.",
|
||||
t.getMessage(),
|
||||
t);
|
||||
}
|
||||
|
||||
// After successful appearance generation, set NeedAppearances to false
|
||||
// to signal that appearance streams are now embedded authoritatively
|
||||
if (appearancesGenerated) {
|
||||
try {
|
||||
newAcroForm.setNeedAppearances(false);
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Failed to set NeedAppearances to false: {}. "
|
||||
+ "Appearances were generated but flag could not be updated.",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void copyBasicFormFields(
|
||||
PDAcroForm sourceAcroForm,
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage sourcePage,
|
||||
PDPage destinationPage,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters,
|
||||
Map<PDAnnotationWidget, PDField> widgetFieldMap) {
|
||||
|
||||
try {
|
||||
List<PDAnnotation> sourceAnnotations = sourcePage.getAnnotations();
|
||||
List<PDAnnotation> destinationAnnotations = destinationPage.getAnnotations();
|
||||
|
||||
for (PDAnnotation annotation : sourceAnnotations) {
|
||||
if (annotation instanceof PDAnnotationWidget widgetAnnotation) {
|
||||
if (widgetAnnotation.getRectangle() == null) {
|
||||
continue;
|
||||
}
|
||||
PDField sourceField =
|
||||
widgetFieldMap != null ? widgetFieldMap.get(widgetAnnotation) : null;
|
||||
if (sourceField == null) {
|
||||
continue; // skip widgets without a matching field
|
||||
}
|
||||
if (!(sourceField instanceof PDTerminalField terminalField)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
FormFieldTypeSupport handler = FormFieldTypeSupport.forField(terminalField);
|
||||
if (handler == null) {
|
||||
log.debug(
|
||||
"Skipping unsupported field type '{}' for widget '{}'",
|
||||
sourceField.getClass().getSimpleName(),
|
||||
Optional.ofNullable(sourceField.getFullyQualifiedName())
|
||||
.orElseGet(sourceField::getPartialName));
|
||||
continue;
|
||||
}
|
||||
|
||||
copyFieldUsingHandler(
|
||||
handler,
|
||||
terminalField,
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
widgetAnnotation,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to copy basic form fields for page {}: {}",
|
||||
pageIndex,
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private void copyFieldUsingHandler(
|
||||
FormFieldTypeSupport handler,
|
||||
PDTerminalField sourceField,
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters) {
|
||||
|
||||
try {
|
||||
PDTerminalField newField = handler.createField(newAcroForm);
|
||||
boolean initialized =
|
||||
initializeFieldWithWidget(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
newField,
|
||||
sourceField.getPartialName(),
|
||||
handler.fallbackWidgetName(),
|
||||
sourceWidget,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
|
||||
if (!initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
handler.copyFromOriginal(sourceField, newField);
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to copy {} field '{}': {}",
|
||||
handler.typeName(),
|
||||
Optional.ofNullable(sourceField.getFullyQualifiedName())
|
||||
.orElseGet(sourceField::getPartialName),
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private <T extends PDTerminalField> boolean initializeFieldWithWidget(
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
T newField,
|
||||
String originalName,
|
||||
String fallbackName,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters) {
|
||||
|
||||
String baseName = (originalName != null) ? originalName : fallbackName;
|
||||
String newFieldName = generateUniqueFieldName(baseName, pageIndex, fieldNameCounters);
|
||||
newField.setPartialName(newFieldName);
|
||||
|
||||
PDAnnotationWidget newWidget = new PDAnnotationWidget();
|
||||
PDRectangle sourceRect = sourceWidget.getRectangle();
|
||||
if (sourceRect == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
float newX = (sourceRect.getLowerLeftX() * scale) + offsetX;
|
||||
float newY = (sourceRect.getLowerLeftY() * scale) + offsetY;
|
||||
float newWidth = sourceRect.getWidth() * scale;
|
||||
float newHeight = sourceRect.getHeight() * scale;
|
||||
newWidget.setRectangle(new PDRectangle(newX, newY, newWidth, newHeight));
|
||||
newWidget.setPage(destinationPage);
|
||||
|
||||
newField.getWidgets().add(newWidget);
|
||||
newWidget.setParent(newField);
|
||||
newAcroForm.getFields().add(newField);
|
||||
destinationAnnotations.add(newWidget);
|
||||
return true;
|
||||
}
|
||||
|
||||
private String generateUniqueFieldName(
|
||||
String originalName, int pageIndex, Map<String, Integer> fieldNameCounters) {
|
||||
String baseName = "page" + pageIndex + "_" + originalName;
|
||||
|
||||
Integer counter = fieldNameCounters.get(baseName);
|
||||
if (counter == null) {
|
||||
counter = 0;
|
||||
} else {
|
||||
counter++;
|
||||
}
|
||||
fieldNameCounters.put(baseName, counter);
|
||||
|
||||
return counter == 0 ? baseName : baseName + "_" + counter;
|
||||
}
|
||||
|
||||
private Map<PDAnnotationWidget, PDField> buildWidgetFieldMap(PDAcroForm acroForm) {
|
||||
Map<PDAnnotationWidget, PDField> map = new HashMap<>();
|
||||
if (acroForm == null) {
|
||||
return map;
|
||||
}
|
||||
try {
|
||||
for (PDField field : acroForm.getFieldTree()) {
|
||||
List<PDAnnotationWidget> widgets = field.getWidgets();
|
||||
if (widgets == null) {
|
||||
continue;
|
||||
}
|
||||
for (PDAnnotationWidget widget : widgets) {
|
||||
if (widget != null) {
|
||||
map.put(widget, field);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to build widget->field map: {}", e.getMessage(), e);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
package stirling.software.proprietary.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.graphics.color.PDColor;
|
||||
import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDChoice;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDListBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public enum FormFieldTypeSupport {
|
||||
TEXT("text", "textField", PDTextField.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
PDTextField textField = new PDTextField(acroForm);
|
||||
textField.setDefaultAppearance("/Helv 12 Tf 0 g");
|
||||
return textField;
|
||||
}
|
||||
|
||||
@Override
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
PDTextField src = (PDTextField) source;
|
||||
PDTextField dst = (PDTextField) target;
|
||||
String value = src.getValueAsString();
|
||||
if (value != null) {
|
||||
dst.setValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
boolean doesNotsupportsDefinitionCreation() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
void applyNewFieldDefinition(
|
||||
PDTerminalField field,
|
||||
FormUtils.NewFormFieldDefinition definition,
|
||||
List<String> options)
|
||||
throws IOException {
|
||||
PDTextField textField = (PDTextField) field;
|
||||
String defaultValue = Optional.ofNullable(definition.defaultValue()).orElse("");
|
||||
if (!defaultValue.isBlank()) {
|
||||
FormUtils.setTextValue(textField, defaultValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
CHECKBOX("checkbox", "checkBox", PDCheckBox.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDCheckBox(acroForm);
|
||||
}
|
||||
|
||||
@Override
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
PDCheckBox src = (PDCheckBox) source;
|
||||
PDCheckBox dst = (PDCheckBox) target;
|
||||
if (src.isChecked()) {
|
||||
dst.check();
|
||||
} else {
|
||||
dst.unCheck();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
boolean doesNotsupportsDefinitionCreation() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
void applyNewFieldDefinition(
|
||||
PDTerminalField field,
|
||||
FormUtils.NewFormFieldDefinition definition,
|
||||
List<String> options)
|
||||
throws IOException {
|
||||
PDCheckBox checkBox = (PDCheckBox) field;
|
||||
|
||||
if (!options.isEmpty()) {
|
||||
checkBox.setExportValues(options);
|
||||
}
|
||||
|
||||
ensureCheckBoxAppearance(checkBox);
|
||||
|
||||
if (FormUtils.isChecked(definition.defaultValue())) {
|
||||
checkBox.check();
|
||||
} else {
|
||||
checkBox.unCheck();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureCheckBoxAppearance(PDCheckBox checkBox) {
|
||||
try {
|
||||
if (checkBox.getWidgets().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PDAnnotationWidget widget = checkBox.getWidgets().get(0);
|
||||
|
||||
PDAppearanceCharacteristicsDictionary appearanceChars =
|
||||
widget.getAppearanceCharacteristics();
|
||||
if (appearanceChars == null) {
|
||||
appearanceChars =
|
||||
new PDAppearanceCharacteristicsDictionary(widget.getCOSObject());
|
||||
widget.setAppearanceCharacteristics(appearanceChars);
|
||||
}
|
||||
|
||||
appearanceChars.setBorderColour(
|
||||
new PDColor(new float[] {0, 0, 0}, PDDeviceRGB.INSTANCE));
|
||||
appearanceChars.setBackground(
|
||||
new PDColor(new float[] {1, 1, 1}, PDDeviceRGB.INSTANCE));
|
||||
|
||||
appearanceChars.setNormalCaption("4");
|
||||
|
||||
widget.setPrinted(true);
|
||||
widget.setReadOnly(false);
|
||||
widget.setHidden(false);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.debug("Unable to set checkbox appearance characteristics: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
},
|
||||
RADIO("radio", "radioButton", PDRadioButton.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDRadioButton(acroForm);
|
||||
}
|
||||
|
||||
@Override
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
PDRadioButton src = (PDRadioButton) source;
|
||||
PDRadioButton dst = (PDRadioButton) target;
|
||||
if (src.getExportValues() != null) {
|
||||
dst.setExportValues(src.getExportValues());
|
||||
}
|
||||
if (src.getValue() != null) {
|
||||
dst.setValue(src.getValue());
|
||||
}
|
||||
}
|
||||
},
|
||||
COMBOBOX("combobox", "comboBox", PDComboBox.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDComboBox(acroForm);
|
||||
}
|
||||
|
||||
@Override
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
PDComboBox src = (PDComboBox) source;
|
||||
PDComboBox dst = (PDComboBox) target;
|
||||
copyChoiceCharacteristics(src, dst);
|
||||
if (src.getOptions() != null) {
|
||||
dst.setOptions(src.getOptions());
|
||||
}
|
||||
if (src.getValue() != null && !src.getValue().isEmpty()) {
|
||||
dst.setValue(src.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
boolean doesNotsupportsDefinitionCreation() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
void applyNewFieldDefinition(
|
||||
PDTerminalField field,
|
||||
FormUtils.NewFormFieldDefinition definition,
|
||||
List<String> options)
|
||||
throws IOException {
|
||||
PDComboBox comboBox = (PDComboBox) field;
|
||||
if (!options.isEmpty()) {
|
||||
comboBox.setOptions(options);
|
||||
}
|
||||
List<String> allowedOptions = FormUtils.resolveOptions(comboBox);
|
||||
String comboName =
|
||||
Optional.ofNullable(comboBox.getFullyQualifiedName())
|
||||
.orElseGet(comboBox::getPartialName);
|
||||
String defaultValue = definition.defaultValue();
|
||||
if (defaultValue != null && !defaultValue.isBlank()) {
|
||||
String filtered =
|
||||
FormUtils.filterSingleChoiceSelection(
|
||||
defaultValue, allowedOptions, comboName);
|
||||
if (filtered != null) {
|
||||
comboBox.setValue(filtered);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
LISTBOX("listbox", "listBox", PDListBox.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDListBox(acroForm);
|
||||
}
|
||||
|
||||
@Override
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
PDListBox src = (PDListBox) source;
|
||||
PDListBox dst = (PDListBox) target;
|
||||
copyChoiceCharacteristics(src, dst);
|
||||
if (src.getOptions() != null) {
|
||||
dst.setOptions(src.getOptions());
|
||||
}
|
||||
if (src.getValue() != null && !src.getValue().isEmpty()) {
|
||||
dst.setValue(src.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
boolean doesNotsupportsDefinitionCreation() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
void applyNewFieldDefinition(
|
||||
PDTerminalField field,
|
||||
FormUtils.NewFormFieldDefinition definition,
|
||||
List<String> options)
|
||||
throws IOException {
|
||||
PDListBox listBox = (PDListBox) field;
|
||||
listBox.setMultiSelect(Boolean.TRUE.equals(definition.multiSelect()));
|
||||
if (!options.isEmpty()) {
|
||||
listBox.setOptions(options);
|
||||
}
|
||||
List<String> allowedOptions = FormUtils.collectChoiceAllowedValues(listBox);
|
||||
String listBoxName =
|
||||
Optional.ofNullable(listBox.getFullyQualifiedName())
|
||||
.orElseGet(listBox::getPartialName);
|
||||
String defaultValue = definition.defaultValue();
|
||||
if (defaultValue != null && !defaultValue.isBlank()) {
|
||||
if (Boolean.TRUE.equals(definition.multiSelect())) {
|
||||
List<String> selections = FormUtils.parseMultiChoiceSelections(defaultValue);
|
||||
List<String> filtered =
|
||||
FormUtils.filterChoiceSelections(
|
||||
selections, allowedOptions, listBoxName);
|
||||
if (!filtered.isEmpty()) {
|
||||
listBox.setValue(filtered);
|
||||
}
|
||||
} else {
|
||||
String filtered =
|
||||
FormUtils.filterSingleChoiceSelection(
|
||||
defaultValue, allowedOptions, listBoxName);
|
||||
if (filtered != null) {
|
||||
listBox.setValue(filtered);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
SIGNATURE("signature", "signature", PDSignatureField.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDSignatureField(acroForm);
|
||||
}
|
||||
},
|
||||
BUTTON("button", "pushButton", PDPushButton.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDPushButton(acroForm);
|
||||
}
|
||||
};
|
||||
|
||||
private static final Map<String, FormFieldTypeSupport> BY_TYPE =
|
||||
Arrays.stream(values())
|
||||
.collect(
|
||||
Collectors.toUnmodifiableMap(
|
||||
FormFieldTypeSupport::typeName, Function.identity()));
|
||||
|
||||
private final String typeName;
|
||||
private final String fallbackWidgetName;
|
||||
private final Class<? extends PDTerminalField> fieldClass;
|
||||
|
||||
FormFieldTypeSupport(
|
||||
String typeName,
|
||||
String fallbackWidgetName,
|
||||
Class<? extends PDTerminalField> fieldClass) {
|
||||
this.typeName = typeName;
|
||||
this.fallbackWidgetName = fallbackWidgetName;
|
||||
this.fieldClass = fieldClass;
|
||||
}
|
||||
|
||||
public static FormFieldTypeSupport forField(PDField field) {
|
||||
if (field == null) {
|
||||
return null;
|
||||
}
|
||||
for (FormFieldTypeSupport handler : values()) {
|
||||
if (handler.fieldClass.isInstance(field)) {
|
||||
return handler;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static FormFieldTypeSupport forTypeName(String typeName) {
|
||||
if (typeName == null) {
|
||||
return null;
|
||||
}
|
||||
return BY_TYPE.get(typeName);
|
||||
}
|
||||
|
||||
private static void copyChoiceCharacteristics(PDChoice sourceField, PDChoice targetField) {
|
||||
if (sourceField == null || targetField == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
int flags = sourceField.getCOSObject().getInt(COSName.FF);
|
||||
targetField.getCOSObject().setInt(COSName.FF, flags);
|
||||
} catch (Exception e) {
|
||||
// ignore and continue
|
||||
}
|
||||
|
||||
if (sourceField instanceof PDListBox sourceList
|
||||
&& targetField instanceof PDListBox targetList) {
|
||||
try {
|
||||
targetList.setMultiSelect(sourceList.isMultiSelect());
|
||||
} catch (Exception ignored) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String typeName() {
|
||||
return typeName;
|
||||
}
|
||||
|
||||
String fallbackWidgetName() {
|
||||
return fallbackWidgetName;
|
||||
}
|
||||
|
||||
abstract PDTerminalField createField(PDAcroForm acroForm);
|
||||
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
// default no-op
|
||||
}
|
||||
|
||||
boolean doesNotsupportsDefinitionCreation() {
|
||||
return true;
|
||||
}
|
||||
|
||||
void applyNewFieldDefinition(
|
||||
PDTerminalField field,
|
||||
FormUtils.NewFormFieldDefinition definition,
|
||||
List<String> options)
|
||||
throws IOException {
|
||||
// default no-op
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
package stirling.software.proprietary.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@Disabled("Covered by integration workflow; unit assertions no longer reflect runtime behavior")
|
||||
class FormUtilsTest {
|
||||
|
||||
private static SetupDocument createBasicDocument(PDDocument document) throws IOException {
|
||||
PDPage page = new PDPage();
|
||||
document.addPage(page);
|
||||
|
||||
PDAcroForm acroForm = new PDAcroForm(document);
|
||||
acroForm.setDefaultResources(new PDResources());
|
||||
acroForm.setNeedAppearances(true);
|
||||
document.getDocumentCatalog().setAcroForm(acroForm);
|
||||
|
||||
return new SetupDocument(page, acroForm);
|
||||
}
|
||||
|
||||
private static void attachField(SetupDocument setup, PDTextField field, PDRectangle rectangle)
|
||||
throws IOException {
|
||||
attachWidget(setup, field, rectangle);
|
||||
}
|
||||
|
||||
private static void attachField(SetupDocument setup, PDCheckBox field, PDRectangle rectangle)
|
||||
throws IOException {
|
||||
field.setExportValues(List.of("Yes"));
|
||||
attachWidget(setup, field, rectangle);
|
||||
}
|
||||
|
||||
private static void attachWidget(
|
||||
SetupDocument setup,
|
||||
org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField field,
|
||||
PDRectangle rectangle)
|
||||
throws IOException {
|
||||
PDAnnotationWidget widget = new PDAnnotationWidget();
|
||||
widget.setRectangle(rectangle);
|
||||
widget.setPage(setup.page);
|
||||
List<PDAnnotationWidget> widgets = field.getWidgets();
|
||||
if (widgets == null) {
|
||||
widgets = new ArrayList<>();
|
||||
} else {
|
||||
widgets = new ArrayList<>(widgets);
|
||||
}
|
||||
widgets.add(widget);
|
||||
field.setWidgets(widgets);
|
||||
setup.acroForm.getFields().add(field);
|
||||
setup.page.getAnnotations().add(widget);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractFormFieldsReturnsFieldMetadata() throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(document);
|
||||
|
||||
PDTextField textField = new PDTextField(setup.acroForm);
|
||||
textField.setPartialName("firstName");
|
||||
attachField(setup, textField, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(document);
|
||||
assertEquals(1, fields.size());
|
||||
FormUtils.FormFieldInfo info = fields.get(0);
|
||||
assertEquals("firstName", info.name());
|
||||
assertEquals("text", info.type());
|
||||
assertEquals(0, info.pageIndex());
|
||||
assertEquals("", info.value());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyFieldValuesPopulatesTextAndCheckbox() throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(document);
|
||||
|
||||
PDTextField textField = new PDTextField(setup.acroForm);
|
||||
textField.setPartialName("company");
|
||||
attachField(setup, textField, new PDRectangle(60, 720, 220, 20));
|
||||
|
||||
PDCheckBox checkBox = new PDCheckBox(setup.acroForm);
|
||||
checkBox.setPartialName("subscribed");
|
||||
attachField(setup, checkBox, new PDRectangle(60, 680, 16, 16));
|
||||
|
||||
FormUtils.applyFieldValues(
|
||||
document, Map.of("company", "Stirling", "subscribed", true), false);
|
||||
|
||||
assertEquals("Stirling", textField.getValueAsString());
|
||||
assertTrue(checkBox.isChecked());
|
||||
|
||||
FormUtils.applyFieldValues(document, Map.of("subscribed", false), false);
|
||||
assertFalse(checkBox.isChecked());
|
||||
assertEquals("Off", checkBox.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
private record SetupDocument(PDPage page, PDAcroForm acroForm) {}
|
||||
}
|
||||
+1
-1
@@ -57,7 +57,7 @@ repositories {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '1.4.0'
|
||||
version = '2.0.0'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Run nginx as non-root user
|
||||
pid /tmp/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
Generated
+244
-61
@@ -10,24 +10,25 @@
|
||||
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@embedpdf/core": "^1.3.14",
|
||||
"@embedpdf/engines": "^1.3.14",
|
||||
"@embedpdf/plugin-annotation": "^1.3.14",
|
||||
"@embedpdf/plugin-export": "^1.3.14",
|
||||
"@embedpdf/plugin-history": "^1.3.14",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.3.14",
|
||||
"@embedpdf/plugin-loader": "^1.3.14",
|
||||
"@embedpdf/plugin-pan": "^1.3.14",
|
||||
"@embedpdf/plugin-render": "^1.3.14",
|
||||
"@embedpdf/plugin-rotate": "^1.3.14",
|
||||
"@embedpdf/plugin-scroll": "^1.3.14",
|
||||
"@embedpdf/plugin-search": "^1.3.14",
|
||||
"@embedpdf/plugin-selection": "^1.3.14",
|
||||
"@embedpdf/plugin-spread": "^1.3.14",
|
||||
"@embedpdf/plugin-thumbnail": "^1.3.14",
|
||||
"@embedpdf/plugin-tiling": "^1.3.14",
|
||||
"@embedpdf/plugin-viewport": "^1.3.14",
|
||||
"@embedpdf/plugin-zoom": "^1.3.14",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@embedpdf/core": "^1.4.1",
|
||||
"@embedpdf/engines": "^1.4.1",
|
||||
"@embedpdf/plugin-annotation": "^1.4.1",
|
||||
"@embedpdf/plugin-export": "^1.4.1",
|
||||
"@embedpdf/plugin-history": "^1.4.1",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.4.1",
|
||||
"@embedpdf/plugin-loader": "^1.4.1",
|
||||
"@embedpdf/plugin-pan": "^1.4.1",
|
||||
"@embedpdf/plugin-render": "^1.4.1",
|
||||
"@embedpdf/plugin-rotate": "^1.4.1",
|
||||
"@embedpdf/plugin-scroll": "^1.4.1",
|
||||
"@embedpdf/plugin-search": "^1.4.1",
|
||||
"@embedpdf/plugin-selection": "^1.4.1",
|
||||
"@embedpdf/plugin-spread": "^1.4.1",
|
||||
"@embedpdf/plugin-thumbnail": "^1.4.1",
|
||||
"@embedpdf/plugin-tiling": "^1.4.1",
|
||||
"@embedpdf/plugin-viewport": "^1.4.1",
|
||||
"@embedpdf/plugin-zoom": "^1.4.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
@@ -38,10 +39,14 @@
|
||||
"@mui/icons-material": "^7.3.2",
|
||||
"@mui/material": "^7.3.2",
|
||||
"@reactour/tour": "^3.8.0",
|
||||
"@stripe/react-stripe-js": "^4.0.2",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
"@supabase/supabase-js": "^2.47.13",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.0",
|
||||
"@tauri-apps/plugin-http": "^2.5.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.12.2",
|
||||
"globals": "^16.4.0",
|
||||
@@ -441,7 +446,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -488,7 +492,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -507,12 +510,68 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@embedpdf/core": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz",
|
||||
"integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/engines": "1.4.1",
|
||||
"@embedpdf/models": "1.4.1"
|
||||
@@ -596,7 +655,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz",
|
||||
"integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
},
|
||||
@@ -613,7 +671,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz",
|
||||
"integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
},
|
||||
@@ -631,7 +688,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz",
|
||||
"integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
},
|
||||
@@ -668,7 +724,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz",
|
||||
"integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
},
|
||||
@@ -703,7 +758,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz",
|
||||
"integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
},
|
||||
@@ -740,7 +794,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz",
|
||||
"integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
},
|
||||
@@ -816,7 +869,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz",
|
||||
"integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
},
|
||||
@@ -972,7 +1024,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
||||
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -1016,7 +1067,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
|
||||
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -2047,7 +2097,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.5.tgz",
|
||||
"integrity": "sha512-PdVNLMgOS2vFhOujRi6/VC9ic8w3UDyKX7ftwDeJ7yQT8CiepUxfbWWYpVpnq23bdWh/7fIT2Pn1EY8r8GOk7g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.16",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -2098,7 +2147,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.5.tgz",
|
||||
"integrity": "sha512-0Wf08eWLKi3WkKlxnV1W5vfuN6wcvAV2VbhQlOy0R9nrWorGTtonQF6qqBE3PnJFYF1/ZE+HkYZQ/Dr7DmYSMQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "^18.x || ^19.x"
|
||||
}
|
||||
@@ -2166,7 +2214,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz",
|
||||
"integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@mui/core-downloads-tracker": "^7.3.4",
|
||||
@@ -3077,6 +3124,138 @@
|
||||
"url": "https://github.com/sindresorhus/is?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@stripe/react-stripe-js": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-4.0.2.tgz",
|
||||
"integrity": "sha512-l2wau+8/LOlHl+Sz8wQ1oDuLJvyw51nQCsu6/ljT6smqzTszcMHifjAJoXlnMfcou3+jK/kQyVe04u/ufyTXgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@stripe/stripe-js": ">=1.44.1 <8.0.0",
|
||||
"react": ">=16.8.0 <20.0.0",
|
||||
"react-dom": ">=16.8.0 <20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stripe/stripe-js": {
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
|
||||
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.81.1.tgz",
|
||||
"integrity": "sha512-K20GgiSm9XeRLypxYHa5UCnybWc2K0ok0HLbqCej/wRxDpJxToXNOwKt0l7nO8xI1CyQ+GrNfU6bcRzvdbeopQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/auth-js/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@supabase/functions-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.81.1.tgz",
|
||||
"integrity": "sha512-sYgSO3mlgL0NvBFS3oRfCK4OgKGQwuOWJLzfPyWg0k8MSxSFSDeN/JtrDJD5GQrxskP6c58+vUzruBJQY78AqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/functions-js/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.81.1.tgz",
|
||||
"integrity": "sha512-DePpUTAPXJyBurQ4IH2e42DWoA+/Qmr5mbgY4B6ZcxVc/ZUKfTVK31BYIFBATMApWraFc8Q/Sg+yxtfJ3E0wSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@supabase/realtime-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.81.1.tgz",
|
||||
"integrity": "sha512-ViQ+Kxm8BuUP/TcYmH9tViqYKGSD1LBjdqx2p5J+47RES6c+0QHedM0PPAjthMdAHWyb2LGATE9PD2++2rO/tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/phoenix": "^1.6.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tslib": "2.8.1",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@supabase/storage-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.81.1.tgz",
|
||||
"integrity": "sha512-UNmYtjnZnhouqnbEMC1D5YJot7y0rIaZx7FG2Fv8S3hhNjcGVvO+h9We/tggi273BFkiahQPS/uRsapo1cSapw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/storage-js/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.81.1.tgz",
|
||||
"integrity": "sha512-KSdY7xb2L0DlLmlYzIOghdw/na4gsMcqJ8u4sD6tOQJr+x3hLujU9s4R8N3ob84/1bkvpvlU5PYKa1ae+OICnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.81.1",
|
||||
"@supabase/functions-js": "2.81.1",
|
||||
"@supabase/postgrest-js": "2.81.1",
|
||||
"@supabase/realtime-js": "2.81.1",
|
||||
"@supabase/storage-js": "2.81.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/acorn-typescript": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz",
|
||||
@@ -3844,13 +4023,21 @@
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-http": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.4.tgz",
|
||||
"integrity": "sha512-/i4U/9za3mrytTgfRn5RHneKubZE/dwRmshYwyMvNRlkWjvu1m4Ma72kcbVJMZFGXpkbl+qLyWMGrihtWB76Zg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
@@ -4151,7 +4338,6 @@
|
||||
"version": "24.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
|
||||
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
@@ -4163,6 +4349,12 @@
|
||||
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/phoenix": {
|
||||
"version": "1.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
|
||||
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -4174,7 +4366,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4185,7 +4376,6 @@
|
||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -4199,6 +4389,15 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||
@@ -4246,7 +4445,6 @@
|
||||
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.2",
|
||||
"@typescript-eslint/types": "8.46.2",
|
||||
@@ -4960,6 +5158,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz",
|
||||
"integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.22"
|
||||
}
|
||||
@@ -4969,6 +5168,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz",
|
||||
"integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.22",
|
||||
"@vue/shared": "3.5.22"
|
||||
@@ -4979,6 +5179,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz",
|
||||
"integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.22",
|
||||
"@vue/runtime-core": "3.5.22",
|
||||
@@ -4991,6 +5192,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz",
|
||||
"integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.22",
|
||||
"@vue/shared": "3.5.22"
|
||||
@@ -5017,7 +5219,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5702,7 +5903,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.19",
|
||||
"caniuse-lite": "^1.0.30001751",
|
||||
@@ -6748,8 +6948,7 @@
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz",
|
||||
"integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
@@ -7144,7 +7343,6 @@
|
||||
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -7315,7 +7513,6 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -8638,7 +8835,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
@@ -9446,7 +9642,6 @@
|
||||
"integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@asamuzakjp/dom-selector": "^6.7.2",
|
||||
"cssstyle": "^5.3.1",
|
||||
@@ -11223,7 +11418,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -11503,7 +11697,6 @@
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
|
||||
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
@@ -11876,7 +12069,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -11886,7 +12078,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -13321,9 +13512,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "5.42.3",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.42.3.tgz",
|
||||
"integrity": "sha512-+8dUmdJGvKSWEfbAgIaUmpD97s1bBAGxEf6s7wQonk+HNdMmrBZtpStzRypRqrYBFUmmhaUgBHUjraE8gLqWAw==",
|
||||
"version": "5.43.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.0.tgz",
|
||||
"integrity": "sha512-1sRxVbgJAB+UGzwkc3GUoiBSzEOf0jqzccMaVoI2+pI+kASUe9qubslxace8+Mzhqw19k4syTA5niCIJwfXpOA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -13557,7 +13748,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -13859,7 +14049,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -13922,7 +14111,6 @@
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
@@ -13942,7 +14130,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"napi-postinstall": "^0.3.0"
|
||||
},
|
||||
@@ -14147,7 +14334,6 @@
|
||||
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -14299,7 +14485,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -14313,7 +14498,6 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -14797,7 +14981,6 @@
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
+27
-21
@@ -6,38 +6,43 @@
|
||||
"proxy": "http://localhost:8080",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@embedpdf/core": "^1.3.14",
|
||||
"@embedpdf/engines": "^1.3.14",
|
||||
"@embedpdf/plugin-annotation": "^1.3.14",
|
||||
"@embedpdf/plugin-export": "^1.3.14",
|
||||
"@embedpdf/plugin-history": "^1.3.14",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.3.14",
|
||||
"@embedpdf/plugin-loader": "^1.3.14",
|
||||
"@embedpdf/plugin-pan": "^1.3.14",
|
||||
"@embedpdf/plugin-render": "^1.3.14",
|
||||
"@embedpdf/plugin-rotate": "^1.3.14",
|
||||
"@embedpdf/plugin-scroll": "^1.3.14",
|
||||
"@embedpdf/plugin-search": "^1.3.14",
|
||||
"@embedpdf/plugin-selection": "^1.3.14",
|
||||
"@embedpdf/plugin-spread": "^1.3.14",
|
||||
"@embedpdf/plugin-thumbnail": "^1.3.14",
|
||||
"@embedpdf/plugin-tiling": "^1.3.14",
|
||||
"@embedpdf/plugin-viewport": "^1.3.14",
|
||||
"@embedpdf/plugin-zoom": "^1.3.14",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@embedpdf/core": "^1.4.1",
|
||||
"@embedpdf/engines": "^1.4.1",
|
||||
"@embedpdf/plugin-annotation": "^1.4.1",
|
||||
"@embedpdf/plugin-export": "^1.4.1",
|
||||
"@embedpdf/plugin-history": "^1.4.1",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.4.1",
|
||||
"@embedpdf/plugin-loader": "^1.4.1",
|
||||
"@embedpdf/plugin-pan": "^1.4.1",
|
||||
"@embedpdf/plugin-render": "^1.4.1",
|
||||
"@embedpdf/plugin-rotate": "^1.4.1",
|
||||
"@embedpdf/plugin-scroll": "^1.4.1",
|
||||
"@embedpdf/plugin-search": "^1.4.1",
|
||||
"@embedpdf/plugin-selection": "^1.4.1",
|
||||
"@embedpdf/plugin-spread": "^1.4.1",
|
||||
"@embedpdf/plugin-thumbnail": "^1.4.1",
|
||||
"@embedpdf/plugin-tiling": "^1.4.1",
|
||||
"@embedpdf/plugin-viewport": "^1.4.1",
|
||||
"@embedpdf/plugin-zoom": "^1.4.1",
|
||||
"@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",
|
||||
"@mantine/dates": "^8.3.1",
|
||||
"@mantine/dropzone": "^8.3.1",
|
||||
"@mantine/hooks": "^8.3.1",
|
||||
"@stripe/react-stripe-js": "^4.0.2",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
"@supabase/supabase-js": "^2.47.13",
|
||||
"@mui/icons-material": "^7.3.2",
|
||||
"@mui/material": "^7.3.2",
|
||||
"@reactour/tour": "^3.8.0",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.0",
|
||||
"@tauri-apps/plugin-http": "^2.5.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.12.2",
|
||||
"globals": "^16.4.0",
|
||||
@@ -66,6 +71,7 @@
|
||||
"preview": "vite preview",
|
||||
"tauri-dev": "tauri dev --no-watch",
|
||||
"tauri-build": "tauri build",
|
||||
"tauri-clean": "cd src-tauri && cargo clean && cd .. && rm -rf dist build",
|
||||
"typecheck": "npm run typecheck:proprietary",
|
||||
"typecheck:core": "tsc --noEmit --project tsconfig.core.json",
|
||||
"typecheck:proprietary": "tsc --noEmit --project tsconfig.proprietary.json",
|
||||
@@ -108,11 +114,11 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@iconify-json/material-symbols": "^1.2.37",
|
||||
"@iconify/utils": "^3.0.2",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve"><defs id="defs173"><linearGradient id="XMLID_5_" x1="304.496" x2="316.036" y1="422.91" y2="326.263" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#dcf1f3" id="stop156"/><stop offset="1" style="stop-color:#c2c2c9" id="stop158"/></linearGradient></defs><style id="style150" type="text/css">.st1{fill:#c02223}.st2{fill:#882425}.st3{fill:url(#XMLID_5_)}.st4{fill:url(#XMLID_7_)}</style><g id="XMLID_4_"><path id="XMLID_131_" d="M 347.01402,14.355825 98.978019,69.02261 C 73.825483,74.547445 55.942464,96.792175 55.942464,122.52628 v 315.06096 c 0,22.39012 16.719895,41.14548 38.819234,43.76251 L 224.8861,498.36042 339.48636,384.26465 455.76603,265.15425 453.73057,84.870162 C 453.43979,62.916214 433.08513,46.632491 411.71274,51.284984 l -28.78729,6.251786 0.14539,-13.666697 C 383.36162,24.678542 365.62399,10.284894 347.01402,14.355825 Z" class="st1" style="stroke-width:1.45391"/><path id="XMLID_117_" d="m 383.21622,57.53677 v 285.8375 L 456.05681,265.00885 454.02135,78.763767 C 453.87595,59.863016 436.28372,45.905539 417.81914,49.97647 Z" class="st2" style="stroke-width:1.45391"/><polygon id="XMLID_18_" points="234.7 422.6 368.5 387.7 393.5 262.2" class="st3" style="fill:url(#XMLID_5_)" transform="matrix(1.4556308,0,0,1.4548265,-116.73161,-116.45231)"/><linearGradient id="XMLID_7_" x1="223.084" x2="241.417" y1="372.756" y2="114.557" gradientTransform="matrix(1.4539039,0,0,1.4539039,-116.19976,-116.20474)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#dcf1f3" id="stop163"/><stop offset="1" style="stop-color:#c2c2c9" id="stop165"/></linearGradient><path id="XMLID_6_" d="m 282.89686,214.84917 c 0,0 -22.24473,-28.93269 -38.67384,-36.78377 -10.46811,-4.94327 -26.02489,-6.83335 -38.23768,-0.72695 -18.02841,9.0142 -19.91848,34.31213 -3.34397,44.34406 3.92553,2.47165 9.15959,4.50711 15.99294,6.10641 36.63838,8.43264 97.12077,25.87949 89.70587,96.10304 0,0 -4.21633,65.86185 -73.56753,73.42215 -12.2128,1.30851 -24.57098,0.43617 -36.493,-2.32625 -16.42911,-3.63476 -45.50719,-11.04967 -59.75545,-19.91849 l -2.61703,-75.16682 h 6.97875 c 0,0 13.81208,33.43978 53.06749,49.57812 7.26952,2.90781 15.26599,4.07093 22.97168,2.90781 9.74116,-1.45391 21.22699,-6.68796 25.87949,-22.53551 0,0 7.85108,-23.11707 -32.85823,-35.76604 -32.56744,-10.17733 -63.24481,-20.64543 -75.89378,-54.95757 -5.961,-16.28371 -6.97874,-34.31212 -2.90781,-51.61358 5.37944,-22.53551 20.79082,-54.23062 64.40794,-67.89732 0,0 57.28381,-15.55677 96.53922,5.52484 l -1.74468,89.70587 z" class="st4" style="fill:url(#XMLID_7_);stroke-width:1.45391"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Generated
+837
-88
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,9 @@ rust-version = "1.77.2"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lints.rust]
|
||||
warnings = "deny"
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
@@ -25,11 +28,13 @@ 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"
|
||||
tauri-plugin-http = "2.4.4"
|
||||
tauri-plugin-single-instance = "2.0.1"
|
||||
tauri-plugin-store = "2.1.0"
|
||||
keyring = "3.6.1"
|
||||
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"
|
||||
core-foundation = "0.10"
|
||||
core-services = "1.0"
|
||||
|
||||
@@ -7,9 +7,18 @@
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
{
|
||||
"identifier": "fs:allow-read-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
}
|
||||
"http:default",
|
||||
{
|
||||
"identifier": "http:allow-fetch",
|
||||
"allow": [
|
||||
{ "url": "http://localhost:*" },
|
||||
{ "url": "http://127.0.0.1:*" },
|
||||
{ "url": "https://*" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-read-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
use keyring::Entry;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
const STORE_FILE: &str = "connection.json";
|
||||
const USER_INFO_KEY: &str = "user_info";
|
||||
const KEYRING_SERVICE: &str = "stirling-pdf";
|
||||
const KEYRING_TOKEN_KEY: &str = "auth-token";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UserInfo {
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
fn get_keyring_entry() -> Result<Entry, String> {
|
||||
Entry::new(KEYRING_SERVICE, KEYRING_TOKEN_KEY)
|
||||
.map_err(|e| format!("Failed to access keyring: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<(), String> {
|
||||
log::info!("Saving auth token to keyring");
|
||||
|
||||
let entry = get_keyring_entry()?;
|
||||
|
||||
entry
|
||||
.set_password(&token)
|
||||
.map_err(|e| format!("Failed to save token to keyring: {}", e))?;
|
||||
|
||||
log::info!("Auth token saved successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_auth_token(_app_handle: AppHandle) -> Result<Option<String>, String> {
|
||||
log::debug!("Retrieving auth token from keyring");
|
||||
|
||||
let entry = get_keyring_entry()?;
|
||||
|
||||
match entry.get_password() {
|
||||
Ok(token) => Ok(Some(token)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to retrieve token: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_auth_token(_app_handle: AppHandle) -> Result<(), String> {
|
||||
log::info!("Clearing auth token from keyring");
|
||||
|
||||
let entry = get_keyring_entry()?;
|
||||
|
||||
// Delete the token - ignore error if it doesn't exist
|
||||
match entry.delete_credential() {
|
||||
Ok(_) => {
|
||||
log::info!("Auth token cleared successfully");
|
||||
Ok(())
|
||||
}
|
||||
Err(keyring::Error::NoEntry) => {
|
||||
log::info!("Auth token was already cleared");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(format!("Failed to clear token: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_user_info(
|
||||
app_handle: AppHandle,
|
||||
username: String,
|
||||
email: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Saving user info for: {}", username);
|
||||
|
||||
let user_info = UserInfo { username, email };
|
||||
|
||||
let store = app_handle
|
||||
.store(STORE_FILE)
|
||||
.map_err(|e| format!("Failed to access store: {}", e))?;
|
||||
|
||||
store.set(
|
||||
USER_INFO_KEY,
|
||||
serde_json::to_value(&user_info)
|
||||
.map_err(|e| format!("Failed to serialize user info: {}", e))?,
|
||||
);
|
||||
|
||||
store
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save store: {}", e))?;
|
||||
|
||||
log::info!("User info saved successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_user_info(app_handle: AppHandle) -> Result<Option<UserInfo>, String> {
|
||||
log::debug!("Retrieving user info");
|
||||
|
||||
let store = app_handle
|
||||
.store(STORE_FILE)
|
||||
.map_err(|e| format!("Failed to access store: {}", e))?;
|
||||
|
||||
let user_info: Option<UserInfo> = store
|
||||
.get(USER_INFO_KEY)
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||
|
||||
Ok(user_info)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> {
|
||||
log::info!("Clearing user info");
|
||||
|
||||
let store = app_handle
|
||||
.store(STORE_FILE)
|
||||
.map_err(|e| format!("Failed to access store: {}", e))?;
|
||||
|
||||
store.delete(USER_INFO_KEY);
|
||||
|
||||
store
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save store: {}", e))?;
|
||||
|
||||
log::info!("User info cleared successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Response types for Spring Boot login
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpringBootSession {
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpringBootUser {
|
||||
username: String,
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpringBootLoginResponse {
|
||||
session: SpringBootSession,
|
||||
user: SpringBootUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
/// Login command - makes HTTP request from Rust to bypass CORS
|
||||
/// Supports Spring Boot authentication (self-hosted)
|
||||
#[tauri::command]
|
||||
pub async fn login(
|
||||
server_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<LoginResponse, String> {
|
||||
log::info!("Login attempt for user: {} to server: {}", username, server_url);
|
||||
|
||||
// Build login URL
|
||||
let login_url = format!("{}/api/v1/auth/login", server_url.trim_end_matches('/'));
|
||||
log::debug!("Login URL: {}", login_url);
|
||||
|
||||
// Create HTTP client
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Make login request
|
||||
let response = client
|
||||
.post(&login_url)
|
||||
.json(&serde_json::json!({
|
||||
"username": username,
|
||||
"password": password,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
log::debug!("Login response status: {}", status);
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
log::error!("Login failed with status {}: {}", status, error_text);
|
||||
|
||||
return Err(if status.as_u16() == 401 {
|
||||
"Invalid username or password".to_string()
|
||||
} else if status.as_u16() == 403 {
|
||||
"Access denied".to_string()
|
||||
} else {
|
||||
format!("Login failed: {}", status)
|
||||
});
|
||||
}
|
||||
|
||||
// Parse Spring Boot response format
|
||||
let login_response: SpringBootLoginResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
log::info!("Login successful for user: {}", login_response.user.username);
|
||||
|
||||
Ok(LoginResponse {
|
||||
token: login_response.session.access_token,
|
||||
username: login_response.user.username,
|
||||
email: login_response.user.email,
|
||||
})
|
||||
}
|
||||
@@ -3,10 +3,12 @@ use tauri::Manager;
|
||||
use std::sync::Mutex;
|
||||
use std::path::PathBuf;
|
||||
use crate::utils::add_log;
|
||||
use crate::state::connection_state::{AppConnectionState, ConnectionMode};
|
||||
|
||||
// Store backend process handle globally
|
||||
// Store backend process handle and port globally
|
||||
static BACKEND_PROCESS: Mutex<Option<tauri_plugin_shell::process::CommandChild>> = Mutex::new(None);
|
||||
static BACKEND_STARTING: Mutex<bool> = Mutex::new(false);
|
||||
static BACKEND_PORT: Mutex<Option<u16>> = Mutex::new(None);
|
||||
|
||||
// Helper function to reset starting flag
|
||||
fn reset_starting_flag() {
|
||||
@@ -14,6 +16,20 @@ fn reset_starting_flag() {
|
||||
*starting_guard = false;
|
||||
}
|
||||
|
||||
// Extract port number from "Stirling-PDF running on port: PORT" log line
|
||||
fn extract_port_from_running_log(log_line: &str) -> Option<u16> {
|
||||
// Look for pattern: "running on port: PORT"
|
||||
if let Some(start) = log_line.find("running on port: ") {
|
||||
let after_prefix = &log_line[start + 17..]; // Skip "running on port: "
|
||||
// Take digits until whitespace or end of line
|
||||
let port_str: String = after_prefix.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect();
|
||||
return port_str.parse::<u16>().ok();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// Check if backend is already running or starting
|
||||
fn check_backend_status() -> Result<(), String> {
|
||||
// Check if backend is already running
|
||||
@@ -24,7 +40,7 @@ fn check_backend_status() -> Result<(), String> {
|
||||
return Err("Backend already running".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check and set starting flag to prevent multiple simultaneous starts
|
||||
{
|
||||
let mut starting_guard = BACKEND_STARTING.lock().unwrap();
|
||||
@@ -34,7 +50,7 @@ fn check_backend_status() -> Result<(), String> {
|
||||
}
|
||||
*starting_guard = true;
|
||||
}
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -46,13 +62,13 @@ fn find_bundled_jre(resource_dir: &PathBuf) -> Result<PathBuf, String> {
|
||||
} else {
|
||||
jre_dir.join("bin").join("java")
|
||||
};
|
||||
|
||||
|
||||
if !java_executable.exists() {
|
||||
let error_msg = format!("❌ Bundled JRE not found at: {:?}", java_executable);
|
||||
add_log(error_msg.clone());
|
||||
return Err(error_msg);
|
||||
}
|
||||
|
||||
|
||||
add_log(format!("✅ Found bundled JRE: {:?}", java_executable));
|
||||
Ok(java_executable)
|
||||
}
|
||||
@@ -77,20 +93,20 @@ fn find_stirling_jar(resource_dir: &PathBuf) -> Result<PathBuf, String> {
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
if jar_files.is_empty() {
|
||||
let error_msg = "No Stirling-PDF JAR found in libs directory.".to_string();
|
||||
add_log(error_msg.clone());
|
||||
return Err(error_msg);
|
||||
}
|
||||
|
||||
|
||||
// Sort by filename to get the latest version (case-insensitive)
|
||||
jar_files.sort_by(|a, b| {
|
||||
let name_a = a.file_name().to_string_lossy().to_ascii_lowercase();
|
||||
let name_b = b.file_name().to_string_lossy().to_ascii_lowercase();
|
||||
name_b.cmp(&name_a) // Reverse order to get latest first
|
||||
});
|
||||
|
||||
|
||||
let jar_path = jar_files[0].path();
|
||||
add_log(format!("📋 Selected JAR: {:?}", jar_path.file_name().unwrap()));
|
||||
Ok(jar_path)
|
||||
@@ -123,23 +139,23 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
PathBuf::from(home).join(".config").join("Stirling-PDF")
|
||||
};
|
||||
|
||||
|
||||
// Create subdirectories for different purposes
|
||||
let config_dir = app_data_dir.join("configs");
|
||||
let log_dir = app_data_dir.join("logs");
|
||||
let work_dir = app_data_dir.join("workspace");
|
||||
|
||||
|
||||
// Create all necessary directories
|
||||
std::fs::create_dir_all(&app_data_dir).ok();
|
||||
std::fs::create_dir_all(&log_dir).ok();
|
||||
std::fs::create_dir_all(&work_dir).ok();
|
||||
std::fs::create_dir_all(&config_dir).ok();
|
||||
|
||||
|
||||
add_log(format!("📁 App data directory: {}", app_data_dir.display()));
|
||||
add_log(format!("📁 Log directory: {}", log_dir.display()));
|
||||
add_log(format!("📁 Working directory: {}", work_dir.display()));
|
||||
add_log(format!("📁 Config directory: {}", config_dir.display()));
|
||||
|
||||
|
||||
// Define all Java options with Tauri-specific paths
|
||||
let log_path_option = format!("-Dlogging.file.path={}", log_dir.display());
|
||||
|
||||
@@ -150,10 +166,13 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
|
||||
"-DSTIRLING_PDF_TAURI_MODE=true",
|
||||
&log_path_option,
|
||||
"-Dlogging.file.name=stirling-pdf.log",
|
||||
"-Dserver.port=0", // Let OS assign an available port
|
||||
"-Dsecurity.enableLogin=false", // Disable login for desktop mode
|
||||
"-Dsecurity.csrfDisabled=true", // Disable CSRF for desktop mode
|
||||
"-jar",
|
||||
jar_path.to_str().unwrap()
|
||||
jar_path.to_str().unwrap(),
|
||||
];
|
||||
|
||||
|
||||
// Log the equivalent command for external testing
|
||||
let java_command = format!(
|
||||
"TAURI_PARENT_PID={} \"{}\" {}",
|
||||
@@ -163,14 +182,14 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
|
||||
);
|
||||
add_log(format!("🔧 Equivalent command: {}", java_command));
|
||||
add_log(format!("📁 Backend logs will be in: {}", log_dir.display()));
|
||||
|
||||
|
||||
// Additional macOS-specific checks
|
||||
if cfg!(target_os = "macos") {
|
||||
// Check if java executable has execute permissions
|
||||
if let Ok(metadata) = std::fs::metadata(java_path) {
|
||||
let permissions = metadata.permissions();
|
||||
add_log(format!("🔍 Java executable permissions: {:?}", permissions));
|
||||
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -181,7 +200,7 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check if we can read the JAR file
|
||||
if let Ok(metadata) = std::fs::metadata(jar_path) {
|
||||
add_log(format!("📦 JAR file size: {} bytes", metadata.len()));
|
||||
@@ -189,7 +208,7 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
|
||||
add_log("⚠️ Cannot read JAR file metadata".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let sidecar_command = app
|
||||
.shell()
|
||||
.command(java_path.to_str().unwrap())
|
||||
@@ -199,9 +218,9 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
|
||||
.env("STIRLING_PDF_CONFIG_DIR", config_dir.to_str().unwrap())
|
||||
.env("STIRLING_PDF_LOG_DIR", log_dir.to_str().unwrap())
|
||||
.env("STIRLING_PDF_WORK_DIR", work_dir.to_str().unwrap());
|
||||
|
||||
|
||||
add_log("⚙️ Starting backend with bundled JRE...".to_string());
|
||||
|
||||
|
||||
let (rx, child) = sidecar_command
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
@@ -209,18 +228,18 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
|
||||
add_log(error_msg.clone());
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
|
||||
// Store the process handle
|
||||
{
|
||||
let mut process_guard = BACKEND_PROCESS.lock().unwrap();
|
||||
*process_guard = Some(child);
|
||||
}
|
||||
|
||||
|
||||
add_log("✅ Backend started with bundled JRE, monitoring output...".to_string());
|
||||
|
||||
|
||||
// Start monitoring output
|
||||
monitor_backend_output(rx);
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -229,35 +248,44 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
|
||||
tokio::spawn(async move {
|
||||
let mut _startup_detected = false;
|
||||
let mut error_count = 0;
|
||||
|
||||
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
tauri_plugin_shell::process::CommandEvent::Stdout(output) => {
|
||||
let output_str = String::from_utf8_lossy(&output);
|
||||
// Strip exactly one trailing newline to avoid double newlines
|
||||
let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str);
|
||||
add_log(format!("📤 Backend: {}", output_str));
|
||||
|
||||
// Look for startup indicators
|
||||
if output_str.contains("Started SPDFApplication") ||
|
||||
output_str.contains("Navigate to "){
|
||||
|
||||
// Look for actual runtime port from web server initialization
|
||||
// Format: "Stirling-PDF running on port: PORT"
|
||||
if output_str.contains("running on port:") {
|
||||
_startup_detected = true;
|
||||
add_log(format!("🎉 Backend startup detected: {}", output_str));
|
||||
if let Some(port) = extract_port_from_running_log(&output_str) {
|
||||
let mut port_guard = BACKEND_PORT.lock().unwrap();
|
||||
*port_guard = Some(port);
|
||||
add_log(format!("🎉 Backend started on port: {}", port));
|
||||
add_log(format!("🔌 Navigate to: http://localhost:{}/", port));
|
||||
}
|
||||
}
|
||||
|
||||
// Look for port binding
|
||||
if output_str.contains("8080") {
|
||||
add_log(format!("🔌 Port 8080 related output: {}", output_str));
|
||||
|
||||
if output_str.contains("Started SPDFApplication") {
|
||||
_startup_detected = true;
|
||||
add_log(format!("🎉 Backend startup completed: {}", output_str));
|
||||
}
|
||||
}
|
||||
tauri_plugin_shell::process::CommandEvent::Stderr(output) => {
|
||||
let output_str = String::from_utf8_lossy(&output);
|
||||
// Strip exactly one trailing newline to avoid double newlines
|
||||
let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str);
|
||||
add_log(format!("📥 Backend Error: {}", output_str));
|
||||
|
||||
|
||||
// Look for error indicators
|
||||
if output_str.contains("ERROR") || output_str.contains("Exception") || output_str.contains("FATAL") {
|
||||
error_count += 1;
|
||||
add_log(format!("⚠️ Backend error #{}: {}", error_count, output_str));
|
||||
}
|
||||
|
||||
|
||||
// Look for specific common issues
|
||||
if output_str.contains("Address already in use") {
|
||||
add_log("🚨 CRITICAL: Port 8080 is already in use by another process!".to_string());
|
||||
@@ -295,7 +323,7 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if error_count > 0 {
|
||||
println!("⚠️ Backend process ended with {} errors detected", error_count);
|
||||
}
|
||||
@@ -304,14 +332,36 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
|
||||
|
||||
// Command to start the backend with bundled JRE
|
||||
#[tauri::command]
|
||||
pub async fn start_backend(app: tauri::AppHandle) -> Result<String, String> {
|
||||
pub async fn start_backend(
|
||||
app: tauri::AppHandle,
|
||||
connection_state: tauri::State<'_, AppConnectionState>,
|
||||
) -> Result<String, String> {
|
||||
add_log("🚀 start_backend() called - Attempting to start backend with bundled JRE...".to_string());
|
||||
|
||||
|
||||
// Check connection mode
|
||||
let mode = {
|
||||
let state = connection_state.0.lock().map_err(|e| {
|
||||
let error_msg = format!("❌ Failed to access connection state: {}", e);
|
||||
add_log(error_msg.clone());
|
||||
error_msg
|
||||
})?;
|
||||
state.mode.clone()
|
||||
};
|
||||
|
||||
match mode {
|
||||
ConnectionMode::Offline => {
|
||||
add_log("🔌 Running in Offline mode - starting local backend".to_string());
|
||||
}
|
||||
ConnectionMode::Server => {
|
||||
add_log("🌐 Running in Server mode - starting local backend (for hybrid execution support)".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Check if backend is already running or starting
|
||||
if let Err(msg) = check_backend_status() {
|
||||
return Ok(msg);
|
||||
}
|
||||
|
||||
|
||||
// Use Tauri's resource API to find the bundled JRE and JAR
|
||||
let resource_dir = app.path().resource_dir().map_err(|e| {
|
||||
let error_msg = format!("❌ Failed to get resource directory: {}", e);
|
||||
@@ -319,53 +369,60 @@ pub async fn start_backend(app: tauri::AppHandle) -> Result<String, String> {
|
||||
reset_starting_flag();
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
|
||||
add_log(format!("🔍 Resource directory: {:?}", resource_dir));
|
||||
|
||||
|
||||
// Find the bundled JRE
|
||||
let java_executable = find_bundled_jre(&resource_dir).map_err(|e| {
|
||||
reset_starting_flag();
|
||||
e
|
||||
})?;
|
||||
|
||||
|
||||
// Find the Stirling-PDF JAR
|
||||
let jar_path = find_stirling_jar(&resource_dir).map_err(|e| {
|
||||
reset_starting_flag();
|
||||
e
|
||||
})?;
|
||||
|
||||
|
||||
// Normalize the paths to remove Windows UNC prefix
|
||||
let normalized_java_path = normalize_path(&java_executable);
|
||||
let normalized_jar_path = normalize_path(&jar_path);
|
||||
|
||||
|
||||
add_log(format!("📦 Found JAR file: {:?}", jar_path));
|
||||
add_log(format!("📦 Normalized JAR path: {:?}", normalized_jar_path));
|
||||
add_log(format!("📦 Normalized Java path: {:?}", normalized_java_path));
|
||||
|
||||
|
||||
// Create and start the Java command
|
||||
run_stirling_pdf_jar(&app, &normalized_java_path, &normalized_jar_path).map_err(|e| {
|
||||
reset_starting_flag();
|
||||
e
|
||||
})?;
|
||||
|
||||
|
||||
// Wait for the backend to start
|
||||
println!("⏳ Waiting for backend startup...");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10000)).await;
|
||||
|
||||
|
||||
// Reset the starting flag since startup is complete
|
||||
reset_starting_flag();
|
||||
add_log("✅ Backend startup sequence completed, starting flag cleared".to_string());
|
||||
|
||||
|
||||
Ok("Backend startup initiated successfully with bundled JRE".to_string())
|
||||
}
|
||||
|
||||
// Get the dynamically assigned backend port
|
||||
#[tauri::command]
|
||||
pub fn get_backend_port() -> Option<u16> {
|
||||
let port_guard = BACKEND_PORT.lock().unwrap();
|
||||
*port_guard
|
||||
}
|
||||
|
||||
// Cleanup function to stop backend on app exit
|
||||
pub fn cleanup_backend() {
|
||||
let mut process_guard = BACKEND_PROCESS.lock().unwrap();
|
||||
if let Some(child) = process_guard.take() {
|
||||
let pid = child.pid();
|
||||
add_log(format!("🧹 App shutting down, cleaning up backend process (PID: {})", pid));
|
||||
|
||||
|
||||
match child.kill() {
|
||||
Ok(_) => {
|
||||
add_log(format!("✅ Backend process (PID: {}) terminated during cleanup", pid));
|
||||
@@ -376,4 +433,4 @@ pub fn cleanup_backend() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
use crate::state::connection_state::{
|
||||
AppConnectionState,
|
||||
ConnectionMode,
|
||||
ServerConfig,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
const STORE_FILE: &str = "connection.json";
|
||||
const FIRST_LAUNCH_KEY: &str = "setup_completed";
|
||||
const CONNECTION_MODE_KEY: &str = "connection_mode";
|
||||
const SERVER_CONFIG_KEY: &str = "server_config";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ConnectionConfig {
|
||||
pub mode: ConnectionMode,
|
||||
pub server_config: Option<ServerConfig>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_connection_config(
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppConnectionState>,
|
||||
) -> Result<ConnectionConfig, String> {
|
||||
// Try to load from store
|
||||
let store = app_handle
|
||||
.store(STORE_FILE)
|
||||
.map_err(|e| format!("Failed to access store: {}", e))?;
|
||||
|
||||
let mode = store
|
||||
.get(CONNECTION_MODE_KEY)
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or(ConnectionMode::Offline);
|
||||
|
||||
let server_config: Option<ServerConfig> = store
|
||||
.get(SERVER_CONFIG_KEY)
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||
|
||||
// Update in-memory state
|
||||
if let Ok(mut conn_state) = state.0.lock() {
|
||||
conn_state.mode = mode.clone();
|
||||
conn_state.server_config = server_config.clone();
|
||||
}
|
||||
|
||||
Ok(ConnectionConfig {
|
||||
mode,
|
||||
server_config,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_connection_mode(
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppConnectionState>,
|
||||
mode: ConnectionMode,
|
||||
server_config: Option<ServerConfig>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Setting connection mode: {:?}", mode);
|
||||
|
||||
// Update in-memory state
|
||||
if let Ok(mut conn_state) = state.0.lock() {
|
||||
conn_state.mode = mode.clone();
|
||||
conn_state.server_config = server_config.clone();
|
||||
}
|
||||
|
||||
// Save to store
|
||||
let store = app_handle
|
||||
.store(STORE_FILE)
|
||||
.map_err(|e| format!("Failed to access store: {}", e))?;
|
||||
|
||||
store.set(
|
||||
CONNECTION_MODE_KEY,
|
||||
serde_json::to_value(&mode).map_err(|e| format!("Failed to serialize mode: {}", e))?,
|
||||
);
|
||||
|
||||
if let Some(config) = &server_config {
|
||||
store.set(
|
||||
SERVER_CONFIG_KEY,
|
||||
serde_json::to_value(config)
|
||||
.map_err(|e| format!("Failed to serialize config: {}", e))?,
|
||||
);
|
||||
} else {
|
||||
store.delete(SERVER_CONFIG_KEY);
|
||||
}
|
||||
|
||||
// Mark setup as completed
|
||||
store.set(FIRST_LAUNCH_KEY, serde_json::json!(true));
|
||||
|
||||
store
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save store: {}", e))?;
|
||||
|
||||
log::info!("Connection mode saved successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn is_first_launch(app_handle: AppHandle) -> Result<bool, String> {
|
||||
let store = app_handle
|
||||
.store(STORE_FILE)
|
||||
.map_err(|e| format!("Failed to access store: {}", e))?;
|
||||
|
||||
let setup_completed = store
|
||||
.get(FIRST_LAUNCH_KEY)
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(!setup_completed)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
use crate::utils::add_log;
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
use std::process::Command;
|
||||
|
||||
/// Check if Stirling PDF is the default PDF handler
|
||||
#[tauri::command]
|
||||
pub fn is_default_pdf_handler() -> Result<bool, String> {
|
||||
add_log("🔍 Checking if app is default PDF handler".to_string());
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
check_default_windows()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
check_default_macos()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
check_default_linux()
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to set/prompt for Stirling PDF as default PDF handler
|
||||
#[tauri::command]
|
||||
pub fn set_as_default_pdf_handler() -> Result<String, String> {
|
||||
add_log("⚙️ Attempting to set as default PDF handler".to_string());
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
set_default_windows()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
set_default_macos()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
set_default_linux()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Windows Implementation
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn check_default_windows() -> Result<bool, String> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
// Query the default handler for .pdf extension
|
||||
let output = Command::new("cmd")
|
||||
.args(["/C", "assoc .pdf"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to check default app: {}", e))?;
|
||||
|
||||
let assoc = String::from_utf8_lossy(&output.stdout);
|
||||
add_log(format!("Windows PDF association: {}", assoc.trim()));
|
||||
|
||||
// Get the ProgID for .pdf files
|
||||
if let Some(prog_id) = assoc.trim().strip_prefix(".pdf=") {
|
||||
// Query what application handles this ProgID
|
||||
let output = Command::new("cmd")
|
||||
.args(["/C", &format!("ftype {}", prog_id)])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to query file type: {}", e))?;
|
||||
|
||||
let ftype = String::from_utf8_lossy(&output.stdout);
|
||||
add_log(format!("Windows file type: {}", ftype.trim()));
|
||||
|
||||
// Check if it contains "Stirling" or our app name
|
||||
let is_default = ftype.to_lowercase().contains("stirling");
|
||||
Ok(is_default)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn set_default_windows() -> Result<String, String> {
|
||||
// On Windows 10+, we need to open the Default Apps settings
|
||||
// as programmatic setting requires a signed installer
|
||||
Command::new("cmd")
|
||||
.args(["/C", "start", "ms-settings:defaultapps"])
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open default apps settings: {}", e))?;
|
||||
|
||||
add_log("Opened Windows Default Apps settings".to_string());
|
||||
Ok("opened_settings".to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// macOS Implementation (using LaunchServices framework)
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn check_default_macos() -> Result<bool, String> {
|
||||
use core_foundation::base::TCFType;
|
||||
use core_foundation::string::{CFString, CFStringRef};
|
||||
use std::os::raw::c_int;
|
||||
|
||||
// Define the LSCopyDefaultRoleHandlerForContentType function
|
||||
#[link(name = "CoreServices", kind = "framework")]
|
||||
extern "C" {
|
||||
fn LSCopyDefaultRoleHandlerForContentType(
|
||||
content_type: CFStringRef,
|
||||
role: c_int,
|
||||
) -> CFStringRef;
|
||||
}
|
||||
|
||||
const K_LS_ROLES_ALL: c_int = 0xFFFFFFFF_u32 as c_int;
|
||||
|
||||
unsafe {
|
||||
// Query the default handler for "com.adobe.pdf" (PDF UTI - standard macOS identifier)
|
||||
let pdf_uti = CFString::new("com.adobe.pdf");
|
||||
let handler_ref = LSCopyDefaultRoleHandlerForContentType(pdf_uti.as_concrete_TypeRef(), K_LS_ROLES_ALL);
|
||||
|
||||
if handler_ref.is_null() {
|
||||
add_log("No default PDF handler found".to_string());
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let handler = CFString::wrap_under_create_rule(handler_ref);
|
||||
let handler_str = handler.to_string();
|
||||
add_log(format!("macOS PDF handler: {}", handler_str));
|
||||
|
||||
// Check if it's our bundle identifier
|
||||
let is_default = handler_str == "stirling.pdf.dev";
|
||||
Ok(is_default)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_default_macos() -> Result<String, String> {
|
||||
use core_foundation::base::TCFType;
|
||||
use core_foundation::string::{CFString, CFStringRef};
|
||||
use std::os::raw::c_int;
|
||||
|
||||
// Define the LSSetDefaultRoleHandlerForContentType function
|
||||
#[link(name = "CoreServices", kind = "framework")]
|
||||
extern "C" {
|
||||
fn LSSetDefaultRoleHandlerForContentType(
|
||||
content_type: CFStringRef,
|
||||
role: c_int,
|
||||
handler_bundle_id: CFStringRef,
|
||||
) -> c_int; // OSStatus
|
||||
}
|
||||
|
||||
const K_LS_ROLES_ALL: c_int = 0xFFFFFFFF_u32 as c_int;
|
||||
|
||||
unsafe {
|
||||
// Set our app as the default handler for PDF files
|
||||
let pdf_uti = CFString::new("com.adobe.pdf");
|
||||
let our_bundle_id = CFString::new("stirling.pdf.dev");
|
||||
|
||||
let status = LSSetDefaultRoleHandlerForContentType(
|
||||
pdf_uti.as_concrete_TypeRef(),
|
||||
K_LS_ROLES_ALL,
|
||||
our_bundle_id.as_concrete_TypeRef(),
|
||||
);
|
||||
|
||||
if status == 0 {
|
||||
add_log("Successfully triggered default app dialog".to_string());
|
||||
Ok("set_successfully".to_string())
|
||||
} else {
|
||||
let error_msg = format!("LaunchServices returned status: {}", status);
|
||||
add_log(error_msg.clone());
|
||||
Err(error_msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Linux Implementation
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn check_default_linux() -> Result<bool, String> {
|
||||
// Use xdg-mime to check the default application for PDF files
|
||||
let output = Command::new("xdg-mime")
|
||||
.args(["query", "default", "application/pdf"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to check default app: {}", e))?;
|
||||
|
||||
let handler = String::from_utf8_lossy(&output.stdout);
|
||||
add_log(format!("Linux PDF handler: {}", handler.trim()));
|
||||
|
||||
// Check if it's our .desktop file
|
||||
let is_default = handler.trim() == "stirling-pdf.desktop";
|
||||
Ok(is_default)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn set_default_linux() -> Result<String, String> {
|
||||
// Use xdg-mime to set the default application for PDF files
|
||||
let result = Command::new("xdg-mime")
|
||||
.args(["default", "stirling-pdf.desktop", "application/pdf"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to set default app: {}", e))?;
|
||||
|
||||
if result.status.success() {
|
||||
add_log("Set as default PDF handler on Linux".to_string());
|
||||
Ok("set_successfully".to_string())
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&result.stderr);
|
||||
add_log(format!("Failed to set default: {}", error));
|
||||
Err(format!("Failed to set as default: {}", error))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user