mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a09ff39eb5 | ||
|
|
731743b618 | ||
|
|
04c4aec0d8 | ||
|
|
f63df148ad | ||
|
|
6f0be94bd6 | ||
|
|
e47ed13be8 | ||
|
|
a62c8b54cf | ||
|
|
1e34038b1e | ||
|
|
d4765938a8 | ||
|
|
476e1505ee | ||
|
|
d3491ecccf | ||
|
|
cddd5e7d15 | ||
|
|
f4c9becce2 | ||
|
|
7629d89356 | ||
|
|
9fdb5295cb | ||
|
|
21657a3fa6 | ||
|
|
bc11875081 | ||
|
|
31b3219169 | ||
|
|
d8a99fcb07 | ||
|
|
8016d271aa | ||
|
|
8f1bef7f46 | ||
|
|
e6db57e031 | ||
|
|
daf749e6be | ||
|
|
53d167eda5 | ||
|
|
d74856f675 | ||
|
|
f8386843d4 | ||
|
|
2277a94c91 |
@@ -1,4 +1,4 @@
|
||||
name: Test Installers Build
|
||||
name: Multi-OS Tauri Releases
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -6,7 +6,23 @@ on:
|
||||
test_mode:
|
||||
description: "Run in test mode (skip release step)"
|
||||
required: false
|
||||
default: "false"
|
||||
default: "true"
|
||||
type: choice
|
||||
options:
|
||||
- "true"
|
||||
- "false"
|
||||
platform:
|
||||
description: "Platform to build (windows, macos, linux, or all)"
|
||||
required: true
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- windows
|
||||
- macos
|
||||
- linux
|
||||
push:
|
||||
branches: [main, V2, V2-demo, V2-master]
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
@@ -14,288 +30,500 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
read_versions:
|
||||
determine-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
# ✅ Get version from Gradle
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: |
|
||||
echo "Running gradlew printVersion..."
|
||||
./gradlew printVersion --quiet
|
||||
VERSION=$(./gradlew printVersion --quiet | tail -1)
|
||||
echo "Extracted version: $VERSION"
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# ✅ Get Mac-specific version from Gradle
|
||||
- name: Get version number mac
|
||||
id: versionNumberMac
|
||||
- name: Determine build matrix
|
||||
id: set-matrix
|
||||
run: |
|
||||
VERSION_MAC=$(./gradlew printMacVersion --quiet | tail -1)
|
||||
echo "versionNumberMac=$VERSION_MAC" >> $GITHUB_OUTPUT
|
||||
|
||||
build-portable:
|
||||
needs: read_versions
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
disable_security: [true, false]
|
||||
include:
|
||||
- disable_security: false
|
||||
file_suffix: "-with-login"
|
||||
- disable_security: true
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Generate jar (Disable Security=${{ matrix.disable_security }})
|
||||
run: ./gradlew clean createExe
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.disable_security }}
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Rename binaries
|
||||
run: |
|
||||
mkdir ./binaries
|
||||
mv ./build/launch4j/Stirling-PDF.exe ./binaries/win-Stirling-PDF-portable-Server${{ matrix.file_suffix }}.exe
|
||||
mv ./app/core/build/libs/stirling-pdf-${{ needs.read_versions.outputs.version }}.jar ./binaries/Stirling-PDF${{ matrix.file_suffix }}.jar
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
name: stirling${{ matrix.file_suffix }}-binaries
|
||||
path: |
|
||||
./binaries/*
|
||||
|
||||
sign_verify-portable:
|
||||
needs: [build-portable, read_versions]
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
disable_security: [true, false]
|
||||
include:
|
||||
- disable_security: false
|
||||
file_suffix: "with-login-"
|
||||
- disable_security: true
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: stirling-${{ matrix.file_suffix }}binaries
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
|
||||
- name: Upload signed artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
name: stirling-${{ matrix.file_suffix }}signed
|
||||
path: |
|
||||
./*
|
||||
!cosign.*
|
||||
|
||||
build-installers:
|
||||
needs: read_versions
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
platform: win-
|
||||
- os: macos-latest
|
||||
platform: mac-
|
||||
# - os: ubuntu-latest
|
||||
# platform: linux-
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
# Install Windows dependencies
|
||||
- name: Install WiX Toolset
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: |
|
||||
curl -L -o wix.exe https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314.exe
|
||||
.\wix.exe /install /quiet
|
||||
|
||||
# Build installer
|
||||
- name: Build Installer
|
||||
run: ./gradlew build jpackage -x test --info
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
STIRLING_PDF_DESKTOP_UI: true
|
||||
BROWSER_OPEN: true
|
||||
|
||||
- name: Set up JDK (x86_64)
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: |
|
||||
curl -L -o jdk.tar.gz https://cdn.azul.com/zulu/bin/zulu17.56.15-ca-jdk17.0.14-macosx_x64.tar.gz
|
||||
mkdir -p zulu17
|
||||
tar -xzf jdk.tar.gz -C zulu17 --strip-components=1
|
||||
echo "JAVA_HOME=$PWD/zulu17" >> $GITHUB_ENV
|
||||
echo "$PWD/zulu17/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Verify JDK architecture
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: file $JAVA_HOME/bin/java
|
||||
|
||||
- name: Build project and run jpackage (x86_64)
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: arch -x86_64 ./gradlew jpackageMacX64
|
||||
|
||||
# Rename and collect artifacts based on OS
|
||||
- name: Prepare artifacts
|
||||
id: prepare
|
||||
shell: bash
|
||||
run: |
|
||||
ls -lah ./build/jpackage/
|
||||
mkdir ./binaries
|
||||
if [ "${{ matrix.os }}" = "windows-latest" ]; then
|
||||
mv "./build/jpackage/Stirling PDF-${{ needs.read_versions.outputs.version }}.exe" "./binaries/Stirling-PDF-win-installer.exe"
|
||||
elif [ "${{ matrix.os }}" = "macos-latest" ]; then
|
||||
mv "./build/jpackage/Stirling PDF-${{ needs.read_versions.outputs.versionMac }}.dmg" "./binaries/Stirling-PDF-mac-installer.dmg"
|
||||
mv "./build/jpackage/x86_64/Stirling PDF (x86_64)-${{ needs.read_versions.outputs.versionMac }}.dmg" "./binaries/Stirling-PDF-mac-x86_64-installer.dmg"
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
case "${{ github.event.inputs.platform }}" in
|
||||
"windows")
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"macos")
|
||||
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"linux")
|
||||
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
*)
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
else
|
||||
mv "./build/jpackage/stirling-pdf_${{ needs.read_versions.outputs.version }}-1_amd64.deb" "./binaries/Stirling-PDF-linux-installer.deb"
|
||||
# For push/release events, build all platforms
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R ./binaries
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
name: ${{ matrix.platform }}binaries
|
||||
path: |
|
||||
./binaries/*
|
||||
|
||||
sign_verify:
|
||||
needs: [read_versions, build-installers]
|
||||
build-jars:
|
||||
needs: determine-matrix
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
platform: win-
|
||||
- os: macos-latest
|
||||
platform: mac-
|
||||
# - os: ubuntu-latest
|
||||
# platform: linux-
|
||||
runs-on: ubuntu-latest
|
||||
variant:
|
||||
- name: "default"
|
||||
disable_security: true
|
||||
build_frontend: true
|
||||
file_suffix: ""
|
||||
- name: "with-login"
|
||||
disable_security: false
|
||||
build_frontend: true
|
||||
file_suffix: "-with-login"
|
||||
- name: "server-only"
|
||||
disable_security: true
|
||||
build_frontend: false
|
||||
file_suffix: "-server"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
name: ${{ matrix.platform }}binaries
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Install Cosign
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
- name: Setup Node.js
|
||||
if: matrix.variant.build_frontend == true
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Generate key pair
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: cosign generate-key-pair
|
||||
- name: Build JAR
|
||||
run: ./gradlew clean build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.variant.disable_security }}
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Sign and generate attestations
|
||||
if: matrix.os == 'windows-latest'
|
||||
- name: Rename JAR
|
||||
run: |
|
||||
cosign sign-blob \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-signature ./Stirling-PDF-win-installer.exe.sig \
|
||||
./Stirling-PDF-win-installer.exe
|
||||
echo "Version from determine-matrix: ${{ needs.determine-matrix.outputs.version }}"
|
||||
echo "Looking for: app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar"
|
||||
ls -la app/core/build/libs/
|
||||
mkdir -p ./jar-dist
|
||||
cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar
|
||||
|
||||
cosign attest-blob \
|
||||
--predicate - \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-attestation ./Stirling-PDF-win-installer.exe.intoto.jsonl \
|
||||
./Stirling-PDF-win-installer.exe
|
||||
|
||||
cosign verify-blob \
|
||||
--key ./cosign.pub \
|
||||
--signature ./Stirling-PDF-win-installer.exe.sig \
|
||||
./Stirling-PDF-win-installer.exe
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
|
||||
- name: Upload signed artifacts
|
||||
- name: Upload JAR artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: jar${{ matrix.variant.file_suffix }}
|
||||
path: ./jar-dist/*.jar
|
||||
retention-days: 1
|
||||
|
||||
build:
|
||||
needs: determine-matrix
|
||||
strategy:
|
||||
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
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Build Java backend with JLink
|
||||
working-directory: ./
|
||||
shell: bash
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
echo "🔧 Building Stirling-PDF JAR..."
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Find the built JAR
|
||||
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
|
||||
echo "✅ Built JAR: $STIRLING_JAR"
|
||||
|
||||
# Create Tauri directories
|
||||
mkdir -p ./frontend/src-tauri/libs
|
||||
mkdir -p ./frontend/src-tauri/runtime
|
||||
|
||||
# Copy JAR to Tauri libs
|
||||
cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
|
||||
echo "✅ JAR copied to Tauri libs"
|
||||
|
||||
# Analyze JAR dependencies for jlink modules
|
||||
echo "🔍 Analyzing JAR dependencies..."
|
||||
if command -v jdeps &> /dev/null; then
|
||||
DETECTED_MODULES=$(jdeps --print-module-deps --ignore-missing-deps "$STIRLING_JAR" 2>/dev/null || echo "")
|
||||
if [ -n "$DETECTED_MODULES" ]; then
|
||||
echo "📋 jdeps detected modules: $DETECTED_MODULES"
|
||||
MODULES="$DETECTED_MODULES,java.compiler,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
else
|
||||
echo "⚠️ jdeps analysis failed, using predefined modules"
|
||||
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ jdeps not available, using predefined modules"
|
||||
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
fi
|
||||
|
||||
# Create custom JRE with jlink
|
||||
echo "🔧 Creating custom JRE with jlink..."
|
||||
echo "📋 Using modules: $MODULES"
|
||||
|
||||
# Remove any existing JRE
|
||||
rm -rf ./frontend/src-tauri/runtime/jre
|
||||
|
||||
# Create the custom JRE
|
||||
jlink \
|
||||
--add-modules "$MODULES" \
|
||||
--strip-debug \
|
||||
--compress=2 \
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output ./frontend/src-tauri/runtime/jre
|
||||
|
||||
if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
|
||||
echo "❌ Failed to create JLink runtime"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test the bundled runtime
|
||||
if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then
|
||||
RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
|
||||
echo "✅ Custom JRE created successfully: $RUNTIME_VERSION"
|
||||
else
|
||||
echo "❌ Custom JRE executable not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Calculate runtime size
|
||||
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
|
||||
echo "📊 Custom JRE size: $RUNTIME_SIZE"
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm install
|
||||
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
|
||||
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: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
|
||||
# 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 == '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
|
||||
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') && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master')
|
||||
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') && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master')
|
||||
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: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
|
||||
# Only enable Windows signing in Tauri when on release or V2-master
|
||||
SIGN: ${{ (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') && (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 != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
|
||||
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 ""
|
||||
|
||||
# Find only the files we need to sign
|
||||
$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
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if (-not $pkcs11Config) {
|
||||
Write-Host "[ERROR] PKCS11_CONFIG environment variable not set"
|
||||
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..."
|
||||
$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"
|
||||
|
||||
if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") {
|
||||
Write-Host "[ERROR] Signing failed for $($file.Name)"
|
||||
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: Rename artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ./dist
|
||||
cd ./frontend/src-tauri/target
|
||||
|
||||
# Find and rename artifacts based on platform
|
||||
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
|
||||
find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \;
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
|
||||
else
|
||||
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
|
||||
fi
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
name: ${{ matrix.platform }}signed
|
||||
path: |
|
||||
./Stirling-PDF-${{ matrix.platform }}installer.*
|
||||
./Stirling-PDF-${{ matrix.platform }}x86_64-installer.*
|
||||
!cosign.*
|
||||
|
||||
create-release:
|
||||
if: github.event_name != 'workflow_dispatch' || github.event.inputs.test_mode != 'true'
|
||||
needs: [read_versions, sign_verify, sign_verify-portable]
|
||||
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master'
|
||||
needs: [determine-matrix, build, build-jars]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -305,14 +533,43 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download signed artifacts
|
||||
- name: Download all Tauri artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
pattern: Stirling-PDF-*
|
||||
path: ./artifacts/tauri
|
||||
|
||||
- name: Download JAR artifact (default)
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: jar
|
||||
path: ./artifacts/jars
|
||||
|
||||
- name: Download JAR artifact (with login)
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: jar-with-login
|
||||
path: ./artifacts/jars
|
||||
|
||||
- name: Download JAR artifact (server only)
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: jar-server
|
||||
path: ./artifacts/jars
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
|
||||
run: ls -R ./artifacts
|
||||
|
||||
- name: Upload binaries to Release
|
||||
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
||||
with:
|
||||
tag_name: v${{ needs.read_versions.outputs.version }}
|
||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
./*signed/*
|
||||
./artifacts/**/*.jar
|
||||
./artifacts/**/*.msi
|
||||
./artifacts/**/*.dmg
|
||||
./artifacts/**/*.deb
|
||||
./artifacts/**/*.AppImage
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
name: Push Docker Image - V2 Branch
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- V2-master
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
# or a pull request is updated.
|
||||
# It helps to save resources and time by ensuring that only the latest commit is built and tested
|
||||
# This is particularly useful for long-running jobs that may take a while to complete.
|
||||
# The `group` is set to a combination of the workflow name, event name, and branch name.
|
||||
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
|
||||
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
push:
|
||||
runs-on: ubuntu-24.04-8core
|
||||
permissions:
|
||||
packages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate tags for latest (V2-master branch - production)
|
||||
id: meta
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Generate tags for latest (V2-demo branch - test)
|
||||
id: meta-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-demo'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Unified Dockerfile (latest variant)
|
||||
id: build-push-latest
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/Dockerfile.unified
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta.outputs.tags || steps.meta-test.outputs.tags }}
|
||||
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta.outputs.labels || steps.meta-test.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign regular images
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-latest.outputs.digest }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --yes \
|
||||
--key env://COSIGN_PRIVATE_KEY \
|
||||
"${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags for latest-fat (V2-master branch - production)
|
||||
id: meta-fat
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
|
||||
type=raw,value=latest-fat
|
||||
|
||||
- name: Generate tags for latest-fat (V2-demo branch - test)
|
||||
id: meta-fat-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-demo'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
|
||||
type=raw,value=latest-fat
|
||||
|
||||
- name: Build and push Unified Dockerfile (fat variant)
|
||||
id: build-push-fat
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/Dockerfile.unified
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-fat.outputs.tags || steps.meta-fat-test.outputs.tags }}
|
||||
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-fat.outputs.labels || steps.meta-fat-test.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign fat images
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-fat.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
|
||||
- name: Generate tags for ultra-lite (V2-master branch - production)
|
||||
id: meta-lite
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
|
||||
type=raw,value=latest-ultra-lite
|
||||
|
||||
- name: Generate tags for ultra-lite (V2-demo branch - test)
|
||||
id: meta-lite-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-demo'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
|
||||
type=raw,value=latest-ultra-lite
|
||||
|
||||
- name: Build and push Unified Dockerfile (ultra-lite variant)
|
||||
id: build-push-lite
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/Dockerfile.unified-lite
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-lite.outputs.tags || steps.meta-lite-test.outputs.tags }}
|
||||
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-lite.outputs.labels || steps.meta-lite-test.outputs.labels }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign ultra-lite images
|
||||
if: github.ref == 'refs/heads/V2-master'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
|
||||
TAGS: ${{ steps.meta-lite.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
@@ -2,85 +2,445 @@ name: Release Artifacts
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [created]
|
||||
inputs:
|
||||
platform:
|
||||
description: "Platform to build (windows, macos, linux, or all)"
|
||||
required: true
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- windows
|
||||
- macos
|
||||
- linux
|
||||
push:
|
||||
branches: [main, V2, V2-demo]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
determine-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
disable_security: [true, false]
|
||||
include:
|
||||
- disable_security: false
|
||||
file_suffix: "-with-login"
|
||||
- disable_security: true
|
||||
file_suffix: ""
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Generate jar (Disable Security=${{ matrix.disable_security }})
|
||||
run: ./gradlew clean createExe
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.disable_security }}
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: |
|
||||
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Rename binaries
|
||||
- name: Determine build matrix
|
||||
id: set-matrix
|
||||
run: |
|
||||
mv ./build/launch4j/Stirling-PDF.exe ./build/launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
|
||||
mv ./build/libs/Stirling-PDF-${{ steps.versionNumber.outputs.versionNumber }}.jar ./build/libs/Stirling-PDF${{ matrix.file_suffix }}.jar
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
case "${{ github.event.inputs.platform }}" in
|
||||
"windows")
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"macos")
|
||||
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"linux")
|
||||
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
*)
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# For push events, build all platforms
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Debug build artifacts
|
||||
build:
|
||||
needs: determine-matrix
|
||||
strategy:
|
||||
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
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
echo "Current Directory: $(pwd)"
|
||||
ls -R ./build/libs
|
||||
ls -R ./build/launch4j
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Build Java backend with JLink
|
||||
working-directory: ./
|
||||
shell: bash
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
echo "🔧 Building Stirling-PDF JAR..."
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Find the built JAR
|
||||
STIRLING_JAR=$(ls app/core/build/libs/stirling-pdf-*.jar | head -n 1)
|
||||
echo "✅ Built JAR: $STIRLING_JAR"
|
||||
|
||||
# Create Tauri directories
|
||||
mkdir -p ./frontend/src-tauri/libs
|
||||
mkdir -p ./frontend/src-tauri/runtime
|
||||
|
||||
# Copy JAR to Tauri libs
|
||||
cp "$STIRLING_JAR" ./frontend/src-tauri/libs/
|
||||
echo "✅ JAR copied to Tauri libs"
|
||||
|
||||
# Analyze JAR dependencies for jlink modules
|
||||
echo "🔍 Analyzing JAR dependencies..."
|
||||
if command -v jdeps &> /dev/null; then
|
||||
DETECTED_MODULES=$(jdeps --print-module-deps --ignore-missing-deps "$STIRLING_JAR" 2>/dev/null || echo "")
|
||||
if [ -n "$DETECTED_MODULES" ]; then
|
||||
echo "📋 jdeps detected modules: $DETECTED_MODULES"
|
||||
MODULES="$DETECTED_MODULES,java.compiler,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
else
|
||||
echo "⚠️ jdeps analysis failed, using predefined modules"
|
||||
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ jdeps not available, using predefined modules"
|
||||
MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported"
|
||||
fi
|
||||
|
||||
# Create custom JRE with jlink
|
||||
echo "🔧 Creating custom JRE with jlink..."
|
||||
echo "📋 Using modules: $MODULES"
|
||||
|
||||
# Remove any existing JRE
|
||||
rm -rf ./frontend/src-tauri/runtime/jre
|
||||
|
||||
# Create the custom JRE
|
||||
jlink \
|
||||
--add-modules "$MODULES" \
|
||||
--strip-debug \
|
||||
--compress=2 \
|
||||
--no-header-files \
|
||||
--no-man-pages \
|
||||
--output ./frontend/src-tauri/runtime/jre
|
||||
|
||||
if [ ! -d "./frontend/src-tauri/runtime/jre" ]; then
|
||||
echo "❌ Failed to create JLink runtime"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test the bundled runtime
|
||||
if [ -f "./frontend/src-tauri/runtime/jre/bin/java" ]; then
|
||||
RUNTIME_VERSION=$(./frontend/src-tauri/runtime/jre/bin/java --version 2>&1 | head -n 1)
|
||||
echo "✅ Custom JRE created successfully: $RUNTIME_VERSION"
|
||||
else
|
||||
echo "❌ Custom JRE executable not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Calculate runtime size
|
||||
RUNTIME_SIZE=$(du -sh ./frontend/src-tauri/runtime/jre | cut -f1)
|
||||
echo "📊 Custom JRE size: $RUNTIME_SIZE"
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm install
|
||||
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
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: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
|
||||
# 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 == '' && github.ref == 'refs/heads/main' }}
|
||||
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: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
|
||||
# Only enable Windows signing in Tauri when on main
|
||||
SIGN: ${{ github.ref == 'refs/heads/main' && (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 != '' && github.ref == 'refs/heads/main' }}
|
||||
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 ""
|
||||
|
||||
# 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"
|
||||
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: Rename artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ./dist
|
||||
cd ./frontend/src-tauri/target
|
||||
|
||||
# Find and rename artifacts based on platform
|
||||
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
|
||||
find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \;
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
|
||||
else
|
||||
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
|
||||
fi
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: binaries${{ matrix.file_suffix }}
|
||||
path: |
|
||||
./build/launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.*
|
||||
./build/libs/Stirling-PDF${{ matrix.file_suffix }}.*
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
retention-days: 30
|
||||
|
||||
sign_verify:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: success()
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
disable_security: [true, false]
|
||||
include:
|
||||
- disable_security: false
|
||||
file_suffix: "-with-login"
|
||||
- disable_security: true
|
||||
file_suffix: ""
|
||||
name: [windows-x86_64, macos-aarch64, macos-x86_64, linux-x86_64]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
@@ -90,7 +450,8 @@ jobs:
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: binaries${{ matrix.file_suffix }}
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
|
||||
@@ -101,80 +462,74 @@ jobs:
|
||||
run: cosign generate-key-pair
|
||||
|
||||
- name: Sign and generate attestations
|
||||
shell: bash
|
||||
run: |
|
||||
cosign sign-blob \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-signature ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.sig \
|
||||
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
|
||||
# Sign all artifacts for this platform
|
||||
for file in *; do
|
||||
if [ -f "$file" ] && [[ ! "$file" =~ \.(sig|intoto\.jsonl)$ ]]; then
|
||||
echo "Signing: $file"
|
||||
|
||||
cosign attest-blob \
|
||||
--predicate - \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-attestation ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.intoto.jsonl \
|
||||
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
|
||||
# Sign the artifact
|
||||
cosign sign-blob \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-signature "${file}.sig" \
|
||||
"$file"
|
||||
|
||||
cosign verify-blob \
|
||||
--key ./cosign.pub \
|
||||
--signature ./libs/Stirling-PDF${{ matrix.file_suffix }}.jar.sig \
|
||||
./libs/Stirling-PDF${{ matrix.file_suffix }}.jar
|
||||
# Generate attestation
|
||||
cosign attest-blob \
|
||||
--predicate - \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-attestation "${file}.intoto.jsonl" \
|
||||
"$file"
|
||||
|
||||
cosign sign-blob \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-signature ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.sig \
|
||||
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
|
||||
# Verify the signature
|
||||
cosign verify-blob \
|
||||
--key ./cosign.pub \
|
||||
--signature "${file}.sig" \
|
||||
"$file"
|
||||
|
||||
cosign attest-blob \
|
||||
--predicate - \
|
||||
--key ./cosign.key \
|
||||
--yes \
|
||||
--output-attestation ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.intoto.jsonl \
|
||||
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
|
||||
|
||||
cosign verify-blob \
|
||||
--key ./cosign.pub \
|
||||
--signature ./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe.sig \
|
||||
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.exe
|
||||
echo "✅ Signed and verified: $file"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Upload signed artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: signed${{ matrix.file_suffix }}
|
||||
name: Stirling-PDF-${{ matrix.name }}-signed
|
||||
path: |
|
||||
./libs/Stirling-PDF${{ matrix.file_suffix }}.*
|
||||
./launch4j/Stirling-PDF-Server${{ matrix.file_suffix }}.*
|
||||
*
|
||||
!cosign.key
|
||||
!cosign.pub
|
||||
retention-days: 30
|
||||
|
||||
release:
|
||||
needs: [build, sign_verify]
|
||||
needs: [determine-matrix, build, sign_verify]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
disable_security: [true, false]
|
||||
include:
|
||||
- disable_security: false
|
||||
file_suffix: "-with-login"
|
||||
- disable_security: true
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download signed artifacts
|
||||
- name: Download all signed artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: signed${{ matrix.file_suffix }}
|
||||
pattern: Stirling-PDF-*-signed
|
||||
path: ./artifacts
|
||||
|
||||
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R ./artifacts
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
||||
with:
|
||||
tag_name: v${{ needs.build.outputs.version }}
|
||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
./libs/Stirling-PDF*
|
||||
./launch4j/Stirling-PDF-Server*
|
||||
files: ./artifacts/**/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
+16
-2
@@ -17,8 +17,8 @@ local.properties
|
||||
version.properties
|
||||
|
||||
#### Stirling-PDF Files ###
|
||||
pipeline/watchedFolders/
|
||||
pipeline/finishedFolders/
|
||||
pipeline/
|
||||
!pipeline/.gitkeep
|
||||
customFiles/
|
||||
configs/
|
||||
watchedFolders/
|
||||
@@ -31,6 +31,20 @@ exampleYmlFiles/stirling/
|
||||
/testing/file_snapshots
|
||||
SwaggerDoc.json
|
||||
|
||||
# Frontend build artifacts copied to backend static resources
|
||||
# These are generated by npm build and should not be committed
|
||||
app/core/src/main/resources/static/assets/
|
||||
app/core/src/main/resources/static/index.html
|
||||
app/core/src/main/resources/static/locales/
|
||||
app/core/src/main/resources/static/Login/
|
||||
app/core/src/main/resources/static/classic-logo/
|
||||
app/core/src/main/resources/static/modern-logo/
|
||||
app/core/src/main/resources/static/og_images/
|
||||
app/core/src/main/resources/static/samples/
|
||||
app/core/src/main/resources/static/manifest-classic.json
|
||||
app/core/src/main/resources/static/robots.txt
|
||||
# Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc.
|
||||
|
||||
# Gradle
|
||||
.gradle
|
||||
.gradle-home
|
||||
|
||||
@@ -6,6 +6,10 @@ Portions of this software are licensed as follows:
|
||||
|
||||
* All content that resides under the "app/proprietary/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "app/proprietary/LICENSE".
|
||||
* All content that resides under the "frontend/src/proprietary/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/proprietary/LICENSE".
|
||||
* All content that resides under the "frontend/src/desktop/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/desktop/LICENSE".
|
||||
* Content outside of the above mentioned directories or restrictions above is
|
||||
available under the MIT License as defined below.
|
||||
|
||||
|
||||
@@ -7,23 +7,103 @@ public class RequestUriUtils {
|
||||
}
|
||||
|
||||
public static boolean isStaticResource(String contextPath, String requestURI) {
|
||||
return requestURI.startsWith(contextPath + "/css/")
|
||||
|| requestURI.startsWith(contextPath + "/fonts/")
|
||||
|| requestURI.startsWith(contextPath + "/js/")
|
||||
|| requestURI.endsWith(contextPath + "robots.txt")
|
||||
|| requestURI.startsWith(contextPath + "/images/")
|
||||
|| requestURI.startsWith(contextPath + "/public/")
|
||||
|| requestURI.startsWith(contextPath + "/pdfjs/")
|
||||
|| requestURI.startsWith(contextPath + "/pdfjs-legacy/")
|
||||
|| requestURI.startsWith(contextPath + "/login")
|
||||
|| requestURI.startsWith(contextPath + "/error")
|
||||
|| requestURI.startsWith(contextPath + "/favicon")
|
||||
|| requestURI.endsWith(".svg")
|
||||
|| requestURI.endsWith(".png")
|
||||
|| requestURI.endsWith(".ico")
|
||||
|| requestURI.endsWith(".txt")
|
||||
|| requestURI.endsWith(".webmanifest")
|
||||
|| requestURI.startsWith(contextPath + "/api/v1/info/status");
|
||||
if (requestURI == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String normalizedUri = stripContextPath(contextPath, requestURI);
|
||||
|
||||
// API routes are never static except for the public status endpoint
|
||||
if (normalizedUri.startsWith("/api/")) {
|
||||
return normalizedUri.startsWith("/api/v1/info/status");
|
||||
}
|
||||
|
||||
// Well-known static asset directories (backend + React build artifacts)
|
||||
if (normalizedUri.startsWith("/css/")
|
||||
|| normalizedUri.startsWith("/fonts/")
|
||||
|| normalizedUri.startsWith("/js/")
|
||||
|| normalizedUri.startsWith("/images/")
|
||||
|| normalizedUri.startsWith("/public/")
|
||||
|| normalizedUri.startsWith("/pdfjs/")
|
||||
|| normalizedUri.startsWith("/pdfjs-legacy/")
|
||||
|| normalizedUri.startsWith("/assets/")
|
||||
|| normalizedUri.startsWith("/locales/")
|
||||
|| normalizedUri.startsWith("/Login/")
|
||||
|| normalizedUri.startsWith("/samples/")
|
||||
|| normalizedUri.startsWith("/classic-logo/")
|
||||
|| normalizedUri.startsWith("/modern-logo/")
|
||||
|| normalizedUri.startsWith("/og_images/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Specific static files bundled with the frontend
|
||||
if (normalizedUri.equals("/robots.txt")
|
||||
|| normalizedUri.equals("/favicon.ico")
|
||||
|| normalizedUri.equals("/site.webmanifest")
|
||||
|| normalizedUri.equals("/manifest-classic.json")
|
||||
|| normalizedUri.equals("/index.html")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Login/error pages remain public
|
||||
if (normalizedUri.startsWith("/login") || normalizedUri.startsWith("/error")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Treat common static file extensions as static resources
|
||||
return normalizedUri.endsWith(".svg")
|
||||
|| normalizedUri.endsWith(".png")
|
||||
|| normalizedUri.endsWith(".ico")
|
||||
|| normalizedUri.endsWith(".txt")
|
||||
|| normalizedUri.endsWith(".webmanifest")
|
||||
|| normalizedUri.endsWith(".js")
|
||||
|| normalizedUri.endsWith(".css")
|
||||
|| normalizedUri.endsWith(".mjs")
|
||||
|| normalizedUri.endsWith(".html")
|
||||
|| normalizedUri.endsWith(".toml");
|
||||
}
|
||||
|
||||
public static boolean isFrontendRoute(String contextPath, String requestURI) {
|
||||
if (requestURI == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String normalizedUri = stripContextPath(contextPath, requestURI);
|
||||
|
||||
// APIs are never treated as frontend routes
|
||||
if (normalizedUri.startsWith("/api/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Blocklist of backend/non-frontend paths that should still go through filters
|
||||
String[] backendOnlyPrefixes = {
|
||||
"/register",
|
||||
"/invite",
|
||||
"/pipeline",
|
||||
"/pdfjs",
|
||||
"/pdfjs-legacy",
|
||||
"/fonts",
|
||||
"/images",
|
||||
"/files",
|
||||
"/css",
|
||||
"/js",
|
||||
"/swagger",
|
||||
"/v1/api-docs",
|
||||
"/actuator"
|
||||
};
|
||||
|
||||
for (String prefix : backendOnlyPrefixes) {
|
||||
if (normalizedUri.equals(prefix) || normalizedUri.startsWith(prefix + "/")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedUri.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow root and any extensionless path (React Router will handle these)
|
||||
return !normalizedUri.contains(".");
|
||||
}
|
||||
|
||||
public static boolean isTrackableResource(String requestURI) {
|
||||
@@ -43,6 +123,7 @@ public class RequestUriUtils {
|
||||
|| requestURI.endsWith(".svg")
|
||||
|| requestURI.endsWith("popularity.txt")
|
||||
|| requestURI.endsWith(".js")
|
||||
|| requestURI.endsWith(".toml")
|
||||
|| requestURI.contains("swagger")
|
||||
|| requestURI.startsWith("/api/v1/info")
|
||||
|| requestURI.startsWith("/site.webmanifest")
|
||||
@@ -83,4 +164,11 @@ public class RequestUriUtils {
|
||||
|| trimmedUri.startsWith("/api/v1/invite/accept")
|
||||
|| trimmedUri.contains("/v1/api-docs");
|
||||
}
|
||||
|
||||
private static String stripContextPath(String contextPath, String requestURI) {
|
||||
if (contextPath != null && !contextPath.isBlank() && requestURI.startsWith(contextPath)) {
|
||||
return requestURI.substring(contextPath.length());
|
||||
}
|
||||
return requestURI;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,26 @@ public class RequestUriUtilsTest {
|
||||
"API products should not be static");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsFrontendRoute() {
|
||||
assertTrue(RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route");
|
||||
assertTrue(
|
||||
RequestUriUtils.isFrontendRoute("", "/app/dashboard"),
|
||||
"React routes without extensions should be frontend routes");
|
||||
assertFalse(
|
||||
RequestUriUtils.isFrontendRoute("", "/api/v1/users"),
|
||||
"API routes should not be frontend routes");
|
||||
assertFalse(
|
||||
RequestUriUtils.isFrontendRoute("", "/register"),
|
||||
"Register should not be treated as a frontend route");
|
||||
assertFalse(
|
||||
RequestUriUtils.isFrontendRoute("", "/pipeline/jobs"),
|
||||
"Pipeline should not be treated as a frontend route");
|
||||
assertFalse(
|
||||
RequestUriUtils.isFrontendRoute("", "/files/download"),
|
||||
"Files path should not be treated as a frontend route");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsStaticResourceWithContextPath() {
|
||||
String contextPath = "/myapp";
|
||||
@@ -83,6 +103,7 @@ public class RequestUriUtilsTest {
|
||||
"/favicon.ico",
|
||||
"/icon.svg",
|
||||
"/image.png",
|
||||
"/locales/en/translation.toml",
|
||||
"/site.webmanifest",
|
||||
"/app/logo.svg",
|
||||
"/downloads/document.png",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
apply plugin: 'org.springframework.boot'
|
||||
|
||||
import org.apache.tools.ant.taskdefs.condition.Os
|
||||
|
||||
repositories {
|
||||
maven { url = 'https://build.shibboleth.net/maven/releases' }
|
||||
maven { url = 'https://maven.pkg.github.com/jcefmaven/jcefmaven' }
|
||||
@@ -15,6 +17,7 @@ configurations {
|
||||
spotless {
|
||||
java {
|
||||
target 'src/**/java/**/*.java'
|
||||
targetExclude 'src/main/resources/static/**'
|
||||
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
||||
|
||||
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
||||
@@ -25,12 +28,14 @@ spotless {
|
||||
}
|
||||
yaml {
|
||||
target '**/*.yml', '**/*.yaml'
|
||||
targetExclude 'src/main/resources/static/**'
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
}
|
||||
format 'gradle', {
|
||||
target '**/gradle/*.gradle', '**/*.gradle'
|
||||
targetExclude 'src/main/resources/static/**'
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
@@ -157,5 +162,125 @@ springBoot {
|
||||
mainClass = 'stirling.software.SPDF.SPDFApplication'
|
||||
}
|
||||
|
||||
// Frontend build tasks - only enabled with -PbuildWithFrontend=true
|
||||
def buildWithFrontend = project.hasProperty('buildWithFrontend') && project.property('buildWithFrontend') == 'true'
|
||||
def frontendDir = file('../../frontend')
|
||||
def frontendDistDir = file('../../frontend/dist')
|
||||
def resourcesStaticDir = file('src/main/resources/static')
|
||||
def generatedFrontendPaths = [
|
||||
'assets',
|
||||
'index.html',
|
||||
'locales',
|
||||
'Login',
|
||||
'classic-logo',
|
||||
'modern-logo',
|
||||
'og_images',
|
||||
'samples',
|
||||
'manifest-classic.json'
|
||||
]
|
||||
|
||||
tasks.register('npmInstall', Exec) {
|
||||
enabled = buildWithFrontend
|
||||
group = 'frontend'
|
||||
description = 'Install frontend dependencies'
|
||||
workingDir frontendDir
|
||||
commandLine = Os.isFamily(Os.FAMILY_WINDOWS) ? ['cmd', '/c', 'npm', 'ci', '--prefer-offline'] : ['npm', 'ci', '--prefer-offline']
|
||||
inputs.file(new File(frontendDir, 'package.json'))
|
||||
inputs.file(new File(frontendDir, 'package-lock.json'))
|
||||
outputs.dir(new File(frontendDir, 'node_modules'))
|
||||
|
||||
// Show live output
|
||||
standardOutput = System.out
|
||||
errorOutput = System.err
|
||||
|
||||
// Skip if node_modules exists and is up-to-date
|
||||
onlyIf {
|
||||
def nodeModules = new File(frontendDir, 'node_modules')
|
||||
if (!nodeModules.exists()) {
|
||||
println "node_modules not found, will install..."
|
||||
return true
|
||||
}
|
||||
def packageJson = new File(frontendDir, 'package.json')
|
||||
def packageLock = new File(frontendDir, 'package-lock.json')
|
||||
def isOutdated = nodeModules.lastModified() < packageJson.lastModified() ||
|
||||
nodeModules.lastModified() < packageLock.lastModified()
|
||||
if (isOutdated) {
|
||||
println "package.json or package-lock.json changed, will reinstall..."
|
||||
} else {
|
||||
println "node_modules is up-to-date, skipping npm install"
|
||||
}
|
||||
return isOutdated
|
||||
}
|
||||
|
||||
doFirst {
|
||||
println "Installing npm dependencies in ${frontendDir}..."
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('npmBuild', Exec) {
|
||||
enabled = buildWithFrontend
|
||||
group = 'frontend'
|
||||
description = 'Build frontend application'
|
||||
workingDir frontendDir
|
||||
commandLine = Os.isFamily(Os.FAMILY_WINDOWS) ? ['cmd', '/c', 'npm', 'run', 'build'] : ['npm', 'run', 'build']
|
||||
dependsOn npmInstall
|
||||
inputs.dir(new File(frontendDir, 'src'))
|
||||
inputs.file(new File(frontendDir, 'package.json'))
|
||||
outputs.dir(frontendDistDir)
|
||||
|
||||
// Show live output
|
||||
standardOutput = System.out
|
||||
errorOutput = System.err
|
||||
|
||||
// Override VITE_API_BASE_URL to use relative paths for production builds
|
||||
// This ensures JARs work regardless of how they're deployed (direct, proxied, etc.)
|
||||
environment 'VITE_API_BASE_URL', '/'
|
||||
|
||||
doFirst {
|
||||
println "Building frontend application for production (VITE_API_BASE_URL=/)"
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('copyFrontendAssets', Copy) {
|
||||
enabled = buildWithFrontend
|
||||
group = 'frontend'
|
||||
description = 'Copy frontend build to static resources'
|
||||
dependsOn npmBuild
|
||||
from(frontendDistDir) {
|
||||
// Exclude files that conflict with backend static resources
|
||||
exclude 'robots.txt' // Backend already has this
|
||||
exclude 'favicon.ico' // Backend already has this
|
||||
}
|
||||
into resourcesStaticDir
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE // Let frontend overwrite when needed
|
||||
doFirst {
|
||||
println "Copying frontend build from ${frontendDistDir} to ${resourcesStaticDir}..."
|
||||
println "Backend static resources will be preserved"
|
||||
}
|
||||
doLast {
|
||||
println "Frontend assets copied successfully!"
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('cleanFrontendAssets', Delete) {
|
||||
group = 'frontend'
|
||||
description = 'Remove previously generated frontend assets from static resources'
|
||||
delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) }
|
||||
}
|
||||
|
||||
// Ensure copyFrontendAssets runs after spotless tasks
|
||||
tasks.named('copyFrontendAssets').configure {
|
||||
mustRunAfter tasks.matching { it.name.startsWith('spotless') }
|
||||
}
|
||||
|
||||
if (buildWithFrontend) {
|
||||
println "Frontend build enabled - JAR will include React frontend"
|
||||
processResources.dependsOn copyFrontendAssets
|
||||
} else {
|
||||
println "Frontend build disabled - JAR will be backend-only"
|
||||
// When not building the UI, ensure any stale frontend assets are removed
|
||||
processResources.dependsOn cleanFrontendAssets
|
||||
}
|
||||
|
||||
bootJar.dependsOn ':common:jar'
|
||||
bootJar.dependsOn ':proprietary:jar'
|
||||
|
||||
@@ -42,7 +42,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
// Tauri v1 uses tauri://localhost, v2 uses http(s)://tauri.localhost
|
||||
logger.info("Tauri mode detected - enabling CORS for Tauri protocols (v1 and v2)");
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(
|
||||
.allowedOriginPatterns(
|
||||
"tauri://localhost",
|
||||
"http://tauri.localhost",
|
||||
"https://tauri.localhost")
|
||||
|
||||
+4
-3
@@ -1,16 +1,17 @@
|
||||
package stirling.software.SPDF.controller.web;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
|
||||
@Controller
|
||||
public class ReactRoutingController {
|
||||
|
||||
@GetMapping("/{path:^(?!api|static|robots\\.txt|favicon\\.ico)[^\\.]*$}")
|
||||
@GetMapping("/{path:^(?!api|static|robots\\.txt|favicon\\.ico|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*$}")
|
||||
public String forwardRootPaths() {
|
||||
return "forward:/index.html";
|
||||
}
|
||||
|
||||
@GetMapping("/{path:^(?!api|static)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
@GetMapping("/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
public String forwardNestedPaths() {
|
||||
return "forward:/index.html";
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ server.servlet.session.timeout:30m
|
||||
springdoc.api-docs.path=/v1/api-docs
|
||||
# Set the URL of the OpenAPI JSON for the Swagger UI
|
||||
springdoc.swagger-ui.url=/v1/api-docs
|
||||
springdoc.swagger-ui.path=/index.html
|
||||
springdoc.swagger-ui.path=/swagger-ui.html
|
||||
# Force OpenAPI 3.0 specification version
|
||||
springdoc.api-docs.version=OPENAPI_3_0
|
||||
posthog.api.key=phc_fiR65u5j6qmXTYL56MNrLZSWqLaDW74OrZH0Insd2xq
|
||||
|
||||
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
@@ -76,4 +76,9 @@
|
||||
.cm__btns{
|
||||
padding-top: 1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Lower z-index so cookie banner appears behind onboarding modals */
|
||||
#cc-main {
|
||||
z-index: 100 !important;
|
||||
}
|
||||
+4
-2
@@ -397,7 +397,8 @@ public class ProprietaryUIDataController {
|
||||
Map<Long, Date> teamLastRequest = new HashMap<>();
|
||||
for (Object[] result : teamActivities) {
|
||||
Long teamId = (Long) result[0];
|
||||
Date lastActivity = (Date) result[1];
|
||||
Instant instant = (Instant) result[1];
|
||||
Date lastActivity = instant != null ? Date.from(instant) : null;
|
||||
teamLastRequest.put(teamId, lastActivity);
|
||||
}
|
||||
|
||||
@@ -441,7 +442,8 @@ public class ProprietaryUIDataController {
|
||||
Map<String, Date> userLastRequest = new HashMap<>();
|
||||
for (Object[] result : userSessions) {
|
||||
String username = (String) result[0];
|
||||
Date lastRequest = (Date) result[1];
|
||||
Instant instant = (Instant) result[1];
|
||||
Date lastRequest = instant != null ? Date.from(instant) : null;
|
||||
userLastRequest.put(username, lastRequest);
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -238,6 +238,12 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
path = "errorOAuth=oAuth2AutoCreateDisabled";
|
||||
} else if (request.getParameter("oAuth2AdminBlockedUser") != null) {
|
||||
path = "errorOAuth=oAuth2AdminBlockedUser";
|
||||
} else if (request.getParameter("oAuth2RequiresLicense") != null) {
|
||||
path = "errorOAuth=oAuth2RequiresLicense";
|
||||
} else if (request.getParameter("saml2RequiresLicense") != null) {
|
||||
path = "errorOAuth=saml2RequiresLicense";
|
||||
} else if (request.getParameter("maxUsersReached") != null) {
|
||||
path = "errorOAuth=maxUsersReached";
|
||||
} else if (request.getParameter("userIsDisabled") != null) {
|
||||
path = "errorOAuth=userIsDisabled";
|
||||
} else if ((errorMessage = request.getParameter("error")) != null) {
|
||||
|
||||
+1
@@ -62,6 +62,7 @@ public class InitialSecuritySetup {
|
||||
private void initializeUserLicenseSettings() {
|
||||
licenseSettingsService.initializeGrandfatheredCount();
|
||||
licenseSettingsService.updateLicenseMaxUsers();
|
||||
licenseSettingsService.grandfatherExistingOAuthUsers();
|
||||
}
|
||||
|
||||
private void configureJWTSettings() {
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ public class PremiumEndpointAspect {
|
||||
public Object checkPremiumAccess(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
if (!runningProOrHigher) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN, "This endpoint requires a Pro or higher license");
|
||||
HttpStatus.FORBIDDEN, "This endpoint requires a Server or Enterprise license");
|
||||
}
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
|
||||
+11
-4
@@ -84,6 +84,8 @@ public class SecurityConfiguration {
|
||||
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
|
||||
private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations;
|
||||
private final OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver;
|
||||
private final stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService;
|
||||
|
||||
public SecurityConfiguration(
|
||||
PersistentLoginRepository persistentLoginRepository,
|
||||
@@ -103,7 +105,9 @@ public class SecurityConfiguration {
|
||||
@Autowired(required = false)
|
||||
RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations,
|
||||
@Autowired(required = false)
|
||||
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver) {
|
||||
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver,
|
||||
stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService) {
|
||||
this.userDetailsService = userDetailsService;
|
||||
this.userService = userService;
|
||||
this.loginEnabledValue = loginEnabledValue;
|
||||
@@ -120,10 +124,11 @@ public class SecurityConfiguration {
|
||||
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
|
||||
this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations;
|
||||
this.saml2AuthenticationRequestResolver = saml2AuthenticationRequestResolver;
|
||||
this.licenseSettingsService = licenseSettingsService;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
public static PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@@ -354,7 +359,8 @@ public class SecurityConfiguration {
|
||||
loginAttemptService,
|
||||
securityProperties.getOauth2(),
|
||||
userService,
|
||||
jwtService))
|
||||
jwtService,
|
||||
licenseSettingsService))
|
||||
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
|
||||
// Add existing Authorities from the database
|
||||
.userInfoEndpoint(
|
||||
@@ -395,7 +401,8 @@ public class SecurityConfiguration {
|
||||
loginAttemptService,
|
||||
securityProperties.getSaml2(),
|
||||
userService,
|
||||
jwtService))
|
||||
jwtService,
|
||||
licenseSettingsService))
|
||||
.failureHandler(
|
||||
new CustomSaml2AuthenticationFailureHandler())
|
||||
.authenticationRequestResolver(
|
||||
|
||||
+2
-1
@@ -32,7 +32,8 @@ public class EEAppConfig {
|
||||
@Bean(name = "runningProOrHigher")
|
||||
@Primary
|
||||
public boolean runningProOrHigher() {
|
||||
return licenseKeyChecker.getPremiumLicenseEnabledResult() != License.NORMAL;
|
||||
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
|
||||
return license == License.SERVER || license == License.ENTERPRISE;
|
||||
}
|
||||
|
||||
@Profile("security")
|
||||
|
||||
+57
-33
@@ -30,7 +30,7 @@ public class KeygenLicenseVerifier {
|
||||
|
||||
public enum License {
|
||||
NORMAL,
|
||||
PRO,
|
||||
SERVER,
|
||||
ENTERPRISE
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ public class KeygenLicenseVerifier {
|
||||
log.info("Detected certificate-based license. Processing...");
|
||||
boolean isValid = verifyCertificateLicense(licenseKeyOrCert, context);
|
||||
if (isValid) {
|
||||
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.PRO;
|
||||
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.SERVER;
|
||||
} else {
|
||||
license = License.NORMAL;
|
||||
}
|
||||
@@ -84,7 +84,7 @@ public class KeygenLicenseVerifier {
|
||||
log.info("Detected JWT-style license key. Processing...");
|
||||
boolean isValid = verifyJWTLicense(licenseKeyOrCert, context);
|
||||
if (isValid) {
|
||||
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.PRO;
|
||||
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.SERVER;
|
||||
} else {
|
||||
license = License.NORMAL;
|
||||
}
|
||||
@@ -92,7 +92,7 @@ public class KeygenLicenseVerifier {
|
||||
log.info("Detected standard license key. Processing...");
|
||||
boolean isValid = verifyStandardLicense(licenseKeyOrCert, context);
|
||||
if (isValid) {
|
||||
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.PRO;
|
||||
license = context.isEnterpriseLicense ? License.ENTERPRISE : License.SERVER;
|
||||
} else {
|
||||
license = License.NORMAL;
|
||||
}
|
||||
@@ -261,10 +261,23 @@ public class KeygenLicenseVerifier {
|
||||
// Extract metadata
|
||||
JSONObject metadataObj = attributesObj.optJSONObject("metadata");
|
||||
if (metadataObj != null) {
|
||||
int users = metadataObj.optInt("users", 1);
|
||||
applicationProperties.getPremium().setMaxUsers(users);
|
||||
log.info("License allows for {} users", users);
|
||||
// Check if this is an old license (no planType) with isEnterprise flag
|
||||
context.isEnterpriseLicense = metadataObj.optBoolean("isEnterprise", false);
|
||||
|
||||
// Extract user count - default based on license type
|
||||
// Old licenses: Only had isEnterprise flag
|
||||
// New licenses: Have planType field with "server" or "enterprise"
|
||||
int users = metadataObj.optInt("users", context.isEnterpriseLicense ? 1 : 0);
|
||||
|
||||
// SERVER license (isEnterprise=false, users=0) = unlimited
|
||||
// ENTERPRISE license (isEnterprise=true, users>0) = limited seats
|
||||
if (!context.isEnterpriseLicense && users == 0) {
|
||||
log.info("SERVER license detected with unlimited users");
|
||||
} else if (context.isEnterpriseLicense && users > 0) {
|
||||
log.info("ENTERPRISE license allows for {} users", users);
|
||||
}
|
||||
|
||||
applicationProperties.getPremium().setMaxUsers(users);
|
||||
}
|
||||
|
||||
// Check license status if available
|
||||
@@ -431,28 +444,31 @@ public class KeygenLicenseVerifier {
|
||||
}
|
||||
|
||||
// Extract max users and isEnterprise from policy or metadata
|
||||
int users = policyObj.optInt("users", 1);
|
||||
context.isEnterpriseLicense = policyObj.optBoolean("isEnterprise", false);
|
||||
int users = policyObj.optInt("users", -1);
|
||||
|
||||
if (users > 0) {
|
||||
applicationProperties.getPremium().setMaxUsers(users);
|
||||
log.info("License allows for {} users", users);
|
||||
} else {
|
||||
// Try to get users from metadata if present
|
||||
if (users == -1) {
|
||||
// Try to get users from metadata if not at policy level
|
||||
Object metadataObj = policyObj.opt("metadata");
|
||||
if (metadataObj instanceof JSONObject metadata) {
|
||||
users = metadata.optInt("users", 1);
|
||||
applicationProperties.getPremium().setMaxUsers(users);
|
||||
log.info("License allows for {} users (from metadata)", users);
|
||||
|
||||
// Check for isEnterprise flag in metadata
|
||||
context.isEnterpriseLicense = metadata.optBoolean("isEnterprise", false);
|
||||
context.isEnterpriseLicense =
|
||||
metadata.optBoolean("isEnterprise", context.isEnterpriseLicense);
|
||||
users = metadata.optInt("users", context.isEnterpriseLicense ? 1 : 0);
|
||||
} else {
|
||||
// Default value
|
||||
applicationProperties.getPremium().setMaxUsers(1);
|
||||
log.info("Using default of 1 user for license");
|
||||
// Default based on license type
|
||||
users = context.isEnterpriseLicense ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
// SERVER license (isEnterprise=false, users=0) = unlimited
|
||||
// ENTERPRISE license (isEnterprise=true, users>0) = limited seats
|
||||
if (!context.isEnterpriseLicense && users == 0) {
|
||||
log.info("SERVER license detected with unlimited users");
|
||||
} else if (context.isEnterpriseLicense && users > 0) {
|
||||
log.info("ENTERPRISE license allows for {} users", users);
|
||||
}
|
||||
|
||||
applicationProperties.getPremium().setMaxUsers(users);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -584,17 +600,7 @@ public class KeygenLicenseVerifier {
|
||||
context.maxMachines);
|
||||
}
|
||||
|
||||
// Extract user count, default to 1 if not specified
|
||||
int users =
|
||||
jsonResponse
|
||||
.path("data")
|
||||
.path("attributes")
|
||||
.path("metadata")
|
||||
.path("users")
|
||||
.asInt(1);
|
||||
applicationProperties.getPremium().setMaxUsers(users);
|
||||
|
||||
// Extract isEnterprise flag
|
||||
// Extract isEnterprise flag first
|
||||
context.isEnterpriseLicense =
|
||||
jsonResponse
|
||||
.path("data")
|
||||
@@ -603,6 +609,24 @@ public class KeygenLicenseVerifier {
|
||||
.path("isEnterprise")
|
||||
.asBoolean(false);
|
||||
|
||||
// Extract user count - default based on license type
|
||||
int users =
|
||||
jsonResponse
|
||||
.path("data")
|
||||
.path("attributes")
|
||||
.path("metadata")
|
||||
.path("users")
|
||||
.asInt(context.isEnterpriseLicense ? 1 : 0);
|
||||
|
||||
// SERVER license (isEnterprise=false, users=0) = unlimited
|
||||
// ENTERPRISE license (isEnterprise=true, users>0) = limited seats
|
||||
if (!context.isEnterpriseLicense && users == 0) {
|
||||
log.info("SERVER license detected with unlimited users");
|
||||
} else if (context.isEnterpriseLicense && users > 0) {
|
||||
log.info("ENTERPRISE license allows for {} users", users);
|
||||
}
|
||||
|
||||
applicationProperties.getPremium().setMaxUsers(users);
|
||||
log.debug(applicationProperties.toString());
|
||||
|
||||
} else {
|
||||
|
||||
+2
-2
@@ -68,8 +68,8 @@ public class LicenseKeyChecker {
|
||||
premiumEnabledResult = licenseService.verifyLicense(licenseKey);
|
||||
if (License.ENTERPRISE == premiumEnabledResult) {
|
||||
log.info("License key is Enterprise.");
|
||||
} else if (License.PRO == premiumEnabledResult) {
|
||||
log.info("License key is Pro.");
|
||||
} else if (License.SERVER == premiumEnabledResult) {
|
||||
log.info("License key is Server.");
|
||||
} else {
|
||||
log.info("License key is invalid, defaulting to non pro license.");
|
||||
}
|
||||
|
||||
+13
@@ -87,6 +87,19 @@ public class UserController {
|
||||
.body(Map.of("error", "Password is required"));
|
||||
}
|
||||
|
||||
if (licenseSettingsService.wouldExceedLimit(1)) {
|
||||
long availableSlots = licenseSettingsService.getAvailableUserSlots();
|
||||
int maxAllowed = licenseSettingsService.calculateMaxAllowedUsers();
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Maximum number of users reached. Allowed: "
|
||||
+ maxAllowed
|
||||
+ ", Available slots: "
|
||||
+ availableSlots));
|
||||
}
|
||||
|
||||
Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
|
||||
User user =
|
||||
userService.saveUser(
|
||||
|
||||
+24
@@ -39,4 +39,28 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
long countByTeam(Team team);
|
||||
|
||||
List<User> findAllByTeam(Team team);
|
||||
|
||||
// OAuth grandfathering queries
|
||||
long countBySsoProviderIsNotNull();
|
||||
|
||||
long countByOauthGrandfatheredTrue();
|
||||
|
||||
List<User> findAllBySsoProviderIsNotNull();
|
||||
|
||||
/**
|
||||
* Finds all SSO users - those with sso_provider set OR authenticationType is sso/oauth2/saml2.
|
||||
* This catches V1 users who were created via SSO but never signed in (sso_provider is null).
|
||||
*/
|
||||
@Query(
|
||||
"SELECT u FROM User u WHERE u.ssoProvider IS NOT NULL "
|
||||
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')")
|
||||
List<User> findAllSsoUsers();
|
||||
|
||||
/**
|
||||
* Counts all SSO users - those with sso_provider set OR authenticationType is sso/oauth2/saml2.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT COUNT(u) FROM User u WHERE u.ssoProvider IS NOT NULL "
|
||||
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')")
|
||||
long countSsoUsers();
|
||||
}
|
||||
|
||||
+65
-38
@@ -28,6 +28,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
|
||||
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
|
||||
import stirling.software.common.util.RequestUriUtils;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
@@ -110,7 +111,6 @@ 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 (authentication == null || !authentication.isAuthenticated()) {
|
||||
String method = request.getMethod();
|
||||
String contextPath = request.getContextPath();
|
||||
|
||||
// Allow public auth endpoints to pass through without authentication
|
||||
@@ -119,18 +119,18 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
return;
|
||||
}
|
||||
|
||||
if ("GET".equalsIgnoreCase(method) && !requestURI.startsWith(contextPath + "/login")) {
|
||||
response.sendRedirect(contextPath + "/login"); // redirect to the login page
|
||||
} else {
|
||||
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||
response.getWriter()
|
||||
.write(
|
||||
"""
|
||||
Authentication required. Please provide a X-API-KEY in request header.
|
||||
This is found in Settings -> Account Settings -> API Key
|
||||
Alternatively you can disable authentication if this is unexpected.
|
||||
""");
|
||||
}
|
||||
// For API requests, return 401 with JSON response (no redirects)
|
||||
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||
response.setContentType("application/json");
|
||||
response.getWriter()
|
||||
.write(
|
||||
"""
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": "Authentication required. Please provide valid credentials or X-API-KEY header.",
|
||||
"status": 401
|
||||
}
|
||||
""");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -179,8 +179,18 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
// Block user registration if not allowed by configuration
|
||||
if (blockRegistration && !isUserExists) {
|
||||
log.warn("Blocked registration for OAuth2/SAML user: {}", username);
|
||||
response.sendRedirect(
|
||||
request.getContextPath() + "/logout?oAuth2AdminBlockedUser=true");
|
||||
SecurityContextHolder.clearContext();
|
||||
response.setStatus(HttpStatus.FORBIDDEN.value());
|
||||
response.setContentType("application/json");
|
||||
response.getWriter()
|
||||
.write(
|
||||
"""
|
||||
{
|
||||
"error": "Forbidden",
|
||||
"message": "User registration is blocked by administrator",
|
||||
"status": 403
|
||||
}
|
||||
""");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -194,13 +204,35 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect to logout if credentials are invalid
|
||||
// Return 401 if credentials are invalid (no redirects)
|
||||
if (!isUserExists && notSsoLogin) {
|
||||
response.sendRedirect(request.getContextPath() + "/logout?badCredentials=true");
|
||||
SecurityContextHolder.clearContext();
|
||||
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||
response.setContentType("application/json");
|
||||
response.getWriter()
|
||||
.write(
|
||||
"""
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": "Invalid credentials",
|
||||
"status": 401
|
||||
}
|
||||
""");
|
||||
return;
|
||||
}
|
||||
if (isUserDisabled) {
|
||||
response.sendRedirect(request.getContextPath() + "/logout?userIsDisabled=true");
|
||||
SecurityContextHolder.clearContext();
|
||||
response.setStatus(HttpStatus.FORBIDDEN.value());
|
||||
response.setContentType("application/json");
|
||||
response.getWriter()
|
||||
.write(
|
||||
"""
|
||||
{
|
||||
"error": "Forbidden",
|
||||
"message": "User account is disabled",
|
||||
"status": 403
|
||||
}
|
||||
""");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -250,33 +282,28 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String uri = request.getRequestURI();
|
||||
String contextPath = request.getContextPath();
|
||||
String[] permitAllPatterns = {
|
||||
contextPath + "/login",
|
||||
contextPath + "/register",
|
||||
contextPath + "/invite",
|
||||
contextPath + "/error",
|
||||
contextPath + "/images/",
|
||||
contextPath + "/public/",
|
||||
contextPath + "/css/",
|
||||
contextPath + "/fonts/",
|
||||
contextPath + "/js/",
|
||||
contextPath + "/pdfjs/",
|
||||
contextPath + "/pdfjs-legacy/",
|
||||
|
||||
// Allow unauthenticated access to static resources and SPA routes (GET/HEAD only)
|
||||
if ("GET".equalsIgnoreCase(request.getMethod())
|
||||
|| "HEAD".equalsIgnoreCase(request.getMethod())) {
|
||||
if (RequestUriUtils.isStaticResource(contextPath, uri)
|
||||
|| RequestUriUtils.isFrontendRoute(contextPath, uri)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// For API routes, only skip filter for these public endpoints
|
||||
String[] publicApiPatterns = {
|
||||
contextPath + "/api/v1/info/status",
|
||||
contextPath + "/api/v1/auth/login",
|
||||
contextPath + "/api/v1/auth/refresh",
|
||||
contextPath + "/api/v1/auth/me",
|
||||
contextPath + "/api/v1/invite/validate",
|
||||
contextPath + "/api/v1/invite/accept",
|
||||
contextPath + "/site.webmanifest"
|
||||
contextPath + "/api/v1/invite/accept"
|
||||
};
|
||||
|
||||
for (String pattern : permitAllPatterns) {
|
||||
if (uri.startsWith(pattern)
|
||||
|| uri.endsWith(".svg")
|
||||
|| uri.endsWith(".mjs")
|
||||
|| uri.endsWith(".png")
|
||||
|| uri.endsWith(".ico")) {
|
||||
for (String pattern : publicApiPatterns) {
|
||||
if (uri.startsWith(pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,9 @@ public class User implements UserDetails, Serializable {
|
||||
@Column(name = "sso_provider")
|
||||
private String ssoProvider;
|
||||
|
||||
@Column(name = "oauth_grandfathered")
|
||||
private Boolean oauthGrandfathered = false;
|
||||
|
||||
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
|
||||
private Set<Authority> authorities = new HashSet<>();
|
||||
|
||||
@@ -135,4 +138,12 @@ public class User implements UserDetails, Serializable {
|
||||
public boolean hasPassword() {
|
||||
return this.password != null && !this.password.isEmpty();
|
||||
}
|
||||
|
||||
public boolean isOauthGrandfathered() {
|
||||
return oauthGrandfathered != null && oauthGrandfathered;
|
||||
}
|
||||
|
||||
public void setOauthGrandfathered(boolean oauthGrandfathered) {
|
||||
this.oauthGrandfathered = oauthGrandfathered;
|
||||
}
|
||||
}
|
||||
|
||||
+26
-1
@@ -50,6 +50,8 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
private final ApplicationProperties.Security.OAUTH2 oauth2Properties;
|
||||
private final UserService userService;
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService;
|
||||
|
||||
@Override
|
||||
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
|
||||
@@ -66,6 +68,25 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
username = detailsUser.getUsername();
|
||||
}
|
||||
|
||||
boolean userExists = userService.usernameExistsIgnoreCase(username);
|
||||
|
||||
// Check if user is eligible for OAuth (grandfathered or system has paid license)
|
||||
if (userExists) {
|
||||
stirling.software.proprietary.security.model.User user =
|
||||
userService.findByUsernameIgnoreCase(username).orElse(null);
|
||||
|
||||
if (user != null && !licenseSettingsService.isOAuthEligible(user)) {
|
||||
// User is not grandfathered and no paid license - block OAuth login
|
||||
response.sendRedirect(
|
||||
request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
|
||||
return;
|
||||
}
|
||||
} else if (!licenseSettingsService.isOAuthEligible(null)) {
|
||||
// No existing user and no paid license -> block auto creation
|
||||
response.sendRedirect(request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the saved request
|
||||
HttpSession session = request.getSession(false);
|
||||
String contextPath = request.getContextPath();
|
||||
@@ -91,7 +112,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
.sendRedirect(request, response, "/logout?userIsDisabled=true");
|
||||
return;
|
||||
}
|
||||
if (userService.usernameExistsIgnoreCase(username)
|
||||
if (userExists
|
||||
&& userService.hasPassword(username)
|
||||
&& (!userService.isAuthenticationTypeByUsername(username, SSO)
|
||||
|| !userService.isAuthenticationTypeByUsername(username, OAUTH2))
|
||||
@@ -106,6 +127,10 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
response.sendRedirect(contextPath + "/logout?oAuth2AdminBlockedUser=true");
|
||||
return;
|
||||
}
|
||||
if (!userExists && licenseSettingsService.wouldExceedLimit(1)) {
|
||||
response.sendRedirect(contextPath + "/logout?maxUsersReached=true");
|
||||
return;
|
||||
}
|
||||
if (principal instanceof OAuth2User oAuth2User) {
|
||||
// Extract SSO provider information from OAuth2User
|
||||
String ssoProviderId = oAuth2User.getAttribute("sub"); // OIDC ID
|
||||
|
||||
+26
-1
@@ -49,6 +49,8 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
private ApplicationProperties.Security.SAML2 saml2Properties;
|
||||
private UserService userService;
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService;
|
||||
|
||||
@Override
|
||||
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
|
||||
@@ -63,6 +65,26 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
String username = saml2Principal.name();
|
||||
log.debug("Authenticated principal found for user: {}", username);
|
||||
|
||||
boolean userExists = userService.usernameExistsIgnoreCase(username);
|
||||
|
||||
// Check if user is eligible for SAML (grandfathered or system has paid license)
|
||||
if (userExists) {
|
||||
stirling.software.proprietary.security.model.User user =
|
||||
userService.findByUsernameIgnoreCase(username).orElse(null);
|
||||
|
||||
if (user != null && !licenseSettingsService.isOAuthEligible(user)) {
|
||||
// User is not grandfathered and no paid license - block SAML login
|
||||
response.sendRedirect(
|
||||
request.getContextPath() + "/logout?saml2RequiresLicense=true");
|
||||
return;
|
||||
}
|
||||
} else if (!licenseSettingsService.isOAuthEligible(null)) {
|
||||
// No existing user and no paid license -> block auto creation
|
||||
response.sendRedirect(
|
||||
request.getContextPath() + "/logout?saml2RequiresLicense=true");
|
||||
return;
|
||||
}
|
||||
|
||||
HttpSession session = request.getSession(false);
|
||||
String contextPath = request.getContextPath();
|
||||
SavedRequest savedRequest =
|
||||
@@ -96,7 +118,6 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
"Your account has been locked due to too many failed login attempts.");
|
||||
}
|
||||
|
||||
boolean userExists = userService.usernameExistsIgnoreCase(username);
|
||||
boolean hasPassword = userExists && userService.hasPassword(username);
|
||||
boolean isSSOUser =
|
||||
userExists && userService.isAuthenticationTypeByUsername(username, SSO);
|
||||
@@ -129,6 +150,10 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser");
|
||||
return;
|
||||
}
|
||||
if (!userExists && licenseSettingsService.wouldExceedLimit(1)) {
|
||||
response.sendRedirect(contextPath + "/logout?maxUsersReached=true");
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract SSO provider information from SAML2 assertion
|
||||
String ssoProviderId = saml2Principal.nameId();
|
||||
|
||||
+44
@@ -734,4 +734,48 @@ public class UserService implements UserServiceInterface {
|
||||
public void saveAll(List<User> users) {
|
||||
userRepository.saveAll(users);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the number of OAuth/SAML users. Includes users with sso_provider set OR
|
||||
* authenticationType is sso/oauth2/saml2 (catches V1 users who never signed in).
|
||||
*
|
||||
* @return Count of OAuth users
|
||||
*/
|
||||
public long countOAuthUsers() {
|
||||
return userRepository.countSsoUsers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the number of OAuth users who are grandfathered.
|
||||
*
|
||||
* @return Count of grandfathered OAuth users
|
||||
*/
|
||||
public long countGrandfatheredOAuthUsers() {
|
||||
return userRepository.countByOauthGrandfatheredTrue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Grandfathers all existing OAuth/SAML users. This marks all users with an SSO provider as
|
||||
* grandfathered, allowing them to keep OAuth access even without a paid license.
|
||||
*
|
||||
* @return Number of users updated
|
||||
*/
|
||||
@Transactional
|
||||
public int grandfatherAllOAuthUsers() {
|
||||
List<User> ssoUsers = userRepository.findAllSsoUsers();
|
||||
int updated = 0;
|
||||
|
||||
for (User user : ssoUsers) {
|
||||
if (!user.isOauthGrandfathered()) {
|
||||
user.setOauthGrandfathered(true);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
if (updated > 0) {
|
||||
userRepository.saveAll(ssoUsers);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
|
||||
private boolean hasProOrEnterpriseAccess() {
|
||||
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
|
||||
return license == License.PRO || license == License.ENTERPRISE;
|
||||
return license == License.SERVER || license == License.ENTERPRISE;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
|
||||
+113
-16
@@ -10,6 +10,7 @@ import java.util.UUID;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -18,6 +19,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.model.UserLicenseSettings;
|
||||
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
|
||||
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
|
||||
import stirling.software.proprietary.security.repository.UserLicenseSettingsRepository;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@@ -45,6 +48,7 @@ public class UserLicenseSettingsService {
|
||||
private final UserLicenseSettingsRepository settingsRepository;
|
||||
private final UserService userService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ObjectProvider<LicenseKeyChecker> licenseKeyChecker;
|
||||
|
||||
/**
|
||||
* Gets the current user license settings, creating them if they don't exist.
|
||||
@@ -151,7 +155,7 @@ public class UserLicenseSettingsService {
|
||||
UserLicenseSettings settings = getOrCreateSettings();
|
||||
|
||||
int licenseMaxUsers = 0;
|
||||
if (applicationProperties.getPremium().isEnabled()) {
|
||||
if (hasPaidLicense()) {
|
||||
licenseMaxUsers = applicationProperties.getPremium().getMaxUsers();
|
||||
}
|
||||
|
||||
@@ -162,6 +166,40 @@ public class UserLicenseSettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grandfathers existing OAuth users on first run. This is a one-time migration that marks all
|
||||
* existing OAuth/SAML users as grandfathered, allowing them to keep OAuth access even without a
|
||||
* paid license.
|
||||
*
|
||||
* <p>New users created after this migration will NOT be grandfathered and will require a paid
|
||||
* license to use OAuth.
|
||||
*/
|
||||
@Transactional
|
||||
public void grandfatherExistingOAuthUsers() {
|
||||
UserLicenseSettings settings = getOrCreateSettings();
|
||||
|
||||
// Check if we've already run this migration
|
||||
if (settings.getId() != null && settings.isGrandfatheringLocked()) {
|
||||
// Migration should happen at the same time as grandfathering is locked
|
||||
long oauthUsersCount = userService.countOAuthUsers();
|
||||
long grandfatheredCount = userService.countGrandfatheredOAuthUsers();
|
||||
|
||||
if (oauthUsersCount > 0 && grandfatheredCount == 0) {
|
||||
// We have OAuth users but none are grandfathered - this is first run after upgrade
|
||||
int updated = userService.grandfatherAllOAuthUsers();
|
||||
log.warn(
|
||||
"OAuth GRANDFATHERING: Marked {} existing OAuth/SAML users as grandfathered. "
|
||||
+ "They will retain OAuth access even without a paid license. "
|
||||
+ "New users will require a paid license for OAuth.",
|
||||
updated);
|
||||
} else if (grandfatheredCount > 0) {
|
||||
log.debug(
|
||||
"OAuth grandfathering already completed: {} users grandfathered",
|
||||
grandfatheredCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and enforces the integrity of license settings. This ensures that even if someone
|
||||
* manually modifies the database, the grandfathering rules are still enforced.
|
||||
@@ -241,12 +279,15 @@ public class UserLicenseSettingsService {
|
||||
* <p>Logic:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Grandfathered limit = max(5, existing user count at initialization)
|
||||
* <li>If premium enabled: total limit = grandfathered limit + license maxUsers
|
||||
* <li>If premium disabled: total limit = grandfathered limit
|
||||
* <li>Grandfathered limit = max(5, existing user count at V1→V2 migration)
|
||||
* <li>No license: Uses grandfathered limit only
|
||||
* <li>SERVER license (maxUsers=0): Unlimited users (Integer.MAX_VALUE)
|
||||
* <li>ENTERPRISE license (maxUsers>0): License seats only (NO grandfathering added)
|
||||
* </ul>
|
||||
*
|
||||
* @return Maximum number of users allowed
|
||||
* <p>IMPORTANT: Paid licenses REPLACE the limit, they don't add to grandfathering.
|
||||
*
|
||||
* @return Maximum number of users allowed (Integer.MAX_VALUE for unlimited)
|
||||
*/
|
||||
public int calculateMaxAllowedUsers() {
|
||||
validateSettingsIntegrity();
|
||||
@@ -259,20 +300,52 @@ public class UserLicenseSettingsService {
|
||||
grandfatheredLimit = DEFAULT_USER_LIMIT;
|
||||
}
|
||||
|
||||
int totalLimit = grandfatheredLimit;
|
||||
|
||||
if (applicationProperties.getPremium().isEnabled()) {
|
||||
totalLimit = grandfatheredLimit + settings.getLicenseMaxUsers();
|
||||
// No license: use grandfathered limit
|
||||
if (!hasPaidLicense()) {
|
||||
log.debug("No license: using grandfathered limit of {}", grandfatheredLimit);
|
||||
return grandfatheredLimit;
|
||||
}
|
||||
|
||||
log.debug(
|
||||
"Calculated max allowed users: {} (grandfathered: {}, license: {}, premium enabled: {})",
|
||||
totalLimit,
|
||||
grandfatheredLimit,
|
||||
settings.getLicenseMaxUsers(),
|
||||
applicationProperties.getPremium().isEnabled());
|
||||
int licenseMaxUsers = settings.getLicenseMaxUsers();
|
||||
|
||||
return totalLimit;
|
||||
// SERVER license (maxUsers=0): unlimited users
|
||||
if (licenseMaxUsers == 0) {
|
||||
log.debug("SERVER license: unlimited users allowed");
|
||||
return Integer.MAX_VALUE;
|
||||
}
|
||||
|
||||
// ENTERPRISE license (maxUsers>0): license seats only (replaces grandfathering)
|
||||
log.debug(
|
||||
"ENTERPRISE license: {} seats (grandfathered {} not added)",
|
||||
licenseMaxUsers,
|
||||
grandfatheredLimit);
|
||||
return licenseMaxUsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user is eligible to use OAuth/SAML authentication.
|
||||
*
|
||||
* <p>A user is eligible if:
|
||||
*
|
||||
* <ul>
|
||||
* <li>They are grandfathered for OAuth (existing user before policy change), OR
|
||||
* <li>The system has an ENTERPRISE license (SSO is enterprise-only)
|
||||
* </ul>
|
||||
*
|
||||
* @param user The user to check
|
||||
* @return true if the user can use OAuth/SAML
|
||||
*/
|
||||
public boolean isOAuthEligible(stirling.software.proprietary.security.model.User user) {
|
||||
// Grandfathered users always have OAuth access
|
||||
if (user != null && user.isOauthGrandfathered()) {
|
||||
log.debug("User {} is grandfathered for OAuth", user.getUsername());
|
||||
return true;
|
||||
}
|
||||
|
||||
// Users can use OAuth/SAML only if system has ENTERPRISE license
|
||||
boolean hasEnterpriseLicense = hasEnterpriseLicense();
|
||||
log.debug("OAuth eligibility check: hasEnterpriseLicense={}", hasEnterpriseLicense);
|
||||
return hasEnterpriseLicense;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -408,4 +481,28 @@ public class UserLicenseSettingsService {
|
||||
throw new IllegalStateException("Invalid key for grandfathered user signature", e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasPaidLicense() {
|
||||
LicenseKeyChecker checker = licenseKeyChecker.getIfAvailable();
|
||||
if (checker == null) {
|
||||
return false;
|
||||
}
|
||||
License license = checker.getPremiumLicenseEnabledResult();
|
||||
return license == License.SERVER || license == License.ENTERPRISE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the system has an ENTERPRISE license. Used for enterprise-only features like SSO
|
||||
* (OAuth/SAML).
|
||||
*
|
||||
* @return true if ENTERPRISE license is active
|
||||
*/
|
||||
private boolean hasEnterpriseLicense() {
|
||||
LicenseKeyChecker checker = licenseKeyChecker.getIfAvailable();
|
||||
if (checker == null) {
|
||||
return false;
|
||||
}
|
||||
License license = checker.getPremiumLicenseEnabledResult();
|
||||
return license == License.ENTERPRISE;
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -103,6 +103,9 @@ class CustomLogoutSuccessHandlerTest {
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
|
||||
when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
|
||||
when(request.getParameter("maxUsersReached")).thenReturn(null);
|
||||
when(request.getParameter(error)).thenReturn("true");
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
@@ -208,6 +211,9 @@ class CustomLogoutSuccessHandlerTest {
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
|
||||
when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
|
||||
when(request.getParameter("maxUsersReached")).thenReturn(null);
|
||||
when(request.getParameter("userIsDisabled")).thenReturn(null);
|
||||
when(request.getParameter("error")).thenReturn("!@$!@£" + error + "£$%^*$");
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
@@ -237,6 +243,9 @@ class CustomLogoutSuccessHandlerTest {
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
|
||||
when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
|
||||
when(request.getParameter("maxUsersReached")).thenReturn(null);
|
||||
when(request.getParameter("userIsDisabled")).thenReturn(null);
|
||||
when(request.getParameter("error")).thenReturn(null);
|
||||
when(request.getParameter(error)).thenReturn("true");
|
||||
|
||||
+2
-2
@@ -44,13 +44,13 @@ class LicenseKeyCheckerTest {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
props.getPremium().setEnabled(true);
|
||||
props.getPremium().setKey("abc");
|
||||
when(verifier.verifyLicense("abc")).thenReturn(License.PRO);
|
||||
when(verifier.verifyLicense("abc")).thenReturn(License.SERVER);
|
||||
|
||||
LicenseKeyChecker checker =
|
||||
new LicenseKeyChecker(verifier, props, userLicenseSettingsService);
|
||||
checker.init();
|
||||
|
||||
assertEquals(License.PRO, checker.getPremiumLicenseEnabledResult());
|
||||
assertEquals(License.SERVER, checker.getPremiumLicenseEnabledResult());
|
||||
verify(verifier).verifyLicense("abc");
|
||||
}
|
||||
|
||||
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.model.UserLicenseSettings;
|
||||
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
|
||||
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
|
||||
import stirling.software.proprietary.security.repository.UserLicenseSettingsRepository;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class UserLicenseSettingsServiceTest {
|
||||
|
||||
@Mock private UserLicenseSettingsRepository settingsRepository;
|
||||
@Mock private UserService userService;
|
||||
@Mock private ApplicationProperties applicationProperties;
|
||||
@Mock private ApplicationProperties.Premium premium;
|
||||
@Mock private LicenseKeyChecker licenseKeyChecker;
|
||||
@Mock private ObjectProvider<LicenseKeyChecker> licenseKeyCheckerProvider;
|
||||
|
||||
private UserLicenseSettingsService service;
|
||||
private UserLicenseSettings mockSettings;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockSettings = new UserLicenseSettings();
|
||||
mockSettings.setId(1L);
|
||||
mockSettings.setGrandfatheredUserCount(80);
|
||||
mockSettings.setGrandfatheringLocked(true);
|
||||
mockSettings.setIntegritySalt("test-salt");
|
||||
mockSettings.setGrandfatheredUserSignature("80:test-signature");
|
||||
|
||||
when(applicationProperties.getPremium()).thenReturn(premium);
|
||||
when(settingsRepository.findSettings()).thenReturn(Optional.of(mockSettings));
|
||||
when(userService.getTotalUsersCount()).thenReturn(80L);
|
||||
when(settingsRepository.save(any(UserLicenseSettings.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
|
||||
when(licenseKeyCheckerProvider.getIfAvailable()).thenReturn(licenseKeyChecker);
|
||||
|
||||
// Create service with overridden validateSettingsIntegrity to bypass signature validation
|
||||
service =
|
||||
new UserLicenseSettingsService(
|
||||
settingsRepository,
|
||||
userService,
|
||||
applicationProperties,
|
||||
licenseKeyCheckerProvider) {
|
||||
@Override
|
||||
public void validateSettingsIntegrity() {
|
||||
// Override to do nothing in tests - avoid HMAC signature validation
|
||||
// complexity
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Test
|
||||
void noLicense_returnsGrandfatheredLimit() {
|
||||
// No license active
|
||||
when(premium.isEnabled()).thenReturn(false);
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
|
||||
|
||||
int result = service.calculateMaxAllowedUsers();
|
||||
|
||||
assertEquals(80, result, "Should return grandfathered user count when no license");
|
||||
}
|
||||
|
||||
@Test
|
||||
void serverLicense_returnsUnlimited() {
|
||||
// SERVER license with users=0
|
||||
when(premium.isEnabled()).thenReturn(true);
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
|
||||
mockSettings.setLicenseMaxUsers(0);
|
||||
|
||||
int result = service.calculateMaxAllowedUsers();
|
||||
|
||||
assertEquals(Integer.MAX_VALUE, result, "SERVER license should return unlimited users");
|
||||
}
|
||||
|
||||
@Test
|
||||
void enterpriseLicense_returnsLicenseSeatsOnly() {
|
||||
// ENTERPRISE license with 5 seats
|
||||
when(premium.isEnabled()).thenReturn(true);
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
|
||||
mockSettings.setLicenseMaxUsers(5);
|
||||
|
||||
int result = service.calculateMaxAllowedUsers();
|
||||
|
||||
assertEquals(
|
||||
5,
|
||||
result,
|
||||
"ENTERPRISE license should return license seats only (NOT grandfathered + seats)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void enterpriseLicense_ignoresGrandfathering() {
|
||||
// ENTERPRISE with 20 seats, grandfathered was 80
|
||||
when(premium.isEnabled()).thenReturn(true);
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
|
||||
mockSettings.setLicenseMaxUsers(20);
|
||||
mockSettings.setGrandfatheredUserCount(80); // This should be ignored
|
||||
|
||||
int result = service.calculateMaxAllowedUsers();
|
||||
|
||||
assertEquals(
|
||||
20,
|
||||
result,
|
||||
"ENTERPRISE license should ignore grandfathering and use license seats only");
|
||||
}
|
||||
|
||||
@Test
|
||||
void freshInstall_noLicense_returnsFive() {
|
||||
// Fresh install with default 5 users grandfathered
|
||||
mockSettings.setGrandfatheredUserCount(5);
|
||||
when(premium.isEnabled()).thenReturn(false);
|
||||
|
||||
int result = service.calculateMaxAllowedUsers();
|
||||
|
||||
assertEquals(5, result, "Fresh install with no license should return 5 users");
|
||||
}
|
||||
|
||||
@Test
|
||||
void freshInstall_serverLicense_returnsUnlimited() {
|
||||
// Fresh install with SERVER license
|
||||
mockSettings.setGrandfatheredUserCount(5);
|
||||
when(premium.isEnabled()).thenReturn(true);
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
|
||||
mockSettings.setLicenseMaxUsers(0);
|
||||
|
||||
int result = service.calculateMaxAllowedUsers();
|
||||
|
||||
assertEquals(
|
||||
Integer.MAX_VALUE,
|
||||
result,
|
||||
"Fresh install with SERVER license should return unlimited");
|
||||
}
|
||||
|
||||
@Test
|
||||
void freshInstall_enterpriseLicense_returnsLicenseSeats() {
|
||||
// Fresh install with ENTERPRISE 10 seats
|
||||
mockSettings.setGrandfatheredUserCount(5);
|
||||
when(premium.isEnabled()).thenReturn(true);
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
|
||||
mockSettings.setLicenseMaxUsers(10);
|
||||
|
||||
int result = service.calculateMaxAllowedUsers();
|
||||
|
||||
assertEquals(
|
||||
10, result, "Fresh install with ENTERPRISE license should return license seats");
|
||||
}
|
||||
|
||||
@Test
|
||||
void v1MigrationWith80Users_noLicense_returns80() {
|
||||
// V1→V2 migration with 80 users, no paid license
|
||||
mockSettings.setGrandfatheredUserCount(80);
|
||||
when(premium.isEnabled()).thenReturn(false);
|
||||
|
||||
int result = service.calculateMaxAllowedUsers();
|
||||
|
||||
assertEquals(80, result, "V1→V2 migration should preserve 80 grandfathered users");
|
||||
}
|
||||
|
||||
@Test
|
||||
void v1MigrationWith80Users_thenEnterpriseWith5Seats_returns5() {
|
||||
// V1→V2 with 80 users, then buy ENTERPRISE 5 seats
|
||||
mockSettings.setGrandfatheredUserCount(80);
|
||||
when(premium.isEnabled()).thenReturn(true);
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
|
||||
mockSettings.setLicenseMaxUsers(5);
|
||||
|
||||
int result = service.calculateMaxAllowedUsers();
|
||||
|
||||
assertEquals(
|
||||
5, result, "ENTERPRISE 5 seats should override grandfathered 80 users (not 85)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroGrandfathered_fallsBackToDefault() {
|
||||
// Edge case: grandfathered is 0 (should not happen)
|
||||
mockSettings.setGrandfatheredUserCount(0);
|
||||
when(premium.isEnabled()).thenReturn(false);
|
||||
|
||||
int result = service.calculateMaxAllowedUsers();
|
||||
|
||||
assertEquals(5, result, "Should fall back to default 5 users if grandfathered is 0");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -57,7 +57,7 @@ repositories {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.0.0'
|
||||
version = '2.0.1'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
|
||||
@@ -10,9 +10,11 @@ COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend .
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false npm run build
|
||||
# Override VITE_API_BASE_URL to use relative paths for production
|
||||
# This ensures frontend works with nginx proxy setup
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false VITE_API_BASE_URL=/ npm run build
|
||||
|
||||
# Stage 2: Build Backend
|
||||
# Stage 2: Build Backend (server-only JAR - no UI)
|
||||
FROM gradle:8.14-jdk21 AS backend-build
|
||||
|
||||
COPY build.gradle .
|
||||
@@ -27,6 +29,7 @@ RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || re
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
# Build server-only JAR (no frontend, includes security features controlled by DOCKER_ENABLE_SECURITY at runtime)
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
@@ -62,8 +65,7 @@ COPY docker/unified/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY docker/unified/entrypoint.sh /entrypoint.sh
|
||||
|
||||
# Environment Variables
|
||||
ENV DISABLE_ADDITIONAL_FEATURES=false \
|
||||
VERSION_TAG=$VERSION_TAG \
|
||||
ENV VERSION_TAG=$VERSION_TAG \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Unified Ultra-Lite Dockerfile - Frontend + Backend in single container with minimal dependencies
|
||||
# Supports MODE parameter: BOTH (default), FRONTEND, BACKEND
|
||||
|
||||
# Stage 1: Build Frontend
|
||||
FROM node:20-alpine AS frontend-build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend .
|
||||
# Override VITE_API_BASE_URL to use relative paths for production
|
||||
# This ensures frontend works with nginx proxy setup
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=true VITE_API_BASE_URL=/ npm run build
|
||||
|
||||
# Stage 2: Build Backend
|
||||
FROM gradle:8.14-jdk21 AS backend-build
|
||||
|
||||
COPY build.gradle .
|
||||
COPY settings.gradle .
|
||||
COPY gradlew .
|
||||
COPY gradle gradle/
|
||||
COPY app/core/build.gradle core/.
|
||||
COPY app/common/build.gradle common/.
|
||||
COPY app/proprietary/build.gradle proprietary/.
|
||||
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=true \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Stage 3: Final unified ultra-lite image
|
||||
FROM alpine:3.22.1
|
||||
|
||||
ARG VERSION_TAG
|
||||
|
||||
# Labels
|
||||
LABEL org.opencontainers.image.title="Stirling-PDF Unified Ultra-Lite"
|
||||
LABEL org.opencontainers.image.description="Unified ultra-lite container for Stirling-PDF - Frontend + Backend with minimal dependencies"
|
||||
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
LABEL org.opencontainers.image.vendor="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
|
||||
LABEL maintainer="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.authors="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.version="${VERSION_TAG}"
|
||||
LABEL org.opencontainers.image.keywords="PDF, manipulation, unified, ultra-lite, API, Spring Boot, React"
|
||||
|
||||
# Copy backend files
|
||||
COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
|
||||
COPY scripts/installFonts.sh /scripts/installFonts.sh
|
||||
COPY --from=backend-build /app/app/core/build/libs/*.jar app.jar
|
||||
|
||||
# Copy frontend files
|
||||
COPY --from=frontend-build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY docker/unified/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY docker/unified/entrypoint.sh /entrypoint.sh
|
||||
|
||||
# Environment Variables
|
||||
ENV DISABLE_ADDITIONAL_FEATURES=false \
|
||||
VERSION_TAG=$VERSION_TAG \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
PUID=1000 \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf \
|
||||
TEMP=/tmp/stirling-pdf \
|
||||
TMP=/tmp/stirling-pdf \
|
||||
MODE=BOTH \
|
||||
BACKEND_INTERNAL_PORT=8081 \
|
||||
ENDPOINTS_GROUPS_TO_REMOVE=CLI
|
||||
|
||||
# Install minimal dependencies
|
||||
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
|
||||
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
|
||||
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
|
||||
apk upgrade --no-cache -a && \
|
||||
apk add --no-cache \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
tini \
|
||||
bash \
|
||||
curl \
|
||||
shadow \
|
||||
su-exec \
|
||||
openjdk21-jre \
|
||||
nginx && \
|
||||
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf /pipeline/watchedFolders /pipeline/finishedFolders && \
|
||||
mkdir -p /usr/share/fonts/opentype/noto /var/lib/nginx/tmp /var/log/nginx && \
|
||||
chmod +x /scripts/*.sh && \
|
||||
chmod +x /entrypoint.sh && \
|
||||
# User permissions
|
||||
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /configs /customFiles /pipeline /tmp/stirling-pdf /var/lib/nginx /var/log/nginx /usr/share/nginx && \
|
||||
chown stirlingpdfuser:stirlingpdfgroup /app.jar
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
|
||||
ENTRYPOINT ["tini", "--", "/entrypoint.sh"]
|
||||
@@ -17,7 +17,7 @@ WORKDIR /app
|
||||
# Copy the entire project to the working directory
|
||||
COPY . .
|
||||
|
||||
# Build the application with DISABLE_ADDITIONAL_FEATURES=false
|
||||
# Build the application (server-only JAR - no UI, includes security features controlled at runtime)
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
@@ -44,8 +44,7 @@ LABEL org.opencontainers.image.version="${VERSION_TAG}"
|
||||
LABEL org.opencontainers.image.keywords="PDF, manipulation, backend, API, Spring Boot"
|
||||
|
||||
# Set Environment Variables
|
||||
ENV DISABLE_ADDITIONAL_FEATURES=false \
|
||||
VERSION_TAG=$VERSION_TAG \
|
||||
ENV VERSION_TAG=$VERSION_TAG \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
|
||||
@@ -17,7 +17,7 @@ WORKDIR /app
|
||||
# Copy the entire project to the working directory
|
||||
COPY . .
|
||||
|
||||
# Build the application with DISABLE_ADDITIONAL_FEATURES=false
|
||||
# Build the application (server-only JAR - no UI, includes security features controlled at runtime)
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
@@ -35,8 +35,7 @@ COPY --from=build /app/build/libs/restart-helper.jar restart-helper.jar
|
||||
ARG VERSION_TAG
|
||||
|
||||
# Set Environment Variables
|
||||
ENV DISABLE_ADDITIONAL_FEATURES=true \
|
||||
VERSION_TAG=$VERSION_TAG \
|
||||
ENV VERSION_TAG=$VERSION_TAG \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
|
||||
@@ -28,8 +28,7 @@ FROM alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8
|
||||
ARG VERSION_TAG
|
||||
|
||||
# Set Environment Variables
|
||||
ENV DISABLE_ADDITIONAL_FEATURES=true \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
ENV HOME=/home/stirlingpdfuser \
|
||||
VERSION_TAG=$VERSION_TAG \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
|
||||
Generated
+33
-106
@@ -53,7 +53,6 @@
|
||||
"globals": "^16.4.0",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"license-report": "^6.8.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
@@ -65,12 +64,13 @@
|
||||
"react-rnd": "^10.5.2",
|
||||
"react-router-dom": "^7.9.1",
|
||||
"signature_pad": "^5.0.4",
|
||||
"smol-toml": "^1.4.2",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"web-vitals": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@iconify-json/material-symbols": "^1.2.37",
|
||||
"@iconify-json/material-symbols": "^1.2.48",
|
||||
"@iconify/utils": "^3.0.2",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
@@ -455,7 +455,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -499,7 +498,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -580,7 +578,6 @@
|
||||
"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"
|
||||
@@ -680,7 +677,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"
|
||||
},
|
||||
@@ -697,7 +693,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"
|
||||
},
|
||||
@@ -715,7 +710,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"
|
||||
},
|
||||
@@ -752,7 +746,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"
|
||||
},
|
||||
@@ -787,7 +780,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"
|
||||
},
|
||||
@@ -824,7 +816,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"
|
||||
},
|
||||
@@ -900,7 +891,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"
|
||||
},
|
||||
@@ -1056,7 +1046,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",
|
||||
@@ -1100,7 +1089,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",
|
||||
@@ -1896,9 +1884,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify-json/material-symbols": {
|
||||
"version": "1.2.44",
|
||||
"resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.44.tgz",
|
||||
"integrity": "sha512-NAJjhswaK9FxBeIzFFsNygws7wHtmAkBWhF4YEwn1NZIMbA+LNITqhUiq6sP5mOdKQqnoritFTlQaZ47a5BgBg==",
|
||||
"version": "1.2.48",
|
||||
"resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.48.tgz",
|
||||
"integrity": "sha512-qDjqYEeNCm31tae3YbsprEklWHIoVPvqlbTuqtisNjlhHRSiXy5x+AtUq9VwjlITRU+TtqsP6jW2h+pnEUURRQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -2131,7 +2119,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.6.tgz",
|
||||
"integrity": "sha512-paTl+0x+O/QtgMtqVJaG8maD8sfiOdgPmLOyG485FmeGZ1L3KMdEkhxZtmdGlDFsLXhmMGQ57ducT90bvhXX5A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.16",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -2182,7 +2169,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.6.tgz",
|
||||
"integrity": "sha512-liHfaWXHAkLjJy+Bkr29UsCwAoDQ/a64WrM67lksx8F0qqyjR5RQH8zVlhuOjdpQnwtlUkE/YiTvbJiPcoI0bw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "^18.x || ^19.x"
|
||||
}
|
||||
@@ -2250,7 +2236,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz",
|
||||
"integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@mui/core-downloads-tracker": "^7.3.5",
|
||||
@@ -3183,7 +3168,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
|
||||
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.16"
|
||||
}
|
||||
@@ -3302,6 +3286,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz",
|
||||
"integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"acorn": "^8.9.0"
|
||||
}
|
||||
@@ -4078,7 +4063,6 @@
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
@@ -4407,7 +4391,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"
|
||||
}
|
||||
@@ -4418,7 +4401,6 @@
|
||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -4488,7 +4470,6 @@
|
||||
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.3",
|
||||
"@typescript-eslint/types": "8.46.3",
|
||||
@@ -5202,6 +5183,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
|
||||
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.24"
|
||||
}
|
||||
@@ -5211,6 +5193,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
|
||||
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
@@ -5221,6 +5204,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
|
||||
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.24",
|
||||
"@vue/runtime-core": "3.5.24",
|
||||
@@ -5233,6 +5217,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
|
||||
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.24",
|
||||
"@vue/shared": "3.5.24"
|
||||
@@ -5259,7 +5244,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"
|
||||
},
|
||||
@@ -5667,6 +5651,7 @@
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@@ -5943,7 +5928,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.19",
|
||||
"caniuse-lite": "^1.0.30001751",
|
||||
@@ -6469,15 +6453,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
|
||||
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.6.12"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -7000,8 +6975,7 @@
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz",
|
||||
"integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
@@ -7396,7 +7370,6 @@
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -7567,7 +7540,6 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -7734,7 +7706,8 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
||||
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "10.4.0",
|
||||
@@ -7799,6 +7772,7 @@
|
||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz",
|
||||
"integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||
}
|
||||
@@ -8889,7 +8863,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
@@ -8911,15 +8884,6 @@
|
||||
"@babel/runtime": "^7.23.2"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-http-backend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz",
|
||||
"integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-fetch": "4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
@@ -9375,6 +9339,7 @@
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.6"
|
||||
}
|
||||
@@ -9695,7 +9660,6 @@
|
||||
"integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.19",
|
||||
"@asamuzakjp/dom-selector": "^6.7.3",
|
||||
@@ -10282,7 +10246,8 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
@@ -10734,48 +10699,6 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
@@ -11470,7 +11393,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -11750,7 +11672,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"
|
||||
@@ -12133,7 +12054,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -12143,7 +12063,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"
|
||||
},
|
||||
@@ -13132,6 +13051,18 @@
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/smol-toml": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.4.2.tgz",
|
||||
"integrity": "sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/cyyynthia"
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.7",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
|
||||
@@ -13643,6 +13574,7 @@
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
@@ -13851,7 +13783,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -14153,7 +14084,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -14235,7 +14165,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"napi-postinstall": "^0.3.0"
|
||||
},
|
||||
@@ -14440,7 +14369,6 @@
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -14592,7 +14520,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -14606,7 +14533,6 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -15218,7 +15144,8 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
|
||||
@@ -33,12 +33,12 @@
|
||||
"@mantine/dates": "^8.3.1",
|
||||
"@mantine/dropzone": "^8.3.1",
|
||||
"@mantine/hooks": "^8.3.1",
|
||||
"@stripe/react-stripe-js": "^4.0.2",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
"@supabase/supabase-js": "^2.47.13",
|
||||
"@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",
|
||||
@@ -49,7 +49,6 @@
|
||||
"globals": "^16.4.0",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"license-report": "^6.8.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
@@ -57,10 +56,11 @@
|
||||
"posthog-js": "^1.268.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-rnd": "^10.5.2",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-rnd": "^10.5.2",
|
||||
"react-router-dom": "^7.9.1",
|
||||
"signature_pad": "^5.0.4",
|
||||
"smol-toml": "^1.4.2",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"web-vitals": "^5.1.0"
|
||||
},
|
||||
@@ -117,7 +117,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@iconify-json/material-symbols": "^1.2.37",
|
||||
"@iconify-json/material-symbols": "^1.2.48",
|
||||
"@iconify/utils": "^3.0.2",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
|
||||
@@ -175,4 +175,9 @@
|
||||
#cc-main .pm {
|
||||
background: var(--cc-bg) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
/* Lower z-index so cookie banner appears behind onboarding modals */
|
||||
#cc-main {
|
||||
z-index: 100 !important;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user