Merge branch 'V2' of github.com:Stirling-Tools/Stirling-PDF into feature/V2/compareTool

This commit is contained in:
EthanHealy01 2025-10-28 02:10:56 +00:00
commit 411429a16a
70 changed files with 1898 additions and 440 deletions

View File

@ -31,10 +31,15 @@ jobs:
project: ${{ steps.changes.outputs.project }}
openapi: ${{ steps.changes.outputs.openapi }}
steps:
- uses: actions/checkout@v4.3.0
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Check for file changes
uses: dorny/paths-filter@v3.0.2
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
with:
filters: .github/config/.files.yaml
@ -51,19 +56,19 @@ jobs:
spring-security: [true, false]
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK ${{ matrix.jdk-version }}
uses: actions/setup-java@v4.7.1
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4.4.2
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
@ -89,7 +94,7 @@ jobs:
done
- name: Upload Test Reports
if: always()
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
path: |
@ -106,26 +111,26 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@v2.13.0
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 17
uses: actions/setup-java@v4.7.1
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "17"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@v4.4.2
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Generate OpenAPI documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
env:
DISABLE_ADDITIONAL_FEATURES: true
- name: Upload OpenAPI Documentation
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: openapi-docs
path: ./SwaggerDoc.json
@ -134,15 +139,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@v2.12.2
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up Node.js
uses: actions/setup-node@v4.1.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: '20'
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
@ -154,7 +159,7 @@ jobs:
- name: Run frontend tests
run: cd frontend && npm run test -- --run
- name: Upload frontend build artifacts
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: frontend-build
path: frontend/dist/
@ -166,13 +171,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 17
uses: actions/setup-java@v4.7.1
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "17"
distribution: "temurin"
@ -180,7 +185,7 @@ jobs:
run: ./gradlew clean checkLicense
- name: FAILED - check the licenses for compatibility
if: failure()
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: dependencies-without-allowed-license.json
path: build/reports/dependency-license/dependencies-without-allowed-license.json
@ -207,15 +212,15 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up Java 17
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "17"
distribution: "temurin"
@ -225,11 +230,11 @@ jobs:
- name: Install Docker Compose
run: |
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.37.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.39.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: "3.12"
cache: 'pip' # caching pip dependencies
@ -256,21 +261,21 @@ jobs:
docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "Dockerfile.fat"]
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 17
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "17"
distribution: "temurin"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14

View File

@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![95%](https://geps.dev/progress/95) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![35%](https://geps.dev/progress/35) |
| Basque (Euskara) (eu_ES) | ![20%](https://geps.dev/progress/20) |
| Bulgarian (Български) (bg_BG) | ![39%](https://geps.dev/progress/39) |
| Catalan (Català) (ca_CA) | ![37%](https://geps.dev/progress/37) |
| Croatian (Hrvatski) (hr_HR) | ![34%](https://geps.dev/progress/34) |
| Czech (Česky) (cs_CZ) | ![38%](https://geps.dev/progress/38) |
| Danish (Dansk) (da_DK) | ![34%](https://geps.dev/progress/34) |
| Dutch (Nederlands) (nl_NL) | ![34%](https://geps.dev/progress/34) |
| Arabic (العربية) (ar_AR) | ![83%](https://geps.dev/progress/83) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![32%](https://geps.dev/progress/32) |
| Basque (Euskara) (eu_ES) | ![18%](https://geps.dev/progress/18) |
| Bulgarian (Български) (bg_BG) | ![35%](https://geps.dev/progress/35) |
| Catalan (Català) (ca_CA) | ![34%](https://geps.dev/progress/34) |
| Croatian (Hrvatski) (hr_HR) | ![31%](https://geps.dev/progress/31) |
| Czech (Česky) (cs_CZ) | ![34%](https://geps.dev/progress/34) |
| Danish (Dansk) (da_DK) | ![30%](https://geps.dev/progress/30) |
| Dutch (Nederlands) (nl_NL) | ![30%](https://geps.dev/progress/30) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![93%](https://geps.dev/progress/93) |
| German (Deutsch) (de_DE) | ![95%](https://geps.dev/progress/95) |
| Greek (Ελληνικά) (el_GR) | ![38%](https://geps.dev/progress/38) |
| Hindi (हिंदी) (hi_IN) | ![38%](https://geps.dev/progress/38) |
| Hungarian (Magyar) (hu_HU) | ![42%](https://geps.dev/progress/42) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![34%](https://geps.dev/progress/34) |
| Irish (Gaeilge) (ga_IE) | ![38%](https://geps.dev/progress/38) |
| Italian (Italiano) (it_IT) | ![95%](https://geps.dev/progress/95) |
| Japanese (日本語) (ja_JP) | ![70%](https://geps.dev/progress/70) |
| Korean (한국어) (ko_KR) | ![38%](https://geps.dev/progress/38) |
| Norwegian (Norsk) (no_NB) | ![36%](https://geps.dev/progress/36) |
| Persian (فارسی) (fa_IR) | ![38%](https://geps.dev/progress/38) |
| Polish (Polski) (pl_PL) | ![40%](https://geps.dev/progress/40) |
| Portuguese (Português) (pt_PT) | ![38%](https://geps.dev/progress/38) |
| Portuguese Brazilian (Português) (pt_BR) | ![95%](https://geps.dev/progress/95) |
| Romanian (Română) (ro_RO) | ![32%](https://geps.dev/progress/32) |
| Russian (Русский) (ru_RU) | ![94%](https://geps.dev/progress/94) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![42%](https://geps.dev/progress/42) |
| Simplified Chinese (简体中文) (zh_CN) | ![96%](https://geps.dev/progress/96) |
| Slovakian (Slovensky) (sk_SK) | ![28%](https://geps.dev/progress/28) |
| Slovenian (Slovenščina) (sl_SI) | ![40%](https://geps.dev/progress/40) |
| Spanish (Español) (es_ES) | ![95%](https://geps.dev/progress/95) |
| Swedish (Svenska) (sv_SE) | ![37%](https://geps.dev/progress/37) |
| Thai (ไทย) (th_TH) | ![34%](https://geps.dev/progress/34) |
| French (Français) (fr_FR) | ![82%](https://geps.dev/progress/82) |
| German (Deutsch) (de_DE) | ![84%](https://geps.dev/progress/84) |
| Greek (Ελληνικά) (el_GR) | ![34%](https://geps.dev/progress/34) |
| Hindi (हिंदी) (hi_IN) | ![34%](https://geps.dev/progress/34) |
| Hungarian (Magyar) (hu_HU) | ![38%](https://geps.dev/progress/38) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![31%](https://geps.dev/progress/31) |
| Irish (Gaeilge) (ga_IE) | ![34%](https://geps.dev/progress/34) |
| Italian (Italiano) (it_IT) | ![84%](https://geps.dev/progress/84) |
| Japanese (日本語) (ja_JP) | ![62%](https://geps.dev/progress/62) |
| Korean (한국어) (ko_KR) | ![34%](https://geps.dev/progress/34) |
| Norwegian (Norsk) (no_NB) | ![32%](https://geps.dev/progress/32) |
| Persian (فارسی) (fa_IR) | ![34%](https://geps.dev/progress/34) |
| Polish (Polski) (pl_PL) | ![36%](https://geps.dev/progress/36) |
| Portuguese (Português) (pt_PT) | ![34%](https://geps.dev/progress/34) |
| Portuguese Brazilian (Português) (pt_BR) | ![83%](https://geps.dev/progress/83) |
| Romanian (Română) (ro_RO) | ![28%](https://geps.dev/progress/28) |
| Russian (Русский) (ru_RU) | ![83%](https://geps.dev/progress/83) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![37%](https://geps.dev/progress/37) |
| Simplified Chinese (简体中文) (zh_CN) | ![85%](https://geps.dev/progress/85) |
| Slovakian (Slovensky) (sk_SK) | ![26%](https://geps.dev/progress/26) |
| Slovenian (Slovenščina) (sl_SI) | ![36%](https://geps.dev/progress/36) |
| Spanish (Español) (es_ES) | ![84%](https://geps.dev/progress/84) |
| Swedish (Svenska) (sv_SE) | ![33%](https://geps.dev/progress/33) |
| Thai (ไทย) (th_TH) | ![31%](https://geps.dev/progress/31) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) |
| Traditional Chinese (繁體中文) (zh_TW) | ![42%](https://geps.dev/progress/42) |
| Turkish (Türkçe) (tr_TR) | ![41%](https://geps.dev/progress/41) |
| Ukrainian (Українська) (uk_UA) | ![40%](https://geps.dev/progress/40) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![31%](https://geps.dev/progress/31) |
| Traditional Chinese (繁體中文) (zh_TW) | ![38%](https://geps.dev/progress/38) |
| Turkish (Türkçe) (tr_TR) | ![37%](https://geps.dev/progress/37) |
| Ukrainian (Українська) (uk_UA) | ![36%](https://geps.dev/progress/36) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![28%](https://geps.dev/progress/28) |
| Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) |
## Stirling PDF Enterprise

View File

@ -355,6 +355,8 @@ public class ApplicationProperties {
private String tessdataDir;
private Boolean enableAlphaFunctionality;
private Boolean enableAnalytics;
private Boolean enablePosthog;
private Boolean enableScarf;
private Datasource datasource;
private Boolean disableSanitize;
private int maxDPI;
@ -368,6 +370,18 @@ public class ApplicationProperties {
public boolean isAnalyticsEnabled() {
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
}
public boolean isPosthogEnabled() {
// Treat null as enabled when analytics is enabled
return this.isAnalyticsEnabled()
&& (this.getEnablePosthog() == null || this.getEnablePosthog());
}
public boolean isScarfEnabled() {
// Treat null as enabled when analytics is enabled
return this.isAnalyticsEnabled()
&& (this.getEnableScarf() == null || this.getEnableScarf());
}
}
@Data

View File

@ -56,7 +56,7 @@ public class PostHogService {
}
private void captureSystemInfo() {
if (!applicationProperties.getSystem().isAnalyticsEnabled()) {
if (!applicationProperties.getSystem().isPosthogEnabled()) {
return;
}
try {
@ -67,7 +67,7 @@ public class PostHogService {
}
public void captureEvent(String eventName, Map<String, Object> properties) {
if (!applicationProperties.getSystem().isAnalyticsEnabled()) {
if (!applicationProperties.getSystem().isPosthogEnabled()) {
return;
}
@ -325,6 +325,14 @@ public class PostHogService {
properties,
"system_enableAnalytics",
applicationProperties.getSystem().isAnalyticsEnabled());
addIfNotEmpty(
properties,
"system_enablePosthog",
applicationProperties.getSystem().isPosthogEnabled());
addIfNotEmpty(
properties,
"system_enableScarf",
applicationProperties.getSystem().isScarfEnabled());
// Capture UI properties
addIfNotEmpty(properties, "ui_appName", applicationProperties.getUi().getAppName());

View File

@ -6,7 +6,7 @@ import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
@ -29,7 +29,7 @@ public class SettingsController {
@AutoJobPostMapping("/update-enable-analytics")
@Hidden
public ResponseEntity<String> updateApiKey(@RequestBody Boolean enabled) throws IOException {
public ResponseEntity<String> updateApiKey(@RequestParam Boolean enabled) throws IOException {
if (applicationProperties.getSystem().getEnableAnalytics() != null) {
return ResponseEntity.status(HttpStatus.ALREADY_REPORTED)
.body(

View File

@ -65,6 +65,8 @@ public class ConfigController {
applicationProperties.getSystem().getEnableAlphaFunctionality());
configData.put(
"enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
configData.put("enablePosthog", applicationProperties.getSystem().getEnablePosthog());
configData.put("enableScarf", applicationProperties.getSystem().getEnableScarf());
// Premium/Enterprise settings
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());

View File

@ -106,8 +106,8 @@ mail:
from: '' # sender email address
legal:
termsAndConditions: https://www.stirlingpdf.com/terms # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder
privacyPolicy: https://www.stirlingpdf.com/privacy-policy # URL to the privacy policy of your application (e.g. https://example.com/privacy). Empty string to disable or filename to load from local file in static folder
termsAndConditions: https://www.stirling.com/legal/terms-of-service # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder
privacyPolicy: https://www.stirling.com/legal/privacy-policy # URL to the privacy policy of your application (e.g. https://example.com/privacy). Empty string to disable or filename to load from local file in static folder
accessibilityStatement: '' # URL to the accessibility statement of your application (e.g. https://example.com/accessibility). Empty string to disable or filename to load from local file in static folder
cookiePolicy: '' # URL to the cookie policy of your application (e.g. https://example.com/cookie). Empty string to disable or filename to load from local file in static folder
impressum: '' # URL to the impressum of your application (e.g. https://example.com/impressum). Empty string to disable or filename to load from local file in static folder
@ -120,7 +120,9 @@ system:
showUpdateOnlyAdmin: false # only admins can see when a new update is available, depending on showUpdate it must be set to 'true'
customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files
tessdataDir: /usr/share/tessdata # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored.
enableAnalytics: null # set to 'true' to enable analytics, set to 'false' to disable analytics; for enterprise users, this is set to true
enableAnalytics: null # Master toggle for analytics: set to 'true' to enable all analytics, 'false' to disable all analytics, or leave as 'null' to prompt admin on first launch
enablePosthog: null # Enable PostHog analytics (open-source product analytics): set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled
enableScarf: null # Enable Scarf tracking pixel: set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion

141
docker/Dockerfile.unified Normal file
View File

@ -0,0 +1,141 @@
# Unified Dockerfile - Frontend + Backend in single container
# 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 .
RUN 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=false \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Stage 3: Final unified image
FROM alpine:3.22.1
ARG VERSION_TAG
# Labels
LABEL org.opencontainers.image.title="Stirling-PDF Unified"
LABEL org.opencontainers.image.description="Unified container for Stirling-PDF - Frontend + Backend with MODE parameter"
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, API, Spring Boot, React"
# Copy backend files
COPY scripts /scripts
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
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 \
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
UNO_PATH=/usr/lib/libreoffice/program \
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
PATH=$PATH:/opt/venv/bin \
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
TMPDIR=/tmp/stirling-pdf \
TEMP=/tmp/stirling-pdf \
TMP=/tmp/stirling-pdf \
MODE=BOTH \
BACKEND_INTERNAL_PORT=8081 \
VITE_API_BASE_URL=http://localhost:8080
# Install all 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 \
openssl \
openssl-dev \
openjdk21-jre \
nginx \
# Doc conversion
gcompat \
libc6-compat \
libreoffice \
# pdftohtml
poppler-utils \
# OCR MY PDF
unpaper \
tesseract-ocr-data-eng \
tesseract-ocr-data-chi_sim \
tesseract-ocr-data-deu \
tesseract-ocr-data-fra \
tesseract-ocr-data-por \
ocrmypdf \
# CV
py3-opencv \
python3 \
py3-pip \
py3-pillow@testing \
py3-pdf2image@testing && \
python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --upgrade pip setuptools && \
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
mv /usr/share/tessdata /usr/share/tessdata-original && \
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf /pipeline/watchedFolders /pipeline/finishedFolders && \
mkdir -p /var/lib/nginx/tmp /var/log/nginx && \
fc-cache -f -v && \
chmod +x /scripts/* && \
chmod +x /entrypoint.sh && \
# User permissions
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /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"]

View File

@ -0,0 +1,58 @@
# Example Docker Compose for Unified Stirling-PDF Container
# MODE=BACKEND: Backend API only (no frontend)
services:
stirling-pdf-backend-only:
container_name: Stirling-PDF-Backend-Only
build:
context: ../..
dockerfile: docker/Dockerfile.unified
ports:
- "8080:8080"
volumes:
- ./stirling/data:/usr/share/tessdata:rw
- ./stirling/config:/configs:rw
- ./stirling/logs:/logs:rw
- ./stirling/customFiles:/customFiles:rw
- ./stirling/pipeline:/pipeline:rw
environment:
# MODE parameter: BACKEND only
MODE: BACKEND
# Standard Stirling-PDF configuration
DISABLE_ADDITIONAL_FEATURES: "false"
DOCKER_ENABLE_SECURITY: "false"
PUID: 1000
PGID: 1000
UMASK: "022"
# Application settings
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: Stirling-PDF
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
# Optional: Add OCR languages (comma-separated)
# TESSERACT_LANGS: "deu,fra,spa"
# Optional: Java memory settings
# JAVA_CUSTOM_OPTS: "-Xmx4g"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
deploy:
resources:
limits:
memory: 4G
reservations:
memory: 2G
# Access the API at: http://localhost:8080/api
# Swagger UI at: http://localhost:8080/swagger-ui/index.html

View File

@ -0,0 +1,59 @@
# Example Docker Compose for Unified Stirling-PDF Container
# MODE=BOTH (default): Frontend + Backend in single container on port 8080
services:
stirling-pdf-unified:
container_name: Stirling-PDF-Unified-Both
build:
context: ../..
dockerfile: docker/Dockerfile.unified
ports:
- "8080:8080"
volumes:
- ./stirling/data:/usr/share/tessdata:rw
- ./stirling/config:/configs:rw
- ./stirling/logs:/logs:rw
- ./stirling/customFiles:/customFiles:rw
- ./stirling/pipeline:/pipeline:rw
environment:
# MODE parameter: BOTH (default), FRONTEND, or BACKEND
MODE: BOTH
# Backend runs internally on this port when MODE=BOTH
BACKEND_INTERNAL_PORT: 8081
# Standard Stirling-PDF configuration
DISABLE_ADDITIONAL_FEATURES: "false"
DOCKER_ENABLE_SECURITY: "false"
PUID: 1000
PGID: 1000
UMASK: "022"
# Application settings
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: Stirling-PDF
UI_HOMEDESCRIPTION: Your locally hosted one-stop-shop for all your PDF needs
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
# Optional: Add OCR languages (comma-separated)
# TESSERACT_LANGS: "deu,fra,spa"
# Optional: Java memory settings
# JAVA_CUSTOM_OPTS: "-Xmx4g"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
deploy:
resources:
limits:
memory: 4G
reservations:
memory: 2G

View File

@ -0,0 +1,63 @@
# Example Docker Compose for Unified Stirling-PDF Container
# MODE=FRONTEND: Frontend only, connects to separate backend
services:
stirling-pdf-backend:
container_name: Stirling-PDF-Backend
build:
context: ../..
dockerfile: docker/Dockerfile.unified
ports:
- "8081:8080"
volumes:
- ./stirling/data:/usr/share/tessdata:rw
- ./stirling/config:/configs:rw
- ./stirling/logs:/logs:rw
- ./stirling/customFiles:/customFiles:rw
- ./stirling/pipeline:/pipeline:rw
environment:
MODE: BACKEND
DISABLE_ADDITIONAL_FEATURES: "false"
DOCKER_ENABLE_SECURITY: "false"
PUID: 1000
PGID: 1000
UMASK: "022"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status || exit 1"]
interval: 30s
timeout: 10s
retries: 3
deploy:
resources:
limits:
memory: 4G
stirling-pdf-frontend:
container_name: Stirling-PDF-Frontend
build:
context: ../..
dockerfile: docker/Dockerfile.unified
ports:
- "8080:8080"
environment:
MODE: FRONTEND
# Point to the backend service
VITE_API_BASE_URL: http://stirling-pdf-backend:8080
# Minimal config needed for frontend
PUID: 1000
PGID: 1000
depends_on:
- stirling-pdf-backend
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
deploy:
resources:
limits:
memory: 512M

458
docker/unified/README.md Normal file
View File

@ -0,0 +1,458 @@
# Stirling-PDF Unified Container
Single Docker container that can run as **frontend + backend**, **frontend only**, or **backend only** using the `MODE` environment variable.
## Quick Start
### MODE=BOTH (Default)
Single container with both frontend and backend on port 8080:
```bash
docker run -p 8080:8080 \
-e MODE=BOTH \
stirlingtools/stirling-pdf:unified
```
Access at: `http://localhost:8080`
### MODE=FRONTEND
Frontend only, connecting to separate backend:
```bash
docker run -p 8080:8080 \
-e MODE=FRONTEND \
-e VITE_API_BASE_URL=http://backend:8080 \
stirlingtools/stirling-pdf:unified
```
### MODE=BACKEND
Backend API only:
```bash
docker run -p 8080:8080 \
-e MODE=BACKEND \
stirlingtools/stirling-pdf:unified
```
Access API at: `http://localhost:8080/api`
Swagger UI at: `http://localhost:8080/swagger-ui/index.html`
---
## Architecture
### MODE=BOTH (Default)
```
┌─────────────────────────────────────┐
│ Port 8080 (External) │
│ ┌───────────────────────────────┐ │
│ │ Nginx │ │
│ │ • Serves frontend (/) │ │
│ │ • Proxies /api/* → backend │ │
│ └───────────┬───────────────────┘ │
│ │ │
│ ┌───────────▼───────────────────┐ │
│ │ Backend (Internal 8081) │ │
│ │ • Spring Boot │ │
│ │ • PDF Processing │ │
│ │ • UnoServer │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
```
### MODE=FRONTEND
```
┌─────────────────────────────┐ ┌──────────────────┐
│ Frontend Container │ │ Backend │
│ Port 8080 │ │ (External) │
│ ┌───────────────────────┐ │ │ │
│ │ Nginx │ │──────▶ :8080/api │
│ │ • Serves frontend │ │ │ │
│ │ • Proxies to backend │ │ │ │
│ └───────────────────────┘ │ └──────────────────┘
└─────────────────────────────┘
```
### MODE=BACKEND
```
┌─────────────────────────────┐
│ Backend Container │
│ Port 8080 │
│ ┌───────────────────────┐ │
│ │ Spring Boot │ │
│ │ • API Endpoints │ │
│ │ • PDF Processing │ │
│ │ • UnoServer │ │
│ └───────────────────────┘ │
└─────────────────────────────┘
```
---
## Environment Variables
### MODE Configuration
| Variable | Values | Default | Description |
|----------|--------|---------|-------------|
| `MODE` | `BOTH`, `FRONTEND`, `BACKEND` | `BOTH` | Container operation mode |
### MODE=BOTH Specific
| Variable | Default | Description |
|----------|---------|-------------|
| `BACKEND_INTERNAL_PORT` | `8081` | Internal port for backend when MODE=BOTH |
### MODE=FRONTEND Specific
| Variable | Default | Description |
|----------|---------|-------------|
| `VITE_API_BASE_URL` | `http://backend:8080` | Backend URL for API proxying |
### Standard Configuration
All modes support standard Stirling-PDF environment variables:
- `DISABLE_ADDITIONAL_FEATURES` - Enable/disable OCR and LibreOffice features
- `DOCKER_ENABLE_SECURITY` - Enable authentication
- `PUID` / `PGID` - User/Group IDs
- `SYSTEM_MAXFILESIZE` - Max upload size (MB)
- `TESSERACT_LANGS` - Comma-separated OCR language codes
- `JAVA_CUSTOM_OPTS` - Additional JVM options
See full configuration docs at: https://docs.stirlingpdf.com
---
## Docker Compose Examples
### Example 1: All-in-One (MODE=BOTH)
**File:** `docker/compose/docker-compose-unified-both.yml`
```yaml
services:
stirling-pdf:
image: stirlingtools/stirling-pdf:unified
ports:
- "8080:8080"
volumes:
- ./data:/usr/share/tessdata:rw
- ./config:/configs:rw
environment:
MODE: BOTH
restart: unless-stopped
```
### Example 2: Separate Frontend & Backend
**File:** `docker/compose/docker-compose-unified-frontend.yml`
```yaml
services:
backend:
image: stirlingtools/stirling-pdf:unified
ports:
- "8081:8080"
environment:
MODE: BACKEND
volumes:
- ./data:/usr/share/tessdata:rw
- ./config:/configs:rw
frontend:
image: stirlingtools/stirling-pdf:unified
ports:
- "8080:8080"
environment:
MODE: FRONTEND
VITE_API_BASE_URL: http://backend:8080
depends_on:
- backend
```
### Example 3: Backend API Only
**File:** `docker/compose/docker-compose-unified-backend.yml`
```yaml
services:
stirling-pdf-api:
image: stirlingtools/stirling-pdf:unified
ports:
- "8080:8080"
environment:
MODE: BACKEND
volumes:
- ./data:/usr/share/tessdata:rw
- ./config:/configs:rw
restart: unless-stopped
```
---
## Building the Image
```bash
# From repository root
docker build -t stirlingtools/stirling-pdf:unified -f docker/Dockerfile.unified .
```
### Build Arguments
| Argument | Description |
|----------|-------------|
| `VERSION_TAG` | Version tag for the image |
Example:
```bash
docker build \
--build-arg VERSION_TAG=v1.0.0 \
-t stirlingtools/stirling-pdf:unified \
-f docker/Dockerfile.unified .
```
---
## Use Cases
### 1. Simple Deployment (MODE=BOTH)
- **Best for:** Personal use, small teams, simple deployments
- **Pros:** Single container, easy setup, minimal configuration
- **Cons:** Frontend and backend scale together
### 2. Scaled Frontend (MODE=FRONTEND + BACKEND)
- **Best for:** High traffic, need to scale frontend independently
- **Pros:** Scale frontend containers separately, CDN-friendly
- **Example:**
```yaml
services:
backend:
image: stirlingtools/stirling-pdf:unified
environment:
MODE: BACKEND
deploy:
replicas: 1
frontend:
image: stirlingtools/stirling-pdf:unified
environment:
MODE: FRONTEND
VITE_API_BASE_URL: http://backend:8080
deploy:
replicas: 5 # Scale frontend independently
```
### 3. API-Only (MODE=BACKEND)
- **Best for:** Headless deployments, custom frontends, API integrations
- **Pros:** Minimal resources, no nginx overhead
- **Example:** Use with external frontend or API consumers
### 4. Multi-Backend Setup
- **Best for:** Load balancing, high availability
- **Example:**
```yaml
services:
backend-1:
image: stirlingtools/stirling-pdf:unified
environment:
MODE: BACKEND
backend-2:
image: stirlingtools/stirling-pdf:unified
environment:
MODE: BACKEND
frontend:
image: stirlingtools/stirling-pdf:unified
environment:
MODE: FRONTEND
VITE_API_BASE_URL: http://load-balancer:8080
```
---
## Port Configuration
All modes use **port 8080** by default:
- **MODE=BOTH**: Nginx listens on 8080, proxies to backend on internal 8081
- **MODE=FRONTEND**: Nginx listens on 8080
- **MODE=BACKEND**: Spring Boot listens on 8080
**Expose port 8080** in all configurations:
```yaml
ports:
- "8080:8080"
```
---
## Health Checks
### MODE=BOTH and MODE=BACKEND
```yaml
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status || exit 1"]
interval: 30s
timeout: 10s
retries: 3
```
### MODE=FRONTEND
```yaml
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
```
---
## Troubleshooting
### Check logs
```bash
docker logs stirling-pdf-container
```
Look for the startup banner:
```
===================================
Stirling-PDF Unified Container
MODE: BOTH
===================================
```
### Invalid MODE error
```
ERROR: Invalid MODE 'XYZ'. Must be BOTH, FRONTEND, or BACKEND
```
**Fix:** Set `MODE` to one of the three valid values.
### Frontend can't connect to backend (MODE=FRONTEND)
**Check:**
1. `VITE_API_BASE_URL` points to correct backend URL
2. Backend container is running and accessible
3. Network connectivity between containers
### Backend not starting (MODE=BOTH or BACKEND)
**Check:**
1. Sufficient memory allocated (4GB recommended)
2. Java heap size (`JAVA_CUSTOM_OPTS`)
3. Volume permissions for `/tmp/stirling-pdf`
---
## Migration Guide
### From Separate Containers → MODE=BOTH
**Before:**
```yaml
services:
frontend:
image: stirlingtools/stirling-pdf:frontend
ports: ["80:80"]
backend:
image: stirlingtools/stirling-pdf:backend
ports: ["8080:8080"]
```
**After:**
```yaml
services:
stirling-pdf:
image: stirlingtools/stirling-pdf:unified
ports: ["8080:8080"]
environment:
MODE: BOTH
```
### From Legacy → MODE=BACKEND
```yaml
services:
stirling-pdf:
image: stirlingtools/stirling-pdf:latest
ports: ["8080:8080"]
```
**Becomes:**
```yaml
services:
stirling-pdf:
image: stirlingtools/stirling-pdf:unified
ports: ["8080:8080"]
environment:
MODE: BACKEND
```
---
## Performance Tuning
### MODE=BOTH
```yaml
environment:
JAVA_CUSTOM_OPTS: "-Xmx4g -XX:MaxRAMPercentage=75"
BACKEND_INTERNAL_PORT: 8081
deploy:
resources:
limits:
memory: 4G
reservations:
memory: 2G
```
### MODE=FRONTEND (Lightweight)
```yaml
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
```
### MODE=BACKEND (Heavy Processing)
```yaml
environment:
JAVA_CUSTOM_OPTS: "-Xmx8g"
deploy:
resources:
limits:
memory: 10G
reservations:
memory: 4G
```
---
## Security Considerations
1. **MODE=BOTH**: Backend not exposed externally (runs on internal port)
2. **MODE=BACKEND**: API exposed directly - consider API authentication
3. **MODE=FRONTEND**: Only serves static files - minimal attack surface
Enable security features:
```yaml
environment:
DOCKER_ENABLE_SECURITY: "true"
SECURITY_ENABLELOGIN: "true"
```
---
## Support
- Documentation: https://docs.stirlingpdf.com
- GitHub Issues: https://github.com/Stirling-Tools/Stirling-PDF/issues
- Docker Hub: https://hub.docker.com/r/stirlingtools/stirling-pdf
---
## License
MIT License - See repository for full details

38
docker/unified/build.sh Normal file
View File

@ -0,0 +1,38 @@
#!/bin/bash
# Build script for Stirling-PDF Unified Container
# Usage: ./build.sh [version-tag]
set -e
VERSION_TAG=${1:-latest}
IMAGE_NAME="stirlingtools/stirling-pdf:unified-${VERSION_TAG}"
echo "==================================="
echo "Building Stirling-PDF Unified Container"
echo "Version: $VERSION_TAG"
echo "Image: $IMAGE_NAME"
echo "==================================="
# Navigate to repository root (assuming script is in docker/unified/)
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
REPO_ROOT="$SCRIPT_DIR/../.."
cd "$REPO_ROOT"
# Build the image
docker build \
--build-arg VERSION_TAG="$VERSION_TAG" \
-t "$IMAGE_NAME" \
-f docker/Dockerfile.unified \
.
echo "==================================="
echo "✓ Build complete!"
echo "Image: $IMAGE_NAME"
echo ""
echo "Test the image:"
echo " MODE=BOTH: docker run -p 8080:8080 -e MODE=BOTH $IMAGE_NAME"
echo " MODE=FRONTEND: docker run -p 8080:8080 -e MODE=FRONTEND $IMAGE_NAME"
echo " MODE=BACKEND: docker run -p 8080:8080 -e MODE=BACKEND $IMAGE_NAME"
echo "==================================="

View File

@ -0,0 +1,176 @@
#!/bin/bash
set -e
# Default MODE to BOTH if not set
MODE=${MODE:-BOTH}
echo "==================================="
echo "Stirling-PDF Unified Container"
echo "MODE: $MODE"
echo "==================================="
# Function to setup OCR (from init.sh)
setup_ocr() {
echo "Setting up OCR languages..."
# Copy tessdata
mkdir -p /usr/share/tessdata
cp -rn /usr/share/tessdata-original/* /usr/share/tessdata 2>/dev/null || true
if [ -d /usr/share/tesseract-ocr/4.00/tessdata ]; then
cp -r /usr/share/tesseract-ocr/4.00/tessdata/* /usr/share/tessdata 2>/dev/null || true
fi
if [ -d /usr/share/tesseract-ocr/5/tessdata ]; then
cp -r /usr/share/tesseract-ocr/5/tessdata/* /usr/share/tessdata 2>/dev/null || true
fi
# Install additional languages if specified
if [[ -n "$TESSERACT_LANGS" ]]; then
SPACE_SEPARATED_LANGS=$(echo $TESSERACT_LANGS | tr ',' ' ')
pattern='^[a-zA-Z]{2,4}(_[a-zA-Z]{2,4})?$'
for LANG in $SPACE_SEPARATED_LANGS; do
if [[ $LANG =~ $pattern ]]; then
apk add --no-cache "tesseract-ocr-data-$LANG" 2>/dev/null || true
fi
done
fi
}
# Function to setup user permissions (from init-without-ocr.sh)
setup_permissions() {
echo "Setting up user permissions..."
export JAVA_TOOL_OPTIONS="${JAVA_BASE_OPTS} ${JAVA_CUSTOM_OPTS}"
# Update user and group IDs
if [ ! -z "$PUID" ] && [ "$PUID" != "$(id -u stirlingpdfuser)" ]; then
usermod -o -u "$PUID" stirlingpdfuser || true
fi
if [ ! -z "$PGID" ] && [ "$PGID" != "$(getent group stirlingpdfgroup | cut -d: -f3)" ]; then
groupmod -o -g "$PGID" stirlingpdfgroup || true
fi
umask "$UMASK" || true
# Install fonts if needed
if [[ -n "$LANGS" ]]; then
/scripts/installFonts.sh $LANGS
fi
# Ensure directories exist with correct permissions
mkdir -p /tmp/stirling-pdf || true
# Set ownership and permissions
chown -R stirlingpdfuser:stirlingpdfgroup \
$HOME /logs /scripts /usr/share/fonts/opentype/noto \
/configs /customFiles /pipeline /tmp/stirling-pdf \
/var/lib/nginx /var/log/nginx /usr/share/nginx \
/app.jar 2>/dev/null || echo "[WARN] Some chown operations failed, may run as host user"
chmod -R 755 /logs /scripts /usr/share/fonts/opentype/noto \
/configs /customFiles /pipeline /tmp/stirling-pdf 2>/dev/null || true
}
# Function to configure nginx
configure_nginx() {
local backend_url=$1
echo "Configuring nginx with backend URL: $backend_url"
sed -i "s|\${BACKEND_URL}|${backend_url}|g" /etc/nginx/nginx.conf
}
# Function to run as user or root depending on permissions
run_as_user() {
if [ "$(id -u)" = "0" ]; then
# Running as root, use su-exec
su-exec stirlingpdfuser "$@"
else
# Already running as non-root
exec "$@"
fi
}
# Setup OCR and permissions
setup_ocr
setup_permissions
# Handle different modes
case "$MODE" in
BOTH)
echo "Starting in BOTH mode: Frontend + Backend on port 8080"
# Configure nginx to proxy to internal backend
configure_nginx "http://localhost:${BACKEND_INTERNAL_PORT:-8081}"
# Start backend on internal port
echo "Starting backend on port ${BACKEND_INTERNAL_PORT:-8081}..."
run_as_user sh -c "java -Dfile.encoding=UTF-8 \
-Djava.io.tmpdir=/tmp/stirling-pdf \
-Dserver.port=${BACKEND_INTERNAL_PORT:-8081} \
-jar /app.jar" &
BACKEND_PID=$!
# Start unoserver for document conversion
run_as_user /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1 &
UNO_PID=$!
# Wait for backend to start
sleep 3
# Start nginx on port 8080
echo "Starting nginx on port 8080..."
run_as_user nginx -g "daemon off;" &
NGINX_PID=$!
echo "==================================="
echo "✓ Frontend available at: http://localhost:8080"
echo "✓ Backend API at: http://localhost:8080/api"
echo "✓ Backend running internally on port ${BACKEND_INTERNAL_PORT:-8081}"
echo "==================================="
;;
FRONTEND)
echo "Starting in FRONTEND mode: Frontend only on port 8080"
# Configure nginx with external backend URL
BACKEND_URL=${VITE_API_BASE_URL:-http://backend:8080}
configure_nginx "$BACKEND_URL"
# Start nginx on port 8080
echo "Starting nginx on port 8080..."
run_as_user nginx -g "daemon off;" &
NGINX_PID=$!
echo "==================================="
echo "✓ Frontend available at: http://localhost:8080"
echo "✓ Proxying API calls to: $BACKEND_URL"
echo "==================================="
;;
BACKEND)
echo "Starting in BACKEND mode: Backend only on port 8080"
# Start backend on port 8080
echo "Starting backend on port 8080..."
run_as_user sh -c "java -Dfile.encoding=UTF-8 \
-Djava.io.tmpdir=/tmp/stirling-pdf \
-Dserver.port=8080 \
-jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1" &
BACKEND_PID=$!
echo "==================================="
echo "✓ Backend API available at: http://localhost:8080/api"
echo "✓ Swagger UI at: http://localhost:8080/swagger-ui/index.html"
echo "==================================="
;;
*)
echo "ERROR: Invalid MODE '$MODE'. Must be BOTH, FRONTEND, or BACKEND"
exit 1
;;
esac
# Wait for all background processes
wait

118
docker/unified/nginx.conf Normal file
View File

@ -0,0 +1,118 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Add .mjs MIME type mapping
types {
text/javascript mjs;
}
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html index.htm;
# Global settings for file uploads
client_max_body_size 100m;
# Handle client-side routing - support subpaths
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API calls to backend
location /api/ {
proxy_pass ${BACKEND_URL}/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# Additional headers for proper API proxying
proxy_set_header Connection '';
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
# Timeout settings for large file uploads
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Request size limits for file uploads
client_max_body_size 100m;
proxy_request_buffering off;
}
# Proxy Swagger UI to backend (including versioned paths)
location ~ ^/swagger-ui(.*)$ {
proxy_pass ${BACKEND_URL}/swagger-ui$1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Connection '';
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
}
# Proxy API docs to backend (with query parameters and sub-paths)
location ~ ^/v3/api-docs(.*)$ {
proxy_pass ${BACKEND_URL}/v3/api-docs$1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
}
# Proxy v1 API docs to backend (with query parameters and sub-paths)
location ~ ^/v1/api-docs(.*)$ {
proxy_pass ${BACKEND_URL}/v1/api-docs$1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
}
# Serve .mjs files with correct MIME type (must come before general static assets)
location ~* \.mjs$ {
try_files $uri =404;
add_header Content-Type "text/javascript; charset=utf-8" always;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
}

View File

@ -59,7 +59,7 @@
"predev": "npm run generate-icons",
"dev": "npm run typecheck && vite",
"prebuild": "npm run generate-icons",
"lint": "eslint",
"lint": "eslint --max-warnings=0",
"build": "npm run typecheck && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",

View File

@ -250,6 +250,7 @@
"title": "Do you want make Stirling PDF better?",
"paragraph1": "Stirling PDF has opt in analytics to help us improve the product. We do not track any personal information or file contents.",
"paragraph2": "Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better.",
"learnMore": "Learn more",
"enable": "Enable analytics",
"disable": "Disable analytics",
"settings": "You can change the settings for analytics in the config/settings.yml file"
@ -3441,6 +3442,10 @@
"title": "Analytics",
"description": "These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with."
}
},
"services": {
"posthog": "PostHog Analytics",
"scarf": "Scarf Pixel"
}
},
"removeMetadata": {

View File

@ -9,10 +9,12 @@ import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
import { HotkeyProvider } from "./contexts/HotkeyContext";
import { SidebarProvider } from "./contexts/SidebarContext";
import { PreferencesProvider } from "./contexts/PreferencesContext";
import { AppConfigProvider } from "./contexts/AppConfigContext";
import { OnboardingProvider } from "./contexts/OnboardingContext";
import { TourOrchestrationProvider } from "./contexts/TourOrchestrationContext";
import ErrorBoundary from "./components/shared/ErrorBoundary";
import OnboardingTour from "./components/onboarding/OnboardingTour";
import { useScarfTracking } from "./hooks/useScarfTracking";
// Import auth components
import { AuthProvider } from "./auth/UseSession";
@ -48,6 +50,12 @@ const LoadingFallback = () => (
</div>
);
// Component to initialize scarf tracking (must be inside AppConfigProvider)
function ScarfTrackingInitializer() {
useScarfTracking();
return null;
}
export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
@ -66,30 +74,33 @@ export default function App() {
path="/*"
element={
<OnboardingProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<Landing />
<OnboardingTour />
</TourOrchestrationProvider>
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
<AppConfigProvider>
<ScarfTrackingInitializer />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<Landing />
<OnboardingTour />
</TourOrchestrationProvider>
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</AppConfigProvider>
</OnboardingProvider>
}
/>

View File

@ -1,4 +1,4 @@
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import {
Text, Center, Box, LoadingOverlay, Stack
} from '@mantine/core';

View File

@ -1,4 +1,3 @@
import React from 'react';
import { Box } from '@mantine/core';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
@ -7,6 +6,7 @@ import { useFileState } from '../../contexts/FileContext';
import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
import { isBaseWorkbench } from '../../types/workbench';
import { useViewer } from '../../contexts/ViewerContext';
import { useAppConfig } from '../../contexts/AppConfigContext';
import './Workbench.css';
import TopControls from '../shared/TopControls';
@ -21,6 +21,7 @@ import DismissAllErrorsButton from '../shared/DismissAllErrorsButton';
// No props needed - component uses contexts directly
export default function Workbench() {
const { isRainbowMode } = useRainbowThemeContext();
const { config } = useAppConfig();
// Use context-based hooks to eliminate all prop drilling
const { selectors } = useFileState();
@ -194,7 +195,7 @@ export default function Workbench() {
{renderMainContent()}
</Box>
<Footer analyticsEnabled />
<Footer analyticsEnabled={config?.enableAnalytics === true} />
</Box>
);
}

View File

@ -0,0 +1,112 @@
import { Modal, Stack, Button, Text, Title, Anchor } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import { Z_ANALYTICS_MODAL } from '../../styles/zIndex';
import { useAppConfig } from '../../contexts/AppConfigContext';
import apiClient from '../../services/apiClient';
interface AdminAnalyticsChoiceModalProps {
opened: boolean;
onClose: () => void;
}
export default function AdminAnalyticsChoiceModal({ opened, onClose }: AdminAnalyticsChoiceModalProps) {
const { t } = useTranslation();
const { refetch } = useAppConfig();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleChoice = async (enableAnalytics: boolean) => {
setLoading(true);
setError(null);
try {
const formData = new FormData();
formData.append('enabled', enableAnalytics.toString());
await apiClient.post('/api/v1/settings/update-enable-analytics', formData);
// Refetch config to apply new settings without page reload
await refetch();
// Close the modal after successful save
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error occurred');
setLoading(false);
}
};
const handleEnable = () => {
handleChoice(true);
};
const handleDisable = () => {
handleChoice(false);
};
return (
<Modal
opened={opened}
onClose={() => {}} // Prevent closing
closeOnClickOutside={false}
closeOnEscape={false}
withCloseButton={false}
size="lg"
centered
zIndex={Z_ANALYTICS_MODAL}
>
<Stack gap="md">
<Title order={2}>{t('analytics.title', 'Do you want make Stirling PDF better?')}</Title>
<Text size="sm" c="dimmed">
{t('analytics.paragraph1', 'Stirling PDF has opt in analytics to help us improve the product. We do not track any personal information or file contents.')}
</Text>
<Text size="sm" c="dimmed">
{t('analytics.paragraph2', 'Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better.')}{' '}
<Anchor
href="https://docs.stirlingpdf.com/analytics-telemetry"
target="_blank"
rel="noopener noreferrer"
size="sm"
>
{t('analytics.learnMore', 'Learn more')}
</Anchor>
</Text>
{error && (
<Text c="red" size="sm">
{error}
</Text>
)}
<Stack gap="sm">
<Button
onClick={handleEnable}
loading={loading}
fullWidth
size="md"
>
{t('analytics.enable', 'Enable analytics')}
</Button>
<Button
onClick={handleDisable}
loading={loading}
fullWidth
size="md"
variant="subtle"
c="gray"
>
{t('analytics.disable', 'Disable analytics')}
</Button>
</Stack>
<Text size="xs" c="dimmed" ta="center">
{t('analytics.settings', 'You can change the settings for analytics in the config/settings.yml file')}
</Text>
</Stack>
</Modal>
);
}

View File

@ -1,4 +1,4 @@
import './dividerWithText/DividerWithText.css'
import './dividerWithText/DividerWithText.css';
interface TextDividerProps {
text?: string
@ -10,9 +10,9 @@ interface TextDividerProps {
}
export default function DividerWithText({ text, className = '', style, variant = 'default', respondsToDarkMode = true, opacity }: TextDividerProps) {
const variantClass = variant === 'subcategory' ? 'subcategory' : ''
const themeClass = respondsToDarkMode ? '' : 'force-light'
const styleWithOpacity = opacity !== undefined ? { ...(style || {}), ['--text-divider-opacity' as any]: opacity } : style
const variantClass = variant === 'subcategory' ? 'subcategory' : '';
const themeClass = respondsToDarkMode ? '' : 'force-light';
const styleWithOpacity = opacity !== undefined ? { ...(style || {}), ['--text-divider-opacity' as any]: opacity } : style;
if (text) {
return (
@ -24,7 +24,7 @@ export default function DividerWithText({ text, className = '', style, variant =
<span className="text-divider__label">{text}</span>
<div className="text-divider__rule" />
</div>
)
);
}
return (
@ -32,5 +32,5 @@ export default function DividerWithText({ text, className = '', style, variant =
className={`h-px my-2.5 ${themeClass} ${className}`}
style={styleWithOpacity}
/>
)
);
}

View File

@ -12,8 +12,8 @@ interface FooterProps {
}
export default function Footer({
privacyPolicy = '/privacy',
termsAndConditions = '/terms',
privacyPolicy = 'https://www.stirling.com/legal/privacy-policy',
termsAndConditions = 'https://www.stirling.com/legal/terms-of-service',
accessibilityStatement = 'accessibility',
analyticsEnabled = false
}: FooterProps) {

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react';
import { BASE_PATH } from '../../constants/app';
type ImageSlide = { src: string; alt?: string; cornerModelUrl?: string; title?: string; subtitle?: string; followMouseTilt?: boolean; tiltMaxDeg?: number }
@ -14,53 +14,53 @@ export default function LoginRightCarousel({
initialSeconds?: number
slideSeconds?: number
}) {
const totalSlides = imageSlides.length
const [index, setIndex] = useState(0)
const mouse = useRef({ x: 0, y: 0 })
const totalSlides = imageSlides.length;
const [index, setIndex] = useState(0);
const mouse = useRef({ x: 0, y: 0 });
const durationsMs = useMemo(() => {
if (imageSlides.length === 0) return []
return imageSlides.map((_, i) => (i === 0 ? (initialSeconds ?? slideSeconds) : slideSeconds) * 1000)
}, [imageSlides, initialSeconds, slideSeconds])
if (imageSlides.length === 0) return [];
return imageSlides.map((_, i) => (i === 0 ? (initialSeconds ?? slideSeconds) : slideSeconds) * 1000);
}, [imageSlides, initialSeconds, slideSeconds]);
useEffect(() => {
if (totalSlides <= 1) return
if (totalSlides <= 1) return;
const timeout = setTimeout(() => {
setIndex((i) => (i + 1) % totalSlides)
}, durationsMs[index] ?? slideSeconds * 1000)
return () => clearTimeout(timeout)
}, [index, totalSlides, durationsMs, slideSeconds])
setIndex((i) => (i + 1) % totalSlides);
}, durationsMs[index] ?? slideSeconds * 1000);
return () => clearTimeout(timeout);
}, [index, totalSlides, durationsMs, slideSeconds]);
useEffect(() => {
const onMove = (e: MouseEvent) => {
mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1
mouse.current.y = (e.clientY / window.innerHeight) * 2 - 1
}
window.addEventListener('mousemove', onMove)
return () => window.removeEventListener('mousemove', onMove)
}, [])
mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.current.y = (e.clientY / window.innerHeight) * 2 - 1;
};
window.addEventListener('mousemove', onMove);
return () => window.removeEventListener('mousemove', onMove);
}, []);
function TiltImage({ src, alt, enabled, maxDeg = 6 }: { src: string; alt?: string; enabled: boolean; maxDeg?: number }) {
const imgRef = useRef<HTMLImageElement | null>(null)
const imgRef = useRef<HTMLImageElement | null>(null);
useEffect(() => {
const el = imgRef.current
if (!el) return
const el = imgRef.current;
if (!el) return;
let raf = 0
let raf = 0;
const tick = () => {
if (enabled) {
const rotY = (mouse.current.x || 0) * maxDeg
const rotX = -(mouse.current.y || 0) * maxDeg
el.style.transform = `translateY(-2rem) rotateX(${rotX.toFixed(2)}deg) rotateY(${rotY.toFixed(2)}deg)`
const rotY = (mouse.current.x || 0) * maxDeg;
const rotX = -(mouse.current.y || 0) * maxDeg;
el.style.transform = `translateY(-2rem) rotateX(${rotX.toFixed(2)}deg) rotateY(${rotY.toFixed(2)}deg)`;
} else {
el.style.transform = 'translateY(-2rem)'
el.style.transform = 'translateY(-2rem)';
}
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [enabled, maxDeg])
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [enabled, maxDeg]);
return (
<img
@ -79,7 +79,7 @@ export default function LoginRightCarousel({
transformOrigin: '50% 50%',
}}
/>
)
);
}
return (
@ -155,5 +155,5 @@ export default function LoginRightCarousel({
))}
</div>
</div>
)
);
}

View File

@ -13,7 +13,7 @@ import './quickAccessBar/QuickAccessBar.css';
import AllToolsNavButton from './AllToolsNavButton';
import ActiveToolButton from "./quickAccessBar/ActiveToolButton";
import AppConfigModal from './AppConfigModal';
import { useAppConfig } from '../../hooks/useAppConfig';
import { useAppConfig } from '../../contexts/AppConfigContext';
import { useOnboarding } from '../../contexts/OnboardingContext';
import {
isNavButtonActive,

View File

@ -7,6 +7,10 @@ import styles from './textInput/TextInput.module.css';
* Props for the TextInput component
*/
export interface TextInputProps {
/** The input ID (required) */
id: string;
/** The input name (required) */
name: string;
/** The input value (required) */
value: string;
/** Callback when input value changes (required) */
@ -36,6 +40,8 @@ export interface TextInputProps {
}
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
id,
name,
value,
onChange,
placeholder,
@ -76,6 +82,8 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
<input
ref={ref}
type="text"
id={id}
name={name}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.currentTarget.value)}

View File

@ -1,6 +1,6 @@
import React from 'react';
import { Stack, Text, Code, Group, Badge, Alert, Loader, Button } from '@mantine/core';
import { useAppConfig } from '../../../../hooks/useAppConfig';
import { useAppConfig } from '../../../../contexts/AppConfigContext';
import { useAuth } from '../../../../auth/UseSession';
import { useNavigate } from 'react-router-dom';
@ -125,4 +125,4 @@ const Overview: React.FC = () => {
);
};
export default Overview;
export default Overview;

View File

@ -38,6 +38,6 @@ export const loginSlides: LoginCarouselSlide[] = [
followMouseTilt: true,
tiltMaxDeg: 5,
},
]
];
export default loginSlides
export default loginSlides;

View File

@ -1,4 +1,3 @@
import React from 'react';
import { Slider, Text, Group, NumberInput } from '@mantine/core';
interface Props {

View File

@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import { useMemo } from 'react';
import { Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ToolRegistryEntry, getSubcategoryLabel, getSubcategoryColor, getSubcategoryIcon } from '../../data/toolsTaxonomy';
@ -104,7 +104,7 @@ const FullscreenToolList = ({
{showRecentFavorites && (
<>
{favoriteToolItems.length > 0 && (
<section
<section
className="tool-panel__fullscreen-group tool-panel__fullscreen-group--special"
style={{
borderColor: 'var(--fullscreen-border-favorites)',
@ -146,7 +146,7 @@ const FullscreenToolList = ({
)}
{recommendedItems.length > 0 && (
<section
<section
className="tool-panel__fullscreen-group tool-panel__fullscreen-group--special"
style={{
borderColor: 'var(--fullscreen-border-recommended)',

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { usePreferences } from '../../contexts/PreferencesContext';

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AddPageNumbersParameters } from './useAddPageNumbersParameters';
import { pdfWorkerManager } from '../../../services/pdfWorkerManager';

View File

@ -1,4 +1,3 @@
import React from 'react';
import { Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';

View File

@ -1,4 +1,3 @@
import React from 'react';
import { Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';

View File

@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
import { useThumbnailGeneration } from '../../../hooks/useThumbnailGeneration';
import ObscuredOverlay from '../../shared/ObscuredOverlay';

View File

@ -1,4 +1,3 @@
import React from 'react';
import { Stack } from '@mantine/core';
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
import AdjustContrastBasicSettings from './AdjustContrastBasicSettings';

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Text, Stack, Group, Card, Progress } from "@mantine/core";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";

View File

@ -1,6 +1,6 @@
import { Stack, Button } from "@mantine/core";
import { CertSignParameters } from "../../../hooks/tools/certSign/useCertSignParameters";
import { useAppConfig } from "../../../hooks/useAppConfig";
import { useAppConfig } from "../../../contexts/AppConfigContext";
interface CertificateTypeSettingsProps {
parameters: CertSignParameters;

View File

@ -82,15 +82,17 @@ const ToolSearch = ({
}, [autoFocus]);
const searchInput = (
<TextInput
ref={searchRef}
value={value}
onChange={handleSearchChange}
placeholder={placeholder || t("toolPicker.searchPlaceholder", "Search tools...")}
icon={hideIcon ? undefined : <LocalIcon icon="search-rounded" width="1.5rem" height="1.5rem" />}
autoComplete="off"
onFocus={onFocus}
/>
<TextInput
id="tool-search-input"
name="tool-search-input"
ref={searchRef}
value={value}
onChange={handleSearchChange}
placeholder={placeholder || t("toolPicker.searchPlaceholder", "Search tools...")}
icon={hideIcon ? undefined : <LocalIcon icon="search-rounded" width="1.5rem" height="1.5rem" />}
autoComplete="off"
onFocus={onFocus}
/>
);
if (mode === "filter") {

View File

@ -1,4 +1,3 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import './styles.css';
import FieldBlock from './FieldBlock';

View File

@ -1,4 +1,3 @@
import React from 'react';
import { Divider, Group, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { SignatureValidationSignature } from '../../../../types/validateSignature';

View File

@ -1,4 +1,3 @@
import React from 'react';
import { Badge, Popover, Text } from '@mantine/core';
import './styles.css';
import { useTranslation } from 'react-i18next';

View File

@ -1,4 +1,3 @@
import React from 'react';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import './styles.css';

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { createPluginRegistration } from '@embedpdf/core';
import { EmbedPDF } from '@embedpdf/core/react';
import { usePdfiumEngine } from '@embedpdf/engines/react';
@ -316,4 +316,4 @@ export function LocalEmbedPDFWithAnnotations({
</EmbedPDF>
</div>
);
}
}

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import React, { createContext, useContext, useState, useEffect } from 'react';
// Helper to get JWT from localStorage for Authorization header
function getAuthHeaders(): HeadersInit {
@ -16,7 +16,9 @@ export interface AppConfig {
languages?: string[];
enableLogin?: boolean;
enableAlphaFunctionality?: boolean;
enableAnalytics?: boolean;
enableAnalytics?: boolean | null;
enablePosthog?: boolean | null;
enableScarf?: boolean | null;
premiumEnabled?: boolean;
premiumKey?: string;
termsAndConditions?: string;
@ -32,17 +34,21 @@ export interface AppConfig {
error?: string;
}
interface UseAppConfigReturn {
interface AppConfigContextValue {
config: AppConfig | null;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
// Create context
const AppConfigContext = createContext<AppConfigContextValue | undefined>(undefined);
/**
* Custom hook to fetch and manage application configuration
* Provider component that fetches and provides app configuration
* Should be placed at the top level of the app, before any components that need config
*/
export function useAppConfig(): UseAppConfigReturn {
export const AppConfigProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [config, setConfig] = useState<AppConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -59,13 +65,13 @@ export function useAppConfig(): UseAppConfigReturn {
if (!response.ok) {
throw new Error(`Failed to fetch config: ${response.status} ${response.statusText}`);
}
const data: AppConfig = await response.json();
setConfig(data);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
setError(errorMessage);
console.error('Failed to fetch app config:', err);
console.error('[AppConfig] Failed to fetch app config:', err);
} finally {
setLoading(false);
}
@ -75,11 +81,31 @@ export function useAppConfig(): UseAppConfigReturn {
fetchConfig();
}, []);
return {
const value: AppConfigContextValue = {
config,
loading,
error,
refetch: fetchConfig,
};
return (
<AppConfigContext.Provider value={value}>
{children}
</AppConfigContext.Provider>
);
};
/**
* Hook to access application configuration
* Must be used within AppConfigProvider
*/
export function useAppConfig(): AppConfigContextValue {
const context = useContext(AppConfigContext);
if (context === undefined) {
throw new Error('useAppConfig must be used within AppConfigProvider');
}
return context;
}

View File

@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import { useMemo } from 'react';
import type { ToolId } from '../types/toolId';
import type { ToolRegistry } from '../data/toolsTaxonomy';

View File

@ -1,4 +1,4 @@
import React, { useMemo } from "react";
import { useMemo } from "react";
import LocalIcon from "../components/shared/LocalIcon";
import { useTranslation } from "react-i18next";
import SplitPdfPanel from "../tools/Split";

View File

@ -1,4 +1,4 @@
import { useAppConfig } from './useAppConfig';
import { useAppConfig } from '../contexts/AppConfigContext'
export const useBaseUrl = (): string => {
const { config } = useAppConfig();

View File

@ -1,12 +1,15 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { BASE_PATH } from '../constants/app';
import { useAppConfig } from '../contexts/AppConfigContext';
declare global {
interface Window {
CookieConsent: {
CookieConsent?: {
run: (config: any) => void;
show: (show?: boolean) => void;
acceptedCategory: (category: string) => boolean;
acceptedService: (serviceName: string, category: string) => boolean;
};
}
}
@ -15,8 +18,11 @@ interface CookieConsentConfig {
analyticsEnabled?: boolean;
}
export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConfig = {}) => {
export const useCookieConsent = ({
analyticsEnabled = false
}: CookieConsentConfig = {}) => {
const { t } = useTranslation();
const { config } = useAppConfig();
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
@ -30,7 +36,7 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
setIsInitialized(true);
// Force show the modal if it exists but isn't visible
setTimeout(() => {
window.CookieConsent.show();
window.CookieConsent?.show();
}, 100);
return;
}
@ -130,7 +136,24 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
necessary: {
readOnly: true
},
analytics: {}
analytics: {
services: {
...(config?.enablePosthog !== false && {
posthog: {
label: t('cookieBanner.services.posthog', 'PostHog Analytics'),
onAccept: () => console.log('PostHog service accepted'),
onReject: () => console.log('PostHog service rejected')
}
}),
...(config?.enableScarf !== false && {
scarf: {
label: t('cookieBanner.services.scarf', 'Scarf Pixel'),
onAccept: () => console.log('Scarf service accepted'),
onReject: () => console.log('Scarf service rejected')
}
})
}
}
},
language: {
default: "en",
@ -184,7 +207,7 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
// Force show after initialization
setTimeout(() => {
window.CookieConsent.show();
window.CookieConsent?.show();
}, 200);
} catch (error) {
@ -212,15 +235,23 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
document.head.removeChild(customCSS);
}
};
}, [analyticsEnabled, t]);
}, [analyticsEnabled, config?.enablePosthog, config?.enableScarf, t]);
const showCookiePreferences = () => {
if (isInitialized && window.CookieConsent) {
window.CookieConsent.show(true);
window.CookieConsent?.show(true);
}
};
const isServiceAccepted = useCallback((service: string, category: string): boolean => {
if (typeof window === 'undefined' || !window.CookieConsent) {
return false;
}
return window.CookieConsent.acceptedService(service, category);
}, []);
return {
showCookiePreferences
showCookiePreferences,
isServiceAccepted
};
};

View File

@ -0,0 +1,44 @@
import { useEffect } from 'react';
import { useAppConfig } from '../contexts/AppConfigContext';
import { useCookieConsent } from './useCookieConsent';
import { setScarfConfig, firePixel } from '../utils/scarfTracking';
/**
* Hook for initializing Scarf tracking
*
* This hook should be mounted once during app initialization (e.g., in index.tsx).
* It configures the scarf tracking utility with current config and consent state,
* and sets up event listeners to auto-fire pixels when consent is granted.
*
* After initialization, firePixel() can be called from anywhere in the app,
* including non-React utility functions like urlRouting.ts.
*/
export function useScarfTracking() {
const { config } = useAppConfig();
const { isServiceAccepted } = useCookieConsent({ analyticsEnabled: config?.enableAnalytics === true });
// Update scarf config whenever config or consent changes
useEffect(() => {
if (config && config.enableScarf !== undefined) {
setScarfConfig(config.enableScarf, isServiceAccepted);
}
}, [config?.enableScarf, isServiceAccepted]);
// Listen to cookie consent changes and auto-fire pixel when consent is granted
useEffect(() => {
const handleConsentChange = () => {
console.warn('[useScarfTracking] Consent changed, checking scarf service acceptance');
if (isServiceAccepted('scarf', 'analytics')) {
firePixel(window.location.pathname);
}
};
window.addEventListener('cc:onConsent', handleConsentChange);
window.addEventListener('cc:onChange', handleConsentChange);
return () => {
window.removeEventListener('cc:onConsent', handleConsentChange);
window.removeEventListener('cc:onChange', handleConsentChange);
};
}, [isServiceAccepted]);
}

View File

@ -17,22 +17,21 @@ posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, {
defaults: '2025-05-24',
capture_exceptions: true, // This enables capturing exceptions using Error Tracking, set to false if you don't want this
debug: false,
opt_out_capturing_by_default: false, // We handle opt-out via cookie consent
opt_out_capturing_by_default: true, // Opt-out by default, controlled by cookie consent
});
function updatePosthogConsent(){
if(typeof(posthog) == "undefined") {
return;
}
const optIn = (window.CookieConsent as any).acceptedCategory('analytics');
if (optIn) {
posthog.opt_in_capturing();
} else {
posthog.opt_out_capturing();
}
console.log("Updated analytics consent: ", optIn? "opted in" : "opted out");
if(typeof(posthog) == "undefined" || !posthog.__loaded) {
return;
}
const optIn = (window.CookieConsent as any)?.acceptedService?.('posthog', 'analytics') || false;
if (optIn) {
posthog.opt_in_capturing();
} else {
posthog.opt_out_capturing();
}
console.log("Updated PostHog consent: ", optIn ? "opted in" : "opted out");
}
window.addEventListener("cc:onConsent", updatePosthogConsent);
window.addEventListener("cc:onChange", updatePosthogConsent);

View File

@ -7,6 +7,7 @@ import { useDocumentMeta } from "../hooks/useDocumentMeta";
import { BASE_PATH } from "../constants/app";
import { useBaseUrl } from "../hooks/useBaseUrl";
import { useMediaQuery } from "@mantine/hooks";
import { useAppConfig } from "../contexts/AppConfigContext";
import AppsIcon from '@mui/icons-material/AppsRounded';
import ToolPanel from "../components/tools/ToolPanel";
@ -18,6 +19,7 @@ import LocalIcon from "../components/shared/LocalIcon";
import { useFilesModalContext } from "../contexts/FilesModalContext";
import AppConfigModal from "../components/shared/AppConfigModal";
import ToolPanelModePrompt from "../components/tools/ToolPanelModePrompt";
import AdminAnalyticsChoiceModal from "../components/shared/AdminAnalyticsChoiceModal";
import "./HomePage.css";
@ -43,11 +45,20 @@ export default function HomePage() {
const { openFilesModal } = useFilesModalContext();
const { colorScheme } = useMantineColorScheme();
const { config } = useAppConfig();
const isMobile = useMediaQuery("(max-width: 1024px)");
const sliderRef = useRef<HTMLDivElement | null>(null);
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
const isProgrammaticScroll = useRef(false);
const [configModalOpen, setConfigModalOpen] = useState(false);
const [showAnalyticsModal, setShowAnalyticsModal] = useState(false);
// Show admin analytics choice modal if analytics settings not configured
useEffect(() => {
if (config && config.enableAnalytics === null) {
setShowAnalyticsModal(true);
}
}, [config]);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${
@ -152,6 +163,10 @@ export default function HomePage() {
return (
<div className="h-screen overflow-hidden">
<AdminAnalyticsChoiceModal
opened={showAnalyticsModal}
onClose={() => setShowAnalyticsModal(false)}
/>
<ToolPanelModePrompt />
{isMobile ? (
<div className="mobile-layout">

View File

@ -1,6 +1,6 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/UseSession'
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/UseSession';
/**
* OAuth Callback Handler
@ -10,50 +10,50 @@ import { useAuth } from '../auth/UseSession'
* We extract it, store in localStorage, and redirect to the home page.
*/
export default function AuthCallback() {
const navigate = useNavigate()
const { refreshSession } = useAuth()
const navigate = useNavigate();
const { refreshSession } = useAuth();
useEffect(() => {
const handleCallback = async () => {
try {
console.log('[AuthCallback] Handling OAuth callback...')
console.log('[AuthCallback] Handling OAuth callback...');
// Extract JWT from URL fragment (#access_token=...)
const hash = window.location.hash.substring(1) // Remove '#'
const params = new URLSearchParams(hash)
const token = params.get('access_token')
const hash = window.location.hash.substring(1); // Remove '#'
const params = new URLSearchParams(hash);
const token = params.get('access_token');
if (!token) {
console.error('[AuthCallback] No access_token in URL fragment')
console.error('[AuthCallback] No access_token in URL fragment');
navigate('/login', {
replace: true,
state: { error: 'OAuth login failed - no token received.' }
})
return
});
return;
}
// Store JWT in localStorage
localStorage.setItem('stirling_jwt', token)
console.log('[AuthCallback] JWT stored in localStorage')
localStorage.setItem('stirling_jwt', token);
console.log('[AuthCallback] JWT stored in localStorage');
// Refresh session to load user info into state
await refreshSession()
await refreshSession();
console.log('[AuthCallback] Session refreshed, redirecting to home')
console.log('[AuthCallback] Session refreshed, redirecting to home');
// Clear the hash from URL and redirect to home page
navigate('/', { replace: true })
navigate('/', { replace: true });
} catch (error) {
console.error('[AuthCallback] Error:', error)
console.error('[AuthCallback] Error:', error);
navigate('/login', {
replace: true,
state: { error: 'OAuth login failed. Please try again.' }
})
});
}
}
};
handleCallback()
}, [navigate, refreshSession])
handleCallback();
}, [navigate, refreshSession]);
return (
<div style={{
@ -69,5 +69,5 @@ export default function AuthCallback() {
</div>
</div>
</div>
)
);
}

View File

@ -1,6 +1,6 @@
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '../auth/UseSession'
import { useAppConfig } from '../hooks/useAppConfig'
import { useAppConfig } from '../contexts/AppConfigContext'
import HomePage from '../pages/HomePage'
import Login from './Login'
@ -12,18 +12,18 @@ import Login from './Login'
* If user is not authenticated: Show Login or redirect to /login
*/
export default function Landing() {
const { session, loading: authLoading } = useAuth()
const { config, loading: configLoading } = useAppConfig()
const location = useLocation()
const { session, loading: authLoading } = useAuth();
const { config, loading: configLoading } = useAppConfig();
const location = useLocation();
const loading = authLoading || configLoading
const loading = authLoading || configLoading;
console.log('[Landing] State:', {
pathname: location.pathname,
loading,
hasSession: !!session,
loginEnabled: config?.enableLogin,
})
});
// Show loading while checking auth and config
if (loading) {
@ -36,27 +36,27 @@ export default function Landing() {
</div>
</div>
</div>
)
);
}
// If login is disabled, show app directly (anonymous mode)
if (config?.enableLogin === false) {
console.debug('[Landing] Login disabled - showing app in anonymous mode')
return <HomePage />
console.debug('[Landing] Login disabled - showing app in anonymous mode');
return <HomePage />;
}
// If we have a session, show the main app
if (session) {
return <HomePage />
return <HomePage />;
}
// If we're at home route ("/"), show login directly (marketing/landing page)
// Otherwise navigate to login (fixes URL mismatch for tool routes)
const isHome = location.pathname === '/' || location.pathname === ''
const isHome = location.pathname === '/' || location.pathname === '';
if (isHome) {
return <Login />
return <Login />;
}
// For non-home routes without auth, navigate to login (preserves from location)
return <Navigate to="/login" replace state={{ from: location }} />
return <Navigate to="/login" replace state={{ from: location }} />;
}

View File

@ -1,42 +1,42 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { springAuth } from '../auth/springAuthClient'
import { useAuth } from '../auth/UseSession'
import { useTranslation } from 'react-i18next'
import { useDocumentMeta } from '../hooks/useDocumentMeta'
import AuthLayout from './authShared/AuthLayout'
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { springAuth } from '../auth/springAuthClient';
import { useAuth } from '../auth/UseSession';
import { useTranslation } from 'react-i18next';
import { useDocumentMeta } from '../hooks/useDocumentMeta';
import AuthLayout from './authShared/AuthLayout';
// Import login components
import LoginHeader from './login/LoginHeader'
import ErrorMessage from './login/ErrorMessage'
import EmailPasswordForm from './login/EmailPasswordForm'
import OAuthButtons from './login/OAuthButtons'
import DividerWithText from '../components/shared/DividerWithText'
import LoggedInState from './login/LoggedInState'
import { BASE_PATH } from '../constants/app'
import LoginHeader from './login/LoginHeader';
import ErrorMessage from './login/ErrorMessage';
import EmailPasswordForm from './login/EmailPasswordForm';
import OAuthButtons from './login/OAuthButtons';
import DividerWithText from '../components/shared/DividerWithText';
import LoggedInState from './login/LoggedInState';
import { BASE_PATH } from '../constants/app';
export default function Login() {
const navigate = useNavigate()
const { session, loading } = useAuth()
const { t } = useTranslation()
const [isSigningIn, setIsSigningIn] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showEmailForm, setShowEmailForm] = useState(false)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const navigate = useNavigate();
const { session, loading } = useAuth();
const { t } = useTranslation();
const [isSigningIn, setIsSigningIn] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showEmailForm, setShowEmailForm] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
// Prefill email from query param (e.g. after password reset)
useEffect(() => {
try {
const url = new URL(window.location.href)
const emailFromQuery = url.searchParams.get('email')
const url = new URL(window.location.href);
const emailFromQuery = url.searchParams.get('email');
if (emailFromQuery) {
setEmail(emailFromQuery)
setEmail(emailFromQuery);
}
} catch (_) {
// ignore
}
}, [])
}, []);
const baseUrl = window.location.origin + BASE_PATH;
@ -48,74 +48,74 @@ export default function Login() {
ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
ogImage: `${baseUrl}/og_images/home.png`,
ogUrl: `${window.location.origin}${window.location.pathname}`
})
});
// Show logged in state if authenticated
if (session && !loading) {
return <LoggedInState />
return <LoggedInState />;
}
const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
try {
setIsSigningIn(true)
setError(null)
setIsSigningIn(true);
setError(null);
console.log(`[Login] Signing in with ${provider}`)
console.log(`[Login] Signing in with ${provider}`);
// Redirect to Spring OAuth2 endpoint
const { error } = await springAuth.signInWithOAuth({
provider,
options: { redirectTo: `${BASE_PATH}/auth/callback` }
})
});
if (error) {
console.error(`[Login] ${provider} error:`, error)
setError(t('login.failedToSignIn', { provider, message: error.message }) || `Failed to sign in with ${provider}`)
console.error(`[Login] ${provider} error:`, error);
setError(t('login.failedToSignIn', { provider, message: error.message }) || `Failed to sign in with ${provider}`);
}
} catch (err) {
console.error(`[Login] Unexpected error:`, err)
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }) || 'An unexpected error occurred')
console.error(`[Login] Unexpected error:`, err);
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }) || 'An unexpected error occurred');
} finally {
setIsSigningIn(false)
setIsSigningIn(false);
}
}
};
const signInWithEmail = async () => {
if (!email || !password) {
setError(t('login.pleaseEnterBoth') || 'Please enter both email and password')
return
setError(t('login.pleaseEnterBoth') || 'Please enter both email and password');
return;
}
try {
setIsSigningIn(true)
setError(null)
setIsSigningIn(true);
setError(null);
console.log('[Login] Signing in with email:', email)
console.log('[Login] Signing in with email:', email);
const { user, session, error } = await springAuth.signInWithPassword({
email: email.trim(),
password: password
})
});
if (error) {
console.error('[Login] Email sign in error:', error)
setError(error.message)
console.error('[Login] Email sign in error:', error);
setError(error.message);
} else if (user && session) {
console.log('[Login] Email sign in successful')
console.log('[Login] Email sign in successful');
// Auth state will update automatically and Landing will redirect to home
// No need to navigate manually here
}
} catch (err) {
console.error('[Login] Unexpected error:', err)
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }) || 'An unexpected error occurred')
console.error('[Login] Unexpected error:', err);
setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }) || 'An unexpected error occurred');
} finally {
setIsSigningIn(false)
setIsSigningIn(false);
}
}
};
const handleForgotPassword = () => {
navigate('/auth/reset')
}
navigate('/auth/reset');
};
return (
<AuthLayout>
@ -185,5 +185,5 @@ export default function Login() {
</div>
</AuthLayout>
)
);
}

View File

@ -1,28 +1,28 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useDocumentMeta } from '../hooks/useDocumentMeta'
import AuthLayout from './authShared/AuthLayout'
import './authShared/auth.css'
import { BASE_PATH } from '../constants/app'
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useDocumentMeta } from '../hooks/useDocumentMeta';
import AuthLayout from './authShared/AuthLayout';
import './authShared/auth.css';
import { BASE_PATH } from '../constants/app';
// Import signup components
import LoginHeader from './login/LoginHeader'
import ErrorMessage from './login/ErrorMessage'
import DividerWithText from '../components/shared/DividerWithText'
import SignupForm from './signup/SignupForm'
import { useSignupFormValidation, SignupFieldErrors } from './signup/SignupFormValidation'
import { useAuthService } from './signup/AuthService'
import LoginHeader from './login/LoginHeader';
import ErrorMessage from './login/ErrorMessage';
import DividerWithText from '../components/shared/DividerWithText';
import SignupForm from './signup/SignupForm';
import { useSignupFormValidation, SignupFieldErrors } from './signup/SignupFormValidation';
import { useAuthService } from './signup/AuthService';
export default function Signup() {
const navigate = useNavigate()
const { t } = useTranslation()
const [isSigningUp, setIsSigningUp] = useState(false)
const [error, setError] = useState<string | null>(null)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [fieldErrors, setFieldErrors] = useState<SignupFieldErrors>({})
const navigate = useNavigate();
const { t } = useTranslation();
const [isSigningUp, setIsSigningUp] = useState(false);
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [fieldErrors, setFieldErrors] = useState<SignupFieldErrors>({});
const baseUrl = window.location.origin + BASE_PATH;
@ -34,38 +34,38 @@ export default function Signup() {
ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
ogImage: `${baseUrl}/og_images/home.png`,
ogUrl: `${window.location.origin}${window.location.pathname}`
})
});
const { validateSignupForm } = useSignupFormValidation()
const { signUp } = useAuthService()
const { validateSignupForm } = useSignupFormValidation();
const { signUp } = useAuthService();
const handleSignUp = async () => {
const validation = validateSignupForm(email, password, confirmPassword)
const validation = validateSignupForm(email, password, confirmPassword);
if (!validation.isValid) {
setError(validation.error)
setFieldErrors(validation.fieldErrors || {})
return
setError(validation.error);
setFieldErrors(validation.fieldErrors || {});
return;
}
try {
setIsSigningUp(true)
setError(null)
setFieldErrors({})
setIsSigningUp(true);
setError(null);
setFieldErrors({});
const result = await signUp(email, password, '')
const result = await signUp(email, password, '');
if (result.user) {
// Show success message and redirect to login
setError(null)
setTimeout(() => navigate('/login'), 2000)
setError(null);
setTimeout(() => navigate('/login'), 2000);
}
} catch (err) {
console.error('[Signup] Unexpected error:', err)
setError(err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' }))
console.error('[Signup] Unexpected error:', err);
setError(err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' }));
} finally {
setIsSigningUp(false)
setIsSigningUp(false);
}
}
};
return (
<AuthLayout>
@ -101,5 +101,5 @@ export default function Signup() {
</button>
</div>
</AuthLayout>
)
);
}

View File

@ -1,51 +1,51 @@
import React, { useEffect, useRef, useState } from 'react'
import LoginRightCarousel from '../../components/shared/LoginRightCarousel'
import loginSlides from '../../components/shared/loginSlides'
import styles from './AuthLayout.module.css'
import React, { useEffect, useRef, useState } from 'react';
import LoginRightCarousel from '../../components/shared/LoginRightCarousel';
import loginSlides from '../../components/shared/loginSlides';
import styles from './AuthLayout.module.css';
interface AuthLayoutProps {
children: React.ReactNode
}
export default function AuthLayout({ children }: AuthLayoutProps) {
const cardRef = useRef<HTMLDivElement | null>(null)
const [hideRightPanel, setHideRightPanel] = useState(false)
const cardRef = useRef<HTMLDivElement | null>(null);
const [hideRightPanel, setHideRightPanel] = useState(false);
// Force light mode on auth pages
useEffect(() => {
const htmlElement = document.documentElement
const previousColorScheme = htmlElement.getAttribute('data-mantine-color-scheme')
const htmlElement = document.documentElement;
const previousColorScheme = htmlElement.getAttribute('data-mantine-color-scheme');
// Set light mode
htmlElement.setAttribute('data-mantine-color-scheme', 'light')
htmlElement.setAttribute('data-mantine-color-scheme', 'light');
// Cleanup: restore previous theme when leaving auth pages
return () => {
if (previousColorScheme) {
htmlElement.setAttribute('data-mantine-color-scheme', previousColorScheme)
htmlElement.setAttribute('data-mantine-color-scheme', previousColorScheme);
}
}
}, [])
};
}, []);
useEffect(() => {
const update = () => {
// Use viewport to avoid hysteresis when the card is already in single-column mode
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96) // matches min(73.75rem, 96vw)
const columnWidth = cardWidthIfTwoCols / 2
const tooNarrow = columnWidth < 470
const tooShort = viewportHeight < 740
setHideRightPanel(tooNarrow || tooShort)
}
update()
window.addEventListener('resize', update)
window.addEventListener('orientationchange', update)
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96); // matches min(73.75rem, 96vw)
const columnWidth = cardWidthIfTwoCols / 2;
const tooNarrow = columnWidth < 470;
const tooShort = viewportHeight < 740;
setHideRightPanel(tooNarrow || tooShort);
};
update();
window.addEventListener('resize', update);
window.addEventListener('orientationchange', update);
return () => {
window.removeEventListener('resize', update)
window.removeEventListener('orientationchange', update)
}
}, [])
window.removeEventListener('resize', update);
window.removeEventListener('orientationchange', update);
};
}, []);
return (
<div className={styles.authContainer}>
@ -64,5 +64,5 @@ export default function AuthLayout({ children }: AuthLayoutProps) {
)}
</div>
</div>
)
);
}

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import '../authShared/auth.css'
import { useTranslation } from 'react-i18next';
import '../authShared/auth.css';
interface EmailPasswordFormProps {
email: string
@ -27,12 +27,12 @@ export default function EmailPasswordForm({
showPasswordField = true,
fieldErrors = {}
}: EmailPasswordFormProps) {
const { t } = useTranslation()
const { t } = useTranslation();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit()
}
e.preventDefault();
onSubmit();
};
return (
<form onSubmit={handleSubmit}>
@ -82,5 +82,5 @@ export default function EmailPasswordForm({
{submitButtonText}
</button>
</form>
)
);
}

View File

@ -3,11 +3,11 @@ interface ErrorMessageProps {
}
export default function ErrorMessage({ error }: ErrorMessageProps) {
if (!error) return null
if (!error) return null;
return (
<div className="error-message">
<p className="error-message-text">{error}</p>
</div>
)
);
}

View File

@ -1,20 +1,20 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../../auth/UseSession'
import { useTranslation } from 'react-i18next'
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../auth/UseSession';
import { useTranslation } from 'react-i18next';
export default function LoggedInState() {
const navigate = useNavigate()
const { user } = useAuth()
const { t } = useTranslation()
const navigate = useNavigate();
const { user } = useAuth();
const { t } = useTranslation();
useEffect(() => {
const timer = setTimeout(() => {
navigate('/')
}, 2000)
navigate('/');
}, 2000);
return () => clearTimeout(timer)
}, [navigate])
return () => clearTimeout(timer);
}, [navigate]);
return (
<div style={{
@ -50,5 +50,5 @@ export default function LoggedInState() {
</div>
</div>
</div>
)
);
}

View File

@ -18,5 +18,5 @@ export default function LoginHeader({ title, subtitle }: LoginHeaderProps) {
<p className="login-subtitle">{subtitle}</p>
)}
</div>
)
);
}

View File

@ -15,5 +15,5 @@ export default function NavigationLink({ onClick, text, isDisabled = false }: Na
{text}
</button>
</div>
)
);
}

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { BASE_PATH } from '../../constants/app'
import { useTranslation } from 'react-i18next';
import { BASE_PATH } from '../../constants/app';
// OAuth provider configuration
const oauthProviders = [
@ -7,7 +7,7 @@ const oauthProviders = [
{ id: 'github', label: 'GitHub', file: 'github.svg', isDisabled: false },
{ id: 'apple', label: 'Apple', file: 'apple.svg', isDisabled: true },
{ id: 'azure', label: 'Microsoft', file: 'microsoft.svg', isDisabled: true }
]
];
interface OAuthButtonsProps {
onProviderClick: (provider: 'github' | 'google' | 'apple' | 'azure') => void
@ -16,10 +16,10 @@ interface OAuthButtonsProps {
}
export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical' }: OAuthButtonsProps) {
const { t } = useTranslation()
const { t } = useTranslation();
// Filter out disabled providers - don't show them at all
const enabledProviders = oauthProviders.filter(p => !p.isDisabled)
const enabledProviders = oauthProviders.filter(p => !p.isDisabled);
if (layout === 'icons') {
return (
@ -37,7 +37,7 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
</div>
))}
</div>
)
);
}
if (layout === 'grid') {
@ -56,7 +56,7 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
</div>
))}
</div>
)
);
}
return (
@ -74,5 +74,5 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
</button>
))}
</div>
)
);
}

View File

@ -1,5 +1,5 @@
import { springAuth } from '../../auth/springAuthClient'
import { BASE_PATH } from '../../constants/app'
import { springAuth } from '../../auth/springAuthClient';
import { BASE_PATH } from '../../constants/app';
export const useAuthService = () => {
@ -8,7 +8,7 @@ export const useAuthService = () => {
password: string,
name: string
) => {
console.log('[Signup] Creating account for:', email)
console.log('[Signup] Creating account for:', email);
const { user, session, error } = await springAuth.signUp({
email: email.trim(),
@ -17,38 +17,38 @@ export const useAuthService = () => {
data: { full_name: name },
emailRedirectTo: `${BASE_PATH}/auth/callback`
}
})
});
if (error) {
console.error('[Signup] Sign up error:', error)
throw new Error(error.message)
console.error('[Signup] Sign up error:', error);
throw new Error(error.message);
}
if (user) {
console.log('[Signup] Sign up successful:', user)
console.log('[Signup] Sign up successful:', user);
return {
user: user,
session: session,
requiresEmailConfirmation: user && !session
}
};
}
throw new Error('Unknown error occurred during signup')
}
throw new Error('Unknown error occurred during signup');
};
const signInWithProvider = async (provider: 'github' | 'google' | 'apple' | 'azure') => {
const { error } = await springAuth.signInWithOAuth({
provider,
options: { redirectTo: `${BASE_PATH}/auth/callback` }
})
});
if (error) {
throw new Error(error.message)
throw new Error(error.message);
}
}
};
return {
signUp,
signInWithProvider
}
}
};
};

View File

@ -1,7 +1,7 @@
import { useEffect } from 'react'
import '../authShared/auth.css'
import { useTranslation } from 'react-i18next'
import { SignupFieldErrors } from './SignupFormValidation'
import { useEffect } from 'react';
import '../authShared/auth.css';
import { useTranslation } from 'react-i18next';
import { SignupFieldErrors } from './SignupFormValidation';
interface SignupFormProps {
name?: string
@ -38,14 +38,14 @@ export default function SignupForm({
showName = false,
showTerms = false
}: SignupFormProps) {
const { t } = useTranslation()
const showConfirm = password.length >= 4
const { t } = useTranslation();
const showConfirm = password.length >= 4;
useEffect(() => {
if (!showConfirm && confirmPassword) {
setConfirmPassword('')
setConfirmPassword('');
}
}, [showConfirm, confirmPassword, setConfirmPassword])
}, [showConfirm, confirmPassword, setConfirmPassword]);
return (
<>
@ -158,5 +158,5 @@ export default function SignupForm({
{isSubmitting ? t('signup.creatingAccount') : t('signup.signUp')}
</button>
</>
)
);
}

View File

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from 'react-i18next';
export interface SignupFieldErrors {
name?: string
@ -14,7 +14,7 @@ export interface SignupValidationResult {
}
export const useSignupFormValidation = () => {
const { t } = useTranslation()
const { t } = useTranslation();
const validateSignupForm = (
email: string,
@ -22,45 +22,45 @@ export const useSignupFormValidation = () => {
confirmPassword: string,
name?: string
): SignupValidationResult => {
const fieldErrors: SignupFieldErrors = {}
const fieldErrors: SignupFieldErrors = {};
// Validate name
if (name !== undefined && name !== null && !name.trim()) {
fieldErrors.name = t('signup.nameRequired', 'Name is required')
fieldErrors.name = t('signup.nameRequired', 'Name is required');
}
// Validate email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!email) {
fieldErrors.email = t('signup.emailRequired', 'Email is required')
fieldErrors.email = t('signup.emailRequired', 'Email is required');
} else if (!emailRegex.test(email)) {
fieldErrors.email = t('signup.invalidEmail')
fieldErrors.email = t('signup.invalidEmail');
}
// Validate password
if (!password) {
fieldErrors.password = t('signup.passwordRequired', 'Password is required')
fieldErrors.password = t('signup.passwordRequired', 'Password is required');
} else if (password.length < 6) {
fieldErrors.password = t('signup.passwordTooShort')
fieldErrors.password = t('signup.passwordTooShort');
}
// Validate confirm password
if (!confirmPassword) {
fieldErrors.confirmPassword = t('signup.confirmPasswordRequired', 'Please confirm your password')
fieldErrors.confirmPassword = t('signup.confirmPasswordRequired', 'Please confirm your password');
} else if (password !== confirmPassword) {
fieldErrors.confirmPassword = t('signup.passwordsDoNotMatch')
fieldErrors.confirmPassword = t('signup.passwordsDoNotMatch');
}
const hasErrors = Object.keys(fieldErrors).length > 0
const hasErrors = Object.keys(fieldErrors).length > 0;
return {
isValid: !hasErrors,
error: null, // Don't show generic error, field errors are more specific
fieldErrors: hasErrors ? fieldErrors : undefined
}
}
};
};
return {
validateSignupForm
}
}
};
};

View File

@ -3,8 +3,9 @@
export const Z_INDEX_FULLSCREEN_SURFACE = 1000;
export const Z_INDEX_OVER_FULLSCREEN_SURFACE = 1300;
export const Z_ANALYTICS_MODAL = 1301;
export const Z_INDEX_FILE_MANAGER_MODAL = 1200;
export const Z_INDEX_FILE_MANAGER_MODAL = 1200;
export const Z_INDEX_OVER_FILE_MANAGER_MODAL = 1300;
export const Z_INDEX_AUTOMATE_MODAL = 1100;

View File

@ -3,7 +3,7 @@ import path from 'path';
import ts from 'typescript';
import { describe, expect, test } from 'vitest';
const REPO_ROOT = path.join(__dirname, '../../../')
const REPO_ROOT = path.join(__dirname, '../../../');
const SRC_ROOT = path.join(__dirname, '..');
const EN_GB_FILE = path.join(__dirname, '../../public/locales/en-GB/translation.json');

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
import React, { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { createToolFlow } from '../components/tools/shared/createToolFlow';
import { BaseToolProps, ToolComponent } from '../types/tool';
import { useBaseTool } from '../hooks/tools/shared/useBaseTool';

View File

@ -1,14 +1,73 @@
/**
* Scarf analytics pixel tracking utility
*
* This module provides a firePixel function that can be called from anywhere,
* including non-React utility functions. Configuration and consent state are
* injected via setScarfConfig() which should be called from a React hook
* during app initialization.
*
* IMPORTANT: setScarfConfig() must be called before firePixel() will work.
* The initialization hook (useScarfTracking) is mounted in App.tsx.
*
* For testing: Use resetScarfConfig() to clear module state between tests.
*/
// Module-level state
let configured: boolean = false;
let enableScarf: boolean | null = null;
let isServiceAccepted: ((service: string, category: string) => boolean) | null = null;
let lastFiredPathname: string | null = null;
let lastFiredTime = 0;
/**
* Configure scarf tracking with app config and consent checker
* Should be called from a React hook during app initialization (see useScarfTracking)
*
* @param scarfEnabled - Whether scarf tracking is enabled globally
* @param consentChecker - Function to check if user has accepted scarf service
*/
export function setScarfConfig(
scarfEnabled: boolean | null,
consentChecker: (service: string, category: string) => boolean
): void {
configured = true;
enableScarf = scarfEnabled;
isServiceAccepted = consentChecker;
}
/**
* Fire scarf pixel for analytics tracking
* Only fires if pathname is different from last call or enough time has passed
* Only fires if:
* - Scarf tracking has been initialized via setScarfConfig()
* - Scarf is globally enabled in config
* - User has accepted scarf service via cookie consent
* - Pathname has changed or enough time has passed since last fire
*
* @param pathname - The pathname to track (usually window.location.pathname)
*/
export function firePixel(pathname: string): void {
// Dev-mode warning if called before initialization
if (!configured) {
console.warn(
'[scarfTracking] firePixel() called before setScarfConfig(). ' +
'Ensure useScarfTracking() hook is mounted in App.tsx.'
);
return;
}
// Check if Scarf is globally disabled
if (enableScarf === false) {
return;
}
// Check if consent checker is available and scarf service is accepted
if (!isServiceAccepted || !isServiceAccepted('scarf', 'analytics')) {
return;
}
const now = Date.now();
// Only fire if pathname changed or it's been at least 1 second since last fire
// Only fire if pathname changed or it's been at least 250ms since last fire
if (pathname === lastFiredPathname && now - lastFiredTime < 250) {
return;
}
@ -24,3 +83,13 @@ export function firePixel(pathname: string): void {
img.src = url;
}
/**
* Reset scarf tracking configuration and state
* Useful for testing to ensure clean state between test runs
*/
export function resetScarfConfig(): void {
enableScarf = null;
isServiceAccepted = null;
lastFiredPathname = null;
lastFiredTime = 0;
}