diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3bd1988c2..03f684c4f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,10 +31,14 @@ jobs: project: ${{ steps.changes.outputs.project }} openapi: ${{ steps.changes.outputs.openapi }} steps: + - 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 @@ -89,7 +93,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: | @@ -118,17 +122,15 @@ jobs: with: java-version: "17" distribution: "temurin" - - name: Setup Gradle 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 @@ -137,19 +139,21 @@ 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 run: cd frontend && npm ci + - name: Type-check frontend + run: cd frontend && npm run prebuild && npm run typecheck:all - name: Lint frontend run: cd frontend && npm run lint - name: Build frontend @@ -157,7 +161,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/ @@ -174,7 +178,6 @@ jobs: egress-policy: audit - name: Checkout repository uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - name: Set up JDK 17 uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0 with: @@ -186,7 +189,7 @@ jobs: - 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 @@ -220,7 +223,6 @@ jobs: - name: Checkout Repository uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - name: Set up Java 17 uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0 with: @@ -232,7 +234,7 @@ 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 diff --git a/README.md b/README.md index 250f71abb..f8058d906 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java index 3bcc48715..272c0b35c 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java @@ -74,8 +74,7 @@ public class AppConfig { @Bean(name = "appName") public String appName() { - String homeTitle = applicationProperties.getUi().getAppName(); - return (homeTitle != null) ? homeTitle : "Stirling PDF"; + return "Stirling PDF"; } @Bean(name = "appVersion") @@ -93,9 +92,7 @@ public class AppConfig { @Bean(name = "homeText") public String homeText() { - return (applicationProperties.getUi().getHomeDescription() != null) - ? applicationProperties.getUi().getHomeDescription() - : "null"; + return "null"; } @Bean(name = "languages") @@ -110,11 +107,8 @@ public class AppConfig { @Bean(name = "navBarText") public String navBarText() { - String defaultNavBar = - applicationProperties.getUi().getAppNameNavbar() != null - ? applicationProperties.getUi().getAppNameNavbar() - : applicationProperties.getUi().getAppName(); - return (defaultNavBar != null) ? defaultNavBar : "Stirling PDF"; + String navBar = applicationProperties.getUi().getAppNameNavbar(); + return (navBar != null) ? navBar : "Stirling PDF"; } @Bean(name = "enableAlphaFunctionality") diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 72af6dea5..44f8a0ee3 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -121,6 +121,7 @@ public class ApplicationProperties { private String loginMethod = "all"; private String customGlobalAPIKey; private Jwt jwt = new Jwt(); + private Validation validation = new Validation(); public Boolean isAltLogin() { return saml2.getEnabled() || oauth2.getEnabled(); @@ -307,7 +308,41 @@ public class ApplicationProperties { private boolean enableKeyRotation = false; private boolean enableKeyCleanup = true; private int keyRetentionDays = 7; - private boolean secureCookie; + } + + @Data + public static class Validation { + private Trust trust = new Trust(); + private boolean allowAIA = false; + private Aatl aatl = new Aatl(); + private Eutl eutl = new Eutl(); + private Revocation revocation = new Revocation(); + + @Data + public static class Trust { + private boolean serverAsAnchor = true; + private boolean useSystemTrust = false; + private boolean useMozillaBundle = false; + private boolean useAATL = false; + private boolean useEUTL = false; + } + + @Data + public static class Aatl { + private String url = "https://trustlist.adobe.com/tl.pdf"; + } + + @Data + public static class Eutl { + private String lotlUrl = "https://ec.europa.eu/tools/lotl/eu-lotl.xml"; + private boolean acceptTransitional = false; + } + + @Data + public static class Revocation { + private String mode = "none"; + private boolean hardFail = false; + } } } @@ -321,6 +356,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; @@ -330,10 +367,23 @@ public class ApplicationProperties { private String fileUploadLimit; private TempFileManagement tempFileManagement = new TempFileManagement(); private DatabaseBackup databaseBackup = new DatabaseBackup(); + private List corsAllowedOrigins = new ArrayList<>(); 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 @@ -449,21 +499,9 @@ public class ApplicationProperties { @Data public static class Ui { - private String appName; - private String homeDescription; private String appNameNavbar; private List languages; - public String getAppName() { - return appName != null && !appName.trim().isEmpty() ? appName : null; - } - - public String getHomeDescription() { - return homeDescription != null && !homeDescription.trim().isEmpty() - ? homeDescription - : null; - } - public String getAppNameNavbar() { return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null; } @@ -517,6 +555,7 @@ public class ApplicationProperties { @Data public static class Mail { private boolean enabled; + private boolean enableInvites = false; private String host; private int port; private String username; diff --git a/app/common/src/main/java/stirling/software/common/service/PostHogService.java b/app/common/src/main/java/stirling/software/common/service/PostHogService.java index 2bc219832..310fc43ab 100644 --- a/app/common/src/main/java/stirling/software/common/service/PostHogService.java +++ b/app/common/src/main/java/stirling/software/common/service/PostHogService.java @@ -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 properties) { - if (!applicationProperties.getSystem().isAnalyticsEnabled()) { + if (!applicationProperties.getSystem().isPosthogEnabled()) { return; } @@ -325,13 +325,16 @@ public class PostHogService { properties, "system_enableAnalytics", applicationProperties.getSystem().isAnalyticsEnabled()); - - // Capture UI properties - addIfNotEmpty(properties, "ui_appName", applicationProperties.getUi().getAppName()); addIfNotEmpty( properties, - "ui_homeDescription", - applicationProperties.getUi().getHomeDescription()); + "system_enablePosthog", + applicationProperties.getSystem().isPosthogEnabled()); + addIfNotEmpty( + properties, + "system_enableScarf", + applicationProperties.getSystem().isScarfEnabled()); + + // Capture UI properties addIfNotEmpty( properties, "ui_appNameNavbar", applicationProperties.getUi().getAppNameNavbar()); diff --git a/app/common/src/main/java/stirling/software/common/service/UserServiceInterface.java b/app/common/src/main/java/stirling/software/common/service/UserServiceInterface.java index d4cc25dc0..a833d4c84 100644 --- a/app/common/src/main/java/stirling/software/common/service/UserServiceInterface.java +++ b/app/common/src/main/java/stirling/software/common/service/UserServiceInterface.java @@ -6,4 +6,6 @@ public interface UserServiceInterface { String getCurrentUsername(); long getTotalUsersCount(); + + boolean isCurrentUserAdmin(); } diff --git a/app/common/src/main/java/stirling/software/common/util/AppArgsCapture.java b/app/common/src/main/java/stirling/software/common/util/AppArgsCapture.java new file mode 100644 index 000000000..691785187 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/util/AppArgsCapture.java @@ -0,0 +1,29 @@ +package stirling.software.common.util; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +/** + * Captures application command-line arguments at startup so they can be reused for restart + * operations. This allows the application to restart with the same configuration. + */ +@Slf4j +@Component +public class AppArgsCapture implements ApplicationRunner { + + public static final AtomicReference> APP_ARGS = new AtomicReference<>(List.of()); + + @Override + public void run(ApplicationArguments args) { + APP_ARGS.set(List.of(args.getSourceArgs())); + log.debug( + "Captured {} application arguments for restart capability", + args.getSourceArgs().length); + } +} diff --git a/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java b/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java new file mode 100644 index 000000000..738cde8e6 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java @@ -0,0 +1,84 @@ +package stirling.software.common.util; + +import java.io.File; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import lombok.extern.slf4j.Slf4j; + +/** Utility class to locate JAR files at runtime for restart operations */ +@Slf4j +public class JarPathUtil { + + /** + * Gets the path to the currently running JAR file + * + * @return Path to the current JAR, or null if not running from a JAR + */ + public static Path currentJar() { + try { + Path jar = + Paths.get( + JarPathUtil.class + .getProtectionDomain() + .getCodeSource() + .getLocation() + .toURI()) + .toAbsolutePath(); + + // Check if we're actually running from a JAR (not from IDE/classes directory) + if (jar.toString().endsWith(".jar")) { + log.debug("Current JAR located at: {}", jar); + return jar; + } else { + log.warn("Not running from JAR, current location: {}", jar); + return null; + } + } catch (URISyntaxException e) { + log.error("Failed to determine current JAR location", e); + return null; + } + } + + /** + * Gets the path to the restart-helper.jar file Expected to be in the same directory as the main + * JAR + * + * @return Path to restart-helper.jar, or null if not found + */ + public static Path restartHelperJar() { + Path appJar = currentJar(); + if (appJar == null) { + return null; + } + + Path helperJar = appJar.getParent().resolve("restart-helper.jar"); + + if (Files.isRegularFile(helperJar)) { + log.debug("Restart helper JAR located at: {}", helperJar); + return helperJar; + } else { + log.warn("Restart helper JAR not found at: {}", helperJar); + return null; + } + } + + /** + * Gets the java binary path for the current JVM + * + * @return Path to java executable + */ + public static String javaExecutable() { + String javaHome = System.getProperty("java.home"); + String javaBin = javaHome + File.separator + "bin" + File.separator + "java"; + + // On Windows, add .exe extension + if (System.getProperty("os.name").toLowerCase().contains("win")) { + javaBin += ".exe"; + } + + return javaBin; + } +} diff --git a/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java index afd89489e..f075f1518 100644 --- a/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java +++ b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java @@ -115,19 +115,11 @@ class ApplicationPropertiesLogicTest { @Test void ui_getters_return_null_for_blank() { ApplicationProperties.Ui ui = new ApplicationProperties.Ui(); - ui.setAppName(" "); - ui.setHomeDescription(""); ui.setAppNameNavbar(null); - assertNull(ui.getAppName()); - assertNull(ui.getHomeDescription()); assertNull(ui.getAppNameNavbar()); - ui.setAppName("Stirling-PDF"); - ui.setHomeDescription("Home"); ui.setAppNameNavbar("Nav"); - assertEquals("Stirling-PDF", ui.getAppName()); - assertEquals("Home", ui.getHomeDescription()); assertEquals("Nav", ui.getAppNameNavbar()); } diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index dab11c697..4dba70300 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -1,22 +1,49 @@ package stirling.software.SPDF.config; import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import lombok.RequiredArgsConstructor; +import stirling.software.common.model.ApplicationProperties; + @Configuration @RequiredArgsConstructor public class WebMvcConfig implements WebMvcConfigurer { private final EndpointInterceptor endpointInterceptor; + private final ApplicationProperties applicationProperties; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(endpointInterceptor); } + @Override + public void addCorsMappings(CorsRegistry registry) { + // Only configure CORS if allowed origins are specified + if (applicationProperties.getSystem() != null + && applicationProperties.getSystem().getCorsAllowedOrigins() != null + && !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) { + + String[] allowedOrigins = + applicationProperties + .getSystem() + .getCorsAllowedOrigins() + .toArray(new String[0]); + + registry.addMapping("/**") + .allowedOrigins(allowedOrigins) + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } + // If no origins are configured, CORS is not enabled (secure by default) + } + // @Override // public void addResourceHandlers(ResourceHandlerRegistry registry) { // // Handler for external static resources - DISABLED in backend-only mode diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java index 3c2e6f33a..9657d8f15 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java @@ -1,12 +1,15 @@ package stirling.software.SPDF.controller.api; import java.io.IOException; +import java.util.HashMap; 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.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import io.swagger.v3.oas.annotations.Hidden; @@ -29,7 +32,7 @@ public class SettingsController { @AutoJobPostMapping("/update-enable-analytics") @Hidden - public ResponseEntity updateApiKey(@RequestBody Boolean enabled) throws IOException { + public ResponseEntity updateApiKey(@RequestParam Boolean enabled) throws IOException { if (applicationProperties.getSystem().getEnableAnalytics() != null) { return ResponseEntity.status(HttpStatus.ALREADY_REPORTED) .body( @@ -46,4 +49,392 @@ public class SettingsController { public ResponseEntity> getDisabledEndpoints() { return ResponseEntity.ok(endpointConfiguration.getEndpointStatuses()); } + + // ========== GENERAL SETTINGS ========== + + @GetMapping("/admin/settings/general") + @Hidden + public ResponseEntity> getGeneralSettings() { + Map settings = new HashMap<>(); + settings.put("ui", applicationProperties.getUi()); + settings.put( + "system", + Map.of( + "defaultLocale", applicationProperties.getSystem().getDefaultLocale(), + "showUpdate", applicationProperties.getSystem().isShowUpdate(), + "showUpdateOnlyAdmin", + applicationProperties.getSystem().getShowUpdateOnlyAdmin(), + "customHTMLFiles", applicationProperties.getSystem().isCustomHTMLFiles(), + "fileUploadLimit", applicationProperties.getSystem().getFileUploadLimit())); + return ResponseEntity.ok(settings); + } + + @PostMapping("/admin/settings/general") + @Hidden + public ResponseEntity updateGeneralSettings(@RequestBody Map settings) + throws IOException { + // Update UI settings + if (settings.containsKey("ui")) { + Map ui = (Map) settings.get("ui"); + if (ui.containsKey("appNameNavbar")) { + GeneralUtils.saveKeyToSettings("ui.appNameNavbar", ui.get("appNameNavbar")); + applicationProperties.getUi().setAppNameNavbar(ui.get("appNameNavbar")); + } + } + + // Update System settings + if (settings.containsKey("system")) { + Map system = (Map) settings.get("system"); + if (system.containsKey("defaultLocale")) { + GeneralUtils.saveKeyToSettings("system.defaultLocale", system.get("defaultLocale")); + applicationProperties + .getSystem() + .setDefaultLocale((String) system.get("defaultLocale")); + } + if (system.containsKey("showUpdate")) { + GeneralUtils.saveKeyToSettings("system.showUpdate", system.get("showUpdate")); + applicationProperties.getSystem().setShowUpdate((Boolean) system.get("showUpdate")); + } + if (system.containsKey("showUpdateOnlyAdmin")) { + GeneralUtils.saveKeyToSettings( + "system.showUpdateOnlyAdmin", system.get("showUpdateOnlyAdmin")); + applicationProperties + .getSystem() + .setShowUpdateOnlyAdmin((Boolean) system.get("showUpdateOnlyAdmin")); + } + if (system.containsKey("fileUploadLimit")) { + GeneralUtils.saveKeyToSettings( + "system.fileUploadLimit", system.get("fileUploadLimit")); + applicationProperties + .getSystem() + .setFileUploadLimit((String) system.get("fileUploadLimit")); + } + } + + return ResponseEntity.ok( + "General settings updated. Restart required for changes to take effect."); + } + + // ========== SECURITY SETTINGS ========== + + @GetMapping("/admin/settings/security") + @Hidden + public ResponseEntity> getSecuritySettings() { + Map settings = new HashMap<>(); + ApplicationProperties.Security security = applicationProperties.getSecurity(); + + settings.put("enableLogin", security.getEnableLogin()); + settings.put("csrfDisabled", security.getCsrfDisabled()); + settings.put("loginMethod", security.getLoginMethod()); + settings.put("loginAttemptCount", security.getLoginAttemptCount()); + settings.put("loginResetTimeMinutes", security.getLoginResetTimeMinutes()); + settings.put( + "initialLogin", + Map.of( + "username", + security.getInitialLogin().getUsername() != null + ? security.getInitialLogin().getUsername() + : "")); + + // JWT settings + ApplicationProperties.Security.Jwt jwt = security.getJwt(); + settings.put( + "jwt", + Map.of( + "enableKeystore", jwt.isEnableKeystore(), + "enableKeyRotation", jwt.isEnableKeyRotation(), + "enableKeyCleanup", jwt.isEnableKeyCleanup(), + "keyRetentionDays", jwt.getKeyRetentionDays())); + + return ResponseEntity.ok(settings); + } + + @PostMapping("/admin/settings/security") + @Hidden + public ResponseEntity updateSecuritySettings(@RequestBody Map settings) + throws IOException { + if (settings.containsKey("enableLogin")) { + GeneralUtils.saveKeyToSettings("security.enableLogin", settings.get("enableLogin")); + applicationProperties + .getSecurity() + .setEnableLogin((Boolean) settings.get("enableLogin")); + } + if (settings.containsKey("csrfDisabled")) { + GeneralUtils.saveKeyToSettings("security.csrfDisabled", settings.get("csrfDisabled")); + applicationProperties + .getSecurity() + .setCsrfDisabled((Boolean) settings.get("csrfDisabled")); + } + if (settings.containsKey("loginMethod")) { + GeneralUtils.saveKeyToSettings("security.loginMethod", settings.get("loginMethod")); + applicationProperties + .getSecurity() + .setLoginMethod((String) settings.get("loginMethod")); + } + if (settings.containsKey("loginAttemptCount")) { + GeneralUtils.saveKeyToSettings( + "security.loginAttemptCount", settings.get("loginAttemptCount")); + applicationProperties + .getSecurity() + .setLoginAttemptCount((Integer) settings.get("loginAttemptCount")); + } + if (settings.containsKey("loginResetTimeMinutes")) { + GeneralUtils.saveKeyToSettings( + "security.loginResetTimeMinutes", settings.get("loginResetTimeMinutes")); + applicationProperties + .getSecurity() + .setLoginResetTimeMinutes( + ((Number) settings.get("loginResetTimeMinutes")).longValue()); + } + + // JWT settings + if (settings.containsKey("jwt")) { + Map jwt = (Map) settings.get("jwt"); + if (jwt.containsKey("keyRetentionDays")) { + GeneralUtils.saveKeyToSettings( + "security.jwt.keyRetentionDays", jwt.get("keyRetentionDays")); + applicationProperties + .getSecurity() + .getJwt() + .setKeyRetentionDays((Integer) jwt.get("keyRetentionDays")); + } + } + + return ResponseEntity.ok( + "Security settings updated. Restart required for changes to take effect."); + } + + // ========== CONNECTIONS SETTINGS (OAuth/SAML) ========== + + @GetMapping("/admin/settings/connections") + @Hidden + public ResponseEntity> getConnectionsSettings() { + Map settings = new HashMap<>(); + ApplicationProperties.Security security = applicationProperties.getSecurity(); + + // OAuth2 settings + ApplicationProperties.Security.OAUTH2 oauth2 = security.getOauth2(); + settings.put( + "oauth2", + Map.of( + "enabled", oauth2.getEnabled(), + "issuer", oauth2.getIssuer() != null ? oauth2.getIssuer() : "", + "clientId", oauth2.getClientId() != null ? oauth2.getClientId() : "", + "provider", oauth2.getProvider() != null ? oauth2.getProvider() : "", + "autoCreateUser", oauth2.getAutoCreateUser(), + "blockRegistration", oauth2.getBlockRegistration(), + "useAsUsername", + oauth2.getUseAsUsername() != null + ? oauth2.getUseAsUsername() + : "")); + + // SAML2 settings + ApplicationProperties.Security.SAML2 saml2 = security.getSaml2(); + settings.put( + "saml2", + Map.of( + "enabled", saml2.getEnabled(), + "provider", saml2.getProvider() != null ? saml2.getProvider() : "", + "autoCreateUser", saml2.getAutoCreateUser(), + "blockRegistration", saml2.getBlockRegistration(), + "registrationId", saml2.getRegistrationId())); + + return ResponseEntity.ok(settings); + } + + @PostMapping("/admin/settings/connections") + @Hidden + public ResponseEntity updateConnectionsSettings( + @RequestBody Map settings) throws IOException { + // OAuth2 settings + if (settings.containsKey("oauth2")) { + Map oauth2 = (Map) settings.get("oauth2"); + if (oauth2.containsKey("enabled")) { + GeneralUtils.saveKeyToSettings("security.oauth2.enabled", oauth2.get("enabled")); + applicationProperties + .getSecurity() + .getOauth2() + .setEnabled((Boolean) oauth2.get("enabled")); + } + if (oauth2.containsKey("issuer")) { + GeneralUtils.saveKeyToSettings("security.oauth2.issuer", oauth2.get("issuer")); + applicationProperties + .getSecurity() + .getOauth2() + .setIssuer((String) oauth2.get("issuer")); + } + if (oauth2.containsKey("clientId")) { + GeneralUtils.saveKeyToSettings("security.oauth2.clientId", oauth2.get("clientId")); + applicationProperties + .getSecurity() + .getOauth2() + .setClientId((String) oauth2.get("clientId")); + } + if (oauth2.containsKey("clientSecret")) { + GeneralUtils.saveKeyToSettings( + "security.oauth2.clientSecret", oauth2.get("clientSecret")); + applicationProperties + .getSecurity() + .getOauth2() + .setClientSecret((String) oauth2.get("clientSecret")); + } + if (oauth2.containsKey("provider")) { + GeneralUtils.saveKeyToSettings("security.oauth2.provider", oauth2.get("provider")); + applicationProperties + .getSecurity() + .getOauth2() + .setProvider((String) oauth2.get("provider")); + } + if (oauth2.containsKey("autoCreateUser")) { + GeneralUtils.saveKeyToSettings( + "security.oauth2.autoCreateUser", oauth2.get("autoCreateUser")); + applicationProperties + .getSecurity() + .getOauth2() + .setAutoCreateUser((Boolean) oauth2.get("autoCreateUser")); + } + if (oauth2.containsKey("blockRegistration")) { + GeneralUtils.saveKeyToSettings( + "security.oauth2.blockRegistration", oauth2.get("blockRegistration")); + applicationProperties + .getSecurity() + .getOauth2() + .setBlockRegistration((Boolean) oauth2.get("blockRegistration")); + } + if (oauth2.containsKey("useAsUsername")) { + GeneralUtils.saveKeyToSettings( + "security.oauth2.useAsUsername", oauth2.get("useAsUsername")); + applicationProperties + .getSecurity() + .getOauth2() + .setUseAsUsername((String) oauth2.get("useAsUsername")); + } + } + + // SAML2 settings + if (settings.containsKey("saml2")) { + Map saml2 = (Map) settings.get("saml2"); + if (saml2.containsKey("enabled")) { + GeneralUtils.saveKeyToSettings("security.saml2.enabled", saml2.get("enabled")); + applicationProperties + .getSecurity() + .getSaml2() + .setEnabled((Boolean) saml2.get("enabled")); + } + if (saml2.containsKey("provider")) { + GeneralUtils.saveKeyToSettings("security.saml2.provider", saml2.get("provider")); + applicationProperties + .getSecurity() + .getSaml2() + .setProvider((String) saml2.get("provider")); + } + if (saml2.containsKey("autoCreateUser")) { + GeneralUtils.saveKeyToSettings( + "security.saml2.autoCreateUser", saml2.get("autoCreateUser")); + applicationProperties + .getSecurity() + .getSaml2() + .setAutoCreateUser((Boolean) saml2.get("autoCreateUser")); + } + if (saml2.containsKey("blockRegistration")) { + GeneralUtils.saveKeyToSettings( + "security.saml2.blockRegistration", saml2.get("blockRegistration")); + applicationProperties + .getSecurity() + .getSaml2() + .setBlockRegistration((Boolean) saml2.get("blockRegistration")); + } + } + + return ResponseEntity.ok( + "Connection settings updated. Restart required for changes to take effect."); + } + + // ========== PRIVACY SETTINGS ========== + + @GetMapping("/admin/settings/privacy") + @Hidden + public ResponseEntity> getPrivacySettings() { + Map settings = new HashMap<>(); + + settings.put("enableAnalytics", applicationProperties.getSystem().getEnableAnalytics()); + settings.put("googleVisibility", applicationProperties.getSystem().getGooglevisibility()); + settings.put("metricsEnabled", applicationProperties.getMetrics().getEnabled()); + + return ResponseEntity.ok(settings); + } + + @PostMapping("/admin/settings/privacy") + @Hidden + public ResponseEntity updatePrivacySettings(@RequestBody Map settings) + throws IOException { + if (settings.containsKey("enableAnalytics")) { + GeneralUtils.saveKeyToSettings( + "system.enableAnalytics", settings.get("enableAnalytics")); + applicationProperties + .getSystem() + .setEnableAnalytics((Boolean) settings.get("enableAnalytics")); + } + if (settings.containsKey("googleVisibility")) { + GeneralUtils.saveKeyToSettings( + "system.googlevisibility", settings.get("googleVisibility")); + applicationProperties + .getSystem() + .setGooglevisibility((Boolean) settings.get("googleVisibility")); + } + if (settings.containsKey("metricsEnabled")) { + GeneralUtils.saveKeyToSettings("metrics.enabled", settings.get("metricsEnabled")); + applicationProperties.getMetrics().setEnabled((Boolean) settings.get("metricsEnabled")); + } + + return ResponseEntity.ok( + "Privacy settings updated. Restart required for changes to take effect."); + } + + // ========== ADVANCED SETTINGS ========== + + @GetMapping("/admin/settings/advanced") + @Hidden + public ResponseEntity> getAdvancedSettings() { + Map settings = new HashMap<>(); + + settings.put("endpoints", applicationProperties.getEndpoints()); + settings.put( + "enableAlphaFunctionality", + applicationProperties.getSystem().getEnableAlphaFunctionality()); + settings.put("maxDPI", applicationProperties.getSystem().getMaxDPI()); + settings.put("enableUrlToPDF", applicationProperties.getSystem().getEnableUrlToPDF()); + settings.put("customPaths", applicationProperties.getSystem().getCustomPaths()); + settings.put( + "tempFileManagement", applicationProperties.getSystem().getTempFileManagement()); + + return ResponseEntity.ok(settings); + } + + @PostMapping("/admin/settings/advanced") + @Hidden + public ResponseEntity updateAdvancedSettings(@RequestBody Map settings) + throws IOException { + if (settings.containsKey("enableAlphaFunctionality")) { + GeneralUtils.saveKeyToSettings( + "system.enableAlphaFunctionality", settings.get("enableAlphaFunctionality")); + applicationProperties + .getSystem() + .setEnableAlphaFunctionality( + (Boolean) settings.get("enableAlphaFunctionality")); + } + if (settings.containsKey("maxDPI")) { + GeneralUtils.saveKeyToSettings("system.maxDPI", settings.get("maxDPI")); + applicationProperties.getSystem().setMaxDPI((Integer) settings.get("maxDPI")); + } + if (settings.containsKey("enableUrlToPDF")) { + GeneralUtils.saveKeyToSettings("system.enableUrlToPDF", settings.get("enableUrlToPDF")); + applicationProperties + .getSystem() + .setEnableUrlToPDF((Boolean) settings.get("enableUrlToPDF")); + } + + return ResponseEntity.ok( + "Advanced settings updated. Restart required for changes to take effect."); + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 072471e5c..3fc6e3e02 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -15,6 +15,7 @@ import stirling.software.common.annotations.api.ConfigApi; import stirling.software.common.configuration.AppConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.ServerCertificateServiceInterface; +import stirling.software.common.service.UserServiceInterface; @ConfigApi @Hidden @@ -24,17 +25,21 @@ public class ConfigController { private final ApplicationContext applicationContext; private final EndpointConfiguration endpointConfiguration; private final ServerCertificateServiceInterface serverCertificateService; + private final UserServiceInterface userService; public ConfigController( ApplicationProperties applicationProperties, ApplicationContext applicationContext, EndpointConfiguration endpointConfiguration, @org.springframework.beans.factory.annotation.Autowired(required = false) - ServerCertificateServiceInterface serverCertificateService) { + ServerCertificateServiceInterface serverCertificateService, + @org.springframework.beans.factory.annotation.Autowired(required = false) + UserServiceInterface userService) { this.applicationProperties = applicationProperties; this.applicationContext = applicationContext; this.endpointConfiguration = endpointConfiguration; this.serverCertificateService = serverCertificateService; + this.userService = userService; } @GetMapping("/app-config") @@ -51,20 +56,34 @@ public class ConfigController { configData.put("serverPort", appConfig.getServerPort()); // Extract values from ApplicationProperties - configData.put("appName", applicationProperties.getUi().getAppName()); configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar()); - configData.put("homeDescription", applicationProperties.getUi().getHomeDescription()); configData.put("languages", applicationProperties.getUi().getLanguages()); // Security settings configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin()); + // Mail settings + configData.put("enableEmailInvites", applicationProperties.getMail().isEnableInvites()); + + // Check if user is admin using UserServiceInterface + boolean isAdmin = false; + if (userService != null) { + try { + isAdmin = userService.isCurrentUserAdmin(); + } catch (Exception e) { + // If there's an error, isAdmin remains false + } + } + configData.put("isAdmin", isAdmin); + // System settings configData.put( "enableAlphaFunctionality", 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()); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java index a768dcaf6..d7361cb7e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ValidateSignatureController.java @@ -5,10 +5,12 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; +import java.security.cert.PKIXCertPathBuilderResult; import java.security.cert.X509Certificate; import java.security.interfaces.RSAPublicKey; -import java.time.Instant; import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; import java.util.List; import org.apache.pdfbox.pdmodel.PDDocument; @@ -32,6 +34,7 @@ import org.springframework.web.multipart.MultipartFile; import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.swagger.JsonDataResponse; import stirling.software.SPDF.model.api.security.SignatureValidationRequest; @@ -42,6 +45,7 @@ import stirling.software.common.annotations.api.SecurityApi; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; +@Slf4j @SecurityApi @RequiredArgsConstructor public class ValidateSignatureController { @@ -65,8 +69,9 @@ public class ValidateSignatureController { @Operation( summary = "Validate PDF Digital Signature", description = - "Validates the digital signatures in a PDF file against default or custom" - + " certificates. Input:PDF Output:JSON Type:SISO") + "Validates the digital signatures in a PDF file using PKIX path building" + + " and time-of-signing semantics. Supports custom trust anchors." + + " Input:PDF Output:JSON Type:SISO") @AutoJobPostMapping( value = "/validate-signature", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -74,12 +79,12 @@ public class ValidateSignatureController { @ModelAttribute SignatureValidationRequest request) throws IOException { List results = new ArrayList<>(); MultipartFile file = request.getFileInput(); - MultipartFile certFile = request.getCertFile(); // Load custom certificate if provided X509Certificate customCert = null; - if (certFile != null && !certFile.isEmpty()) { - try (ByteArrayInputStream certStream = new ByteArrayInputStream(certFile.getBytes())) { + if (request.getCertFile() != null && !request.getCertFile().isEmpty()) { + try (ByteArrayInputStream certStream = + new ByteArrayInputStream(request.getCertFile().getBytes())) { CertificateFactory cf = CertificateFactory.getInstance("X.509"); customCert = (X509Certificate) cf.generateCertificate(certStream); } catch (CertificateException e) { @@ -108,68 +113,150 @@ public class ValidateSignatureController { Store certStore = signedData.getCertificates(); SignerInformationStore signerStore = signedData.getSignerInfos(); - for (SignerInformation signer : signerStore.getSigners()) { + for (SignerInformation signerInfo : signerStore.getSigners()) { X509CertificateHolder certHolder = (X509CertificateHolder) - certStore.getMatches(signer.getSID()).iterator().next(); - X509Certificate cert = + certStore.getMatches(signerInfo.getSID()).iterator().next(); + X509Certificate signerCert = new JcaX509CertificateConverter().getCertificate(certHolder); - boolean isValid = - signer.verify(new JcaSimpleSignerInfoVerifierBuilder().build(cert)); - result.setValid(isValid); + // Extract intermediate certificates from CMS + Collection intermediates = + certValidationService.extractIntermediateCertificates( + certStore, signerCert); - // Additional validations - result.setChainValid( - customCert != null - ? certValidationService - .validateCertificateChainWithCustomCert( - cert, customCert) - : certValidationService.validateCertificateChain(cert)); + // Log what we found + log.debug( + "Found {} intermediate certificates in CMS signature", + intermediates.size()); + for (X509Certificate inter : intermediates) { + log.debug( + " → Intermediate: {}", + inter.getSubjectX500Principal().getName()); + log.debug( + " Issuer DN: {}", inter.getIssuerX500Principal().getName()); + } - result.setTrustValid( - customCert != null - ? certValidationService.validateTrustWithCustomCert( - cert, customCert) - : certValidationService.validateTrustStore(cert)); + // Determine validation time (TSA timestamp or signingTime, or current) + CertificateValidationService.ValidationTime validationTimeResult = + certValidationService.extractValidationTime(signerInfo); + Date validationTime; + if (validationTimeResult == null) { + validationTime = new Date(); + result.setValidationTimeSource("current"); + } else { + validationTime = validationTimeResult.date; + result.setValidationTimeSource(validationTimeResult.source); + } - result.setNotRevoked(!certValidationService.isRevoked(cert)); - result.setNotExpired( - Instant.now().isBefore(cert.getNotAfter().toInstant())); + // Verify cryptographic signature + boolean cmsValid = + signerInfo.verify( + new JcaSimpleSignerInfoVerifierBuilder().build(signerCert)); + result.setValid(cmsValid); + + // Build and validate certificate path + boolean chainValid = false; + boolean trustValid = false; + try { + PKIXCertPathBuilderResult pathResult = + certValidationService.buildAndValidatePath( + signerCert, intermediates, customCert, validationTime); + chainValid = true; + trustValid = true; // Path ends at trust anchor + result.setCertPathLength( + pathResult.getCertPath().getCertificates().size()); + } catch (Exception e) { + String errorMsg = e.getMessage(); + result.setChainValidationError(errorMsg); + chainValid = false; + trustValid = false; + // Log the full error for debugging + log.warn( + "Certificate path validation failed for {}: {}", + signerCert.getSubjectX500Principal().getName(), + errorMsg); + log.debug("Full stack trace:", e); + } + result.setChainValid(chainValid); + result.setTrustValid(trustValid); + + // Check validity at validation time + boolean outside = + certValidationService.isOutsideValidityPeriod( + signerCert, validationTime); + result.setNotExpired(!outside); + + // Revocation status determination + boolean revocationEnabled = certValidationService.isRevocationEnabled(); + result.setRevocationChecked(revocationEnabled); + + if (!revocationEnabled) { + result.setRevocationStatus("not-checked"); + } else if (chainValid && trustValid) { + // Path building succeeded with revocation enabled = no revocation found + result.setRevocationStatus("good"); + } else if (result.getChainValidationError() != null + && result.getChainValidationError() + .toLowerCase() + .contains("revocation")) { + // Check if failure was revocation-related + if (result.getChainValidationError() + .toLowerCase() + .contains("unable to check")) { + result.setRevocationStatus("soft-fail"); + } else { + result.setRevocationStatus("revoked"); + } + } else { + result.setRevocationStatus("unknown"); + } // Set basic signature info result.setSignerName(sig.getName()); - result.setSignatureDate(sig.getSignDate().toInstant().toString()); + result.setSignatureDate( + sig.getSignDate() != null + ? sig.getSignDate().getTime().toString() + : null); result.setReason(sig.getReason()); result.setLocation(sig.getLocation()); - // Set new certificate details - result.setIssuerDN(cert.getIssuerX500Principal().getName()); - result.setSubjectDN(cert.getSubjectX500Principal().getName()); - result.setSerialNumber(cert.getSerialNumber().toString(16)); // Hex format - result.setValidFrom(cert.getNotBefore().toString()); - result.setValidUntil(cert.getNotAfter().toString()); - result.setSignatureAlgorithm(cert.getSigAlgName()); + // Set certificate details (from signer cert) + result.setIssuerDN(signerCert.getIssuerX500Principal().getName()); + result.setSubjectDN(signerCert.getSubjectX500Principal().getName()); + result.setSerialNumber( + signerCert.getSerialNumber().toString(16)); // Hex format + result.setValidFrom(signerCert.getNotBefore().toString()); + result.setValidUntil(signerCert.getNotAfter().toString()); + result.setSignatureAlgorithm(signerCert.getSigAlgName()); // Get key size (if possible) try { result.setKeySize( - ((RSAPublicKey) cert.getPublicKey()).getModulus().bitLength()); + ((RSAPublicKey) signerCert.getPublicKey()) + .getModulus() + .bitLength()); } catch (Exception e) { // If not RSA or error, set to 0 result.setKeySize(0); } - result.setVersion(String.valueOf(cert.getVersion())); + result.setVersion(String.valueOf(signerCert.getVersion())); // Set key usage List keyUsages = new ArrayList<>(); - boolean[] keyUsageFlags = cert.getKeyUsage(); + boolean[] keyUsageFlags = signerCert.getKeyUsage(); if (keyUsageFlags != null) { String[] keyUsageLabels = { - "Digital Signature", "Non-Repudiation", "Key Encipherment", - "Data Encipherment", "Key Agreement", "Certificate Signing", - "CRL Signing", "Encipher Only", "Decipher Only" + "Digital Signature", + "Non-Repudiation", + "Key Encipherment", + "Data Encipherment", + "Key Agreement", + "Certificate Signing", + "CRL Signing", + "Encipher Only", + "Decipher Only" }; for (int i = 0; i < keyUsageFlags.length; i++) { if (keyUsageFlags[i]) { @@ -179,10 +266,8 @@ public class ValidateSignatureController { } result.setKeyUsages(keyUsages); - // Check if self-signed - result.setSelfSigned( - cert.getSubjectX500Principal() - .equals(cert.getIssuerX500Principal())); + // Check if self-signed (properly) + result.setSelfSigned(certValidationService.isSelfSigned(signerCert)); } } catch (Exception e) { result.setValid(false); diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignatureValidationResult.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignatureValidationResult.java index b4c51f365..b45aeefc3 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignatureValidationResult.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignatureValidationResult.java @@ -6,17 +6,32 @@ import lombok.Data; @Data public class SignatureValidationResult { + // Cryptographic signature validation private boolean valid; + + // Certificate chain validation + private boolean chainValid; + private boolean trustValid; + private String chainValidationError; + private int certPathLength; + + // Time validation + private boolean notExpired; + + // Revocation validation + private boolean revocationChecked; // true if PKIX revocation was enabled + private String revocationStatus; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown" + + private String validationTimeSource; // "current", "signing-time", or "timestamp" + + // Signature metadata private String signerName; private String signatureDate; private String reason; private String location; private String errorMessage; - private boolean chainValid; - private boolean trustValid; - private boolean notExpired; - private boolean notRevoked; + // Certificate details private String issuerDN; // Certificate issuer's Distinguished Name private String subjectDN; // Certificate subject's Distinguished Name private String serialNumber; // Certificate serial number diff --git a/app/core/src/main/java/stirling/software/SPDF/service/CertificateValidationService.java b/app/core/src/main/java/stirling/software/SPDF/service/CertificateValidationService.java index 672ee76f9..6b2c097cc 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/CertificateValidationService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/CertificateValidationService.java @@ -1,143 +1,863 @@ package stirling.software.SPDF.service; import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.security.GeneralSecurityException; import java.security.KeyStore; -import java.security.KeyStoreException; +import java.security.MessageDigest; import java.security.cert.*; import java.util.*; -import org.springframework.stereotype.Service; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; -import io.github.pixee.security.BoundedLineReader; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary; +import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode; +import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification; +import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile; +import org.bouncycastle.asn1.ASN1Encodable; +import org.bouncycastle.asn1.ASN1GeneralizedTime; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.ASN1UTCTime; +import org.bouncycastle.asn1.cms.CMSAttributes; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cms.CMSSignedData; +import org.bouncycastle.cms.SignerInformation; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.tsp.TimeStampToken; +import org.bouncycastle.util.Store; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.w3c.dom.Document; +import org.w3c.dom.NodeList; import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.ServerCertificateServiceInterface; + @Service +@Slf4j public class CertificateValidationService { - private KeyStore trustStore; + /** + * Result container for validation time extraction Contains both the date and the source of the + * time + */ + public static class ValidationTime { + public final Date date; + public final String source; // "timestamp" | "signing-time" | "current" + + public ValidationTime(Date date, String source) { + this.date = date; + this.source = source; + } + } + + // Separate trust stores: signing vs TLS + private KeyStore signingTrustAnchors; // AATL/EUTL + server cert for PDF signing + private final ServerCertificateServiceInterface serverCertificateService; + private final ApplicationProperties applicationProperties; + + // EUTL (EU Trusted List) constants + private static final String NS_TSL = "http://uri.etsi.org/02231/v2#"; + + // Qualified CA service types to import as trust anchors (per ETSI TS 119 612) + private static final Set EUTL_SERVICE_TYPES = + new HashSet<>( + Arrays.asList( + "http://uri.etsi.org/TrstSvc/Svctype/CA/QC", + "http://uri.etsi.org/TrstSvc/Svctype/NationalRootCA-QC")); + + // Active statuses to accept (per ETSI TS 119 612) + private static final String STATUS_UNDER_SUPERVISION = + "http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/undersupervision"; + private static final String STATUS_ACCREDITED = + "http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/accredited"; + private static final String STATUS_SUPERVISION_IN_CESSATION = + "http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/supervisionincessation"; + + static { + if (java.security.Security.getProvider("BC") == null) { + java.security.Security.addProvider(new BouncyCastleProvider()); + } + } + + public CertificateValidationService( + @Autowired(required = false) ServerCertificateServiceInterface serverCertificateService, + ApplicationProperties applicationProperties) { + this.serverCertificateService = serverCertificateService; + this.applicationProperties = applicationProperties; + } @PostConstruct private void initializeTrustStore() throws Exception { - trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); - trustStore.load(null, null); - loadMozillaCertificates(); + signingTrustAnchors = KeyStore.getInstance(KeyStore.getDefaultType()); + signingTrustAnchors.load(null, null); + + ApplicationProperties.Security.Validation validation = + applicationProperties.getSecurity().getValidation(); + + // Enable JDK fetching of OCSP/CRLDP if allowed + if (validation.isAllowAIA()) { + java.security.Security.setProperty("ocsp.enable", "true"); + System.setProperty("com.sun.security.enableCRLDP", "true"); + System.setProperty("com.sun.security.enableAIAcaIssuers", "true"); + log.info("Enabled AIA certificate fetching and revocation checking"); + } + + // Trust only what we explicitly opt into: + if (validation.getTrust().isServerAsAnchor()) loadServerCertAsAnchor(); + if (validation.getTrust().isUseSystemTrust()) loadJavaSystemTrustStore(); + if (validation.getTrust().isUseMozillaBundle()) loadBundledMozillaCACerts(); + if (validation.getTrust().isUseAATL()) loadAATLCertificates(); + if (validation.getTrust().isUseEUTL()) loadEUTLCertificates(); } - private void loadMozillaCertificates() throws Exception { - try (InputStream is = getClass().getResourceAsStream("/certdata.txt")) { - BufferedReader reader = new BufferedReader(new InputStreamReader(is)); - String line; - StringBuilder certData = new StringBuilder(); - boolean inCert = false; - int certCount = 0; + /** + * Core entry-point: build a valid PKIX path from signerCert using provided intermediates + * + * @param signerCert The signer certificate + * @param intermediates Collection of intermediate certificates from CMS + * @param customTrustAnchor Optional custom root/intermediate certificate + * @param validationTime Time to validate at (signing time or current) + * @return PKIXCertPathBuilderResult containing validated path + * @throws GeneralSecurityException if path building/validation fails + */ + public PKIXCertPathBuilderResult buildAndValidatePath( + X509Certificate signerCert, + Collection intermediates, + X509Certificate customTrustAnchor, + Date validationTime) + throws GeneralSecurityException { - while ((line = BoundedLineReader.readLine(reader, 5_000_000)) != null) { - if (line.startsWith("CKA_VALUE MULTILINE_OCTAL")) { - inCert = true; - certData = new StringBuilder(); - continue; + // Build trust anchors + Set anchors = new HashSet<>(); + if (customTrustAnchor != null) { + anchors.add(new TrustAnchor(customTrustAnchor, null)); + } else { + Enumeration aliases = signingTrustAnchors.aliases(); + while (aliases.hasMoreElements()) { + Certificate c = signingTrustAnchors.getCertificate(aliases.nextElement()); + if (c instanceof X509Certificate x) { + anchors.add(new TrustAnchor(x, null)); } - if (inCert) { - if ("END".equals(line)) { - inCert = false; - byte[] certBytes = parseOctalData(certData.toString()); - if (certBytes != null) { - CertificateFactory cf = CertificateFactory.getInstance("X.509"); - X509Certificate cert = - (X509Certificate) - cf.generateCertificate( - new ByteArrayInputStream(certBytes)); - trustStore.setCertificateEntry("mozilla-cert-" + certCount++, cert); - } - } else { - certData.append(line).append("\n"); + } + } + if (anchors.isEmpty()) { + throw new CertPathBuilderException("No trust anchors available"); + } + + // Target certificate selector + X509CertSelector target = new X509CertSelector(); + target.setCertificate(signerCert); + + // Intermediate certificate store + List allCerts = new ArrayList<>(intermediates); + CertStore intermediateStore = + CertStore.getInstance("Collection", new CollectionCertStoreParameters(allCerts)); + + // PKIX parameters + PKIXBuilderParameters params = new PKIXBuilderParameters(anchors, target); + params.addCertStore(intermediateStore); + String revocationMode = + applicationProperties.getSecurity().getValidation().getRevocation().getMode(); + params.setRevocationEnabled(!"none".equalsIgnoreCase(revocationMode)); + if (validationTime != null) { + params.setDate(validationTime); + } + + // Revocation checking + if (!"none".equalsIgnoreCase(revocationMode)) { + try { + PKIXRevocationChecker rc = + (PKIXRevocationChecker) + CertPathValidator.getInstance("PKIX").getRevocationChecker(); + + Set options = + EnumSet.noneOf(PKIXRevocationChecker.Option.class); + + // Soft-fail: allow validation to succeed if revocation status unavailable + boolean revocationHardFail = + applicationProperties + .getSecurity() + .getValidation() + .getRevocation() + .isHardFail(); + if (!revocationHardFail) { + options.add(PKIXRevocationChecker.Option.SOFT_FAIL); + } + + // Revocation mode configuration + if ("ocsp".equalsIgnoreCase(revocationMode)) { + // OCSP-only: prefer OCSP (default), disable fallback to CRL + options.add(PKIXRevocationChecker.Option.NO_FALLBACK); + } else if ("crl".equalsIgnoreCase(revocationMode)) { + // CRL-only: prefer CRLs, disable fallback to OCSP + options.add(PKIXRevocationChecker.Option.PREFER_CRLS); + options.add(PKIXRevocationChecker.Option.NO_FALLBACK); + } + // "ocsp+crl" or other: use defaults (try OCSP first, fallback to CRL) + + rc.setOptions(options); + params.addCertPathChecker(rc); + } catch (Exception e) { + log.warn("Failed to configure revocation checker: {}", e.getMessage()); + } + } + + // Build path + CertPathBuilder builder = CertPathBuilder.getInstance("PKIX"); + return (PKIXCertPathBuilderResult) builder.build(params); + } + + /** + * Extract validation time from signature (TSA timestamp or signingTime) + * + * @param signerInfo The CMS signer information + * @return ValidationTime containing date and source, or null if not found + */ + public ValidationTime extractValidationTime(SignerInformation signerInfo) { + try { + // 1) Check for timestamp token (RFC 3161) - highest priority + var unsignedAttrs = signerInfo.getUnsignedAttributes(); + if (unsignedAttrs != null) { + var attr = + unsignedAttrs.get(new ASN1ObjectIdentifier("1.2.840.113549.1.9.16.2.14")); + if (attr != null) { + try { + TimeStampToken tst = + new TimeStampToken( + new CMSSignedData( + attr.getAttributeValues()[0] + .toASN1Primitive() + .getEncoded())); + Date tstTime = tst.getTimeStampInfo().getGenTime(); + log.debug("Using timestamp token time: {}", tstTime); + return new ValidationTime(tstTime, "timestamp"); + } catch (Exception e) { + log.debug("Failed to parse timestamp token: {}", e.getMessage()); } } } - } - } - private byte[] parseOctalData(String data) { - try { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - String[] tokens = data.split("\\\\"); - for (String token : tokens) { - token = token.trim(); - if (!token.isEmpty()) { - baos.write(Integer.parseInt(token, 8)); + // 2) Check for signingTime attribute - fallback + var signedAttrs = signerInfo.getSignedAttributes(); + if (signedAttrs != null) { + var st = signedAttrs.get(CMSAttributes.signingTime); + if (st != null) { + ASN1Encodable val = st.getAttributeValues()[0]; + Date signingTime = null; + if (val instanceof ASN1UTCTime ut) { + signingTime = ut.getDate(); + } else if (val instanceof ASN1GeneralizedTime gt) { + signingTime = gt.getDate(); + } + if (signingTime != null) { + log.debug("Using signingTime attribute: {}", signingTime); + return new ValidationTime(signingTime, "signing-time"); + } } } - return baos.toByteArray(); } catch (Exception e) { - return null; + log.debug("Error extracting validation time: {}", e.getMessage()); } + return null; } - public boolean validateCertificateChain(X509Certificate cert) { + /** + * Check if certificate is outside validity period at given time + * + * @param cert Certificate to check + * @param at Time to check validity + * @return true if certificate is expired or not yet valid + */ + public boolean isOutsideValidityPeriod(X509Certificate cert, Date at) { try { - CertPathValidator validator = CertPathValidator.getInstance("PKIX"); - CertificateFactory cf = CertificateFactory.getInstance("X.509"); - List certList = Collections.singletonList(cert); - CertPath certPath = cf.generateCertPath(certList); - - Set anchors = new HashSet<>(); - Enumeration aliases = trustStore.aliases(); - while (aliases.hasMoreElements()) { - Object trustCert = trustStore.getCertificate(aliases.nextElement()); - if (trustCert instanceof X509Certificate x509Cert) { - anchors.add(new TrustAnchor(x509Cert, null)); - } - } - - PKIXParameters params = new PKIXParameters(anchors); - params.setRevocationEnabled(false); - validator.validate(certPath, params); - return true; - } catch (Exception e) { - return false; - } - } - - public boolean validateTrustStore(X509Certificate cert) { - try { - Enumeration aliases = trustStore.aliases(); - while (aliases.hasMoreElements()) { - Object trustCert = trustStore.getCertificate(aliases.nextElement()); - if (trustCert instanceof X509Certificate && cert.equals(trustCert)) { - return true; - } - } - return false; - } catch (KeyStoreException e) { - return false; - } - } - - public boolean isRevoked(X509Certificate cert) { - try { - cert.checkValidity(); + cert.checkValidity(at); return false; } catch (CertificateExpiredException | CertificateNotYetValidException e) { return true; } } - public boolean validateCertificateChainWithCustomCert( - X509Certificate cert, X509Certificate customCert) { + /** + * Check if revocation checking is enabled + * + * @return true if revocation mode is not "none" + */ + public boolean isRevocationEnabled() { + String revocationMode = + applicationProperties.getSecurity().getValidation().getRevocation().getMode(); + return !"none".equalsIgnoreCase(revocationMode); + } + + /** + * Check if certificate is a CA certificate + * + * @param cert Certificate to check + * @return true if certificate has basicConstraints with CA=true + */ + public boolean isCA(X509Certificate cert) { + return cert.getBasicConstraints() >= 0; + } + + /** + * Verify if certificate is self-signed by checking signature + * + * @param cert Certificate to check + * @return true if certificate is self-signed and signature is valid + */ + public boolean isSelfSigned(X509Certificate cert) { try { - cert.verify(customCert.getPublicKey()); + if (!cert.getSubjectX500Principal().equals(cert.getIssuerX500Principal())) { + return false; + } + cert.verify(cert.getPublicKey()); return true; } catch (Exception e) { return false; } } - public boolean validateTrustWithCustomCert(X509Certificate cert, X509Certificate customCert) { + /** + * Calculate SHA-256 fingerprint of certificate + * + * @param cert Certificate + * @return Hex string of SHA-256 hash + */ + public String sha256Fingerprint(X509Certificate cert) { try { - // Compare the issuer of the signature certificate with the custom certificate - return cert.getIssuerX500Principal().equals(customCert.getSubjectX500Principal()); + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] hash = md.digest(cert.getEncoded()); + return bytesToHex(hash); } catch (Exception e) { - return false; + return ""; } } + + private String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02X", b)); + } + return sb.toString(); + } + + /** + * Extract all certificates from CMS signature store + * + * @param certStore BouncyCastle certificate store + * @param signerCert The signer certificate + * @return Collection of all certificates except signer + */ + public Collection extractIntermediateCertificates( + Store certStore, X509Certificate signerCert) { + List intermediates = new ArrayList<>(); + try { + JcaX509CertificateConverter converter = new JcaX509CertificateConverter(); + Collection holders = certStore.getMatches(null); + + for (X509CertificateHolder holder : holders) { + X509Certificate cert = converter.getCertificate(holder); + if (!cert.equals(signerCert)) { + intermediates.add(cert); + } + } + } catch (Exception e) { + log.debug("Error extracting intermediate certificates: {}", e.getMessage()); + } + return intermediates; + } + + // ==================== Trust Store Loading ==================== + + /** + * Load certificates from Java's system trust store (cacerts). On Windows, this includes + * certificates from the Windows trust store. This provides maximum compatibility with what + * browsers and OS trust. + */ + private void loadJavaSystemTrustStore() { + try { + log.info("Loading certificates from Java system trust store"); + + // Get default trust manager factory + TrustManagerFactory tmf = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init((KeyStore) null); // null = use system default + + // Extract certificates from trust managers + int loadedCount = 0; + for (TrustManager tm : tmf.getTrustManagers()) { + if (tm instanceof X509TrustManager x509tm) { + for (X509Certificate cert : x509tm.getAcceptedIssuers()) { + if (isCA(cert)) { + String fingerprint = sha256Fingerprint(cert); + String alias = "system-" + fingerprint; + signingTrustAnchors.setCertificateEntry(alias, cert); + loadedCount++; + } + } + } + } + + log.info("Loaded {} CA certificates from Java system trust store", loadedCount); + } catch (Exception e) { + log.error("Failed to load Java system trust store: {}", e.getMessage(), e); + } + } + + /** + * Load bundled Mozilla CA certificate bundle from resources. This bundle contains ~140 trusted + * root CAs from Mozilla's CA Certificate Program, suitable for validating most commercial PDF + * signatures. + */ + private void loadBundledMozillaCACerts() { + try { + log.info("Loading bundled Mozilla CA certificates from resources"); + InputStream certStream = + getClass().getClassLoader().getResourceAsStream("certs/cacert.pem"); + if (certStream == null) { + log.warn("Bundled Mozilla CA certificate file not found in resources"); + return; + } + + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + Collection certs = cf.generateCertificates(certStream); + certStream.close(); + + int loadedCount = 0; + int skippedCount = 0; + + for (Certificate cert : certs) { + if (cert instanceof X509Certificate x509) { + // Only add CA certificates to trust anchors + if (isCA(x509)) { + String fingerprint = sha256Fingerprint(x509); + String alias = "mozilla-" + fingerprint; + signingTrustAnchors.setCertificateEntry(alias, x509); + loadedCount++; + } else { + skippedCount++; + } + } + } + + log.info( + "Loaded {} Mozilla CA certificates as trust anchors (skipped {} non-CA certs)", + loadedCount, + skippedCount); + } catch (Exception e) { + log.error("Failed to load bundled Mozilla CA certificates: {}", e.getMessage(), e); + } + } + + private void loadServerCertAsAnchor() { + try { + if (serverCertificateService != null + && serverCertificateService.isEnabled() + && serverCertificateService.hasServerCertificate()) { + X509Certificate serverCert = serverCertificateService.getServerCertificate(); + + // Self-signed certificates can be trust anchors regardless of CA flag + // Non-self-signed certificates should only be trust anchors if they're CAs + boolean selfSigned = isSelfSigned(serverCert); + boolean ca = isCA(serverCert); + + if (selfSigned || ca) { + signingTrustAnchors.setCertificateEntry("server-anchor", serverCert); + log.info( + "Loaded server certificate as trust anchor (self-signed: {}, CA: {})", + selfSigned, + ca); + } else { + log.warn( + "Server certificate is neither self-signed nor a CA; not adding as trust anchor"); + } + } + } catch (Exception e) { + log.warn("Failed loading server certificate as anchor: {}", e.getMessage()); + } + } + + /** Download and parse Adobe Approved Trust List (AATL) and add CA certs as trust anchors. */ + private void loadAATLCertificates() { + try { + String aatlUrl = applicationProperties.getSecurity().getValidation().getAatl().getUrl(); + log.info("Loading Adobe Approved Trust List (AATL) from: {}", aatlUrl); + byte[] pdfBytes = downloadTrustList(aatlUrl); + if (pdfBytes == null) { + log.warn("AATL download returned no data"); + return; + } + int added = parseAATLPdf(pdfBytes); + log.info("Loaded {} AATL CA certificates into signing trust", added); + } catch (Exception e) { + log.warn("Failed to load AATL: {}", e.getMessage()); + log.debug("AATL loading error", e); + } + } + + /** Simple HTTP(S) fetch with sane timeouts. */ + private byte[] downloadTrustList(String urlStr) { + HttpURLConnection conn = null; + try { + URL url = new URL(urlStr); + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(30_000); + conn.setInstanceFollowRedirects(true); + + int code = conn.getResponseCode(); + if (code == HttpURLConnection.HTTP_OK) { + try (InputStream in = conn.getInputStream(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + byte[] buf = new byte[8192]; + int r; + while ((r = in.read(buf)) != -1) out.write(buf, 0, r); + return out.toByteArray(); + } + } else { + log.warn("AATL download failed: HTTP {}", code); + return null; + } + } catch (Exception e) { + log.warn("AATL download error: {}", e.getMessage()); + return null; + } finally { + if (conn != null) conn.disconnect(); + } + } + + /** + * Parse AATL PDF, extract the embedded "SecuritySettings.xml", and import CA certs. Returns the + * number of newly-added CA certificates. + */ + private int parseAATLPdf(byte[] pdfBytes) throws Exception { + try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + PDDocumentNameDictionary names = doc.getDocumentCatalog().getNames(); + if (names == null) { + log.warn("AATL PDF has no name dictionary"); + return 0; + } + + PDEmbeddedFilesNameTreeNode efRoot = names.getEmbeddedFiles(); + if (efRoot == null) { + log.warn("AATL PDF has no embedded files"); + return 0; + } + + // 1) Try names at root level + Map top = efRoot.getNames(); + if (top != null) { + Integer count = tryParseSecuritySettingsXML(top); + if (count != null) return count; + } + + // 2) Traverse kids (name-tree) + @SuppressWarnings("unchecked") + List kids = efRoot.getKids(); + if (kids != null) { + for (Object kidObj : kids) { + if (kidObj instanceof PDEmbeddedFilesNameTreeNode) { + PDEmbeddedFilesNameTreeNode kid = (PDEmbeddedFilesNameTreeNode) kidObj; + Map map = kid.getNames(); + if (map != null) { + Integer count = tryParseSecuritySettingsXML(map); + if (count != null) return count; + } + } + } + } + + log.warn("AATL PDF did not contain SecuritySettings.xml"); + return 0; + } + } + + /** + * Try to locate "SecuritySettings.xml" in the given name map. If found and parsed, returns the + * number of certs added; otherwise returns null. + */ + private Integer tryParseSecuritySettingsXML(Map nameMap) { + PDComplexFileSpecification fileSpec = nameMap.get("SecuritySettings.xml"); + if (fileSpec == null) return null; + + PDEmbeddedFile ef = fileSpec.getEmbeddedFile(); + if (ef == null) return null; + + try (InputStream xmlStream = ef.createInputStream()) { + return parseSecuritySettingsXML(xmlStream); + } catch (Exception e) { + log.warn("Failed parsing SecuritySettings.xml: {}", e.getMessage()); + log.debug("SecuritySettings.xml parse error", e); + return null; + } + } + + /** + * Parse the SecuritySettings.xml and load only CA certificates (basicConstraints >= 0). Returns + * the number of newly-added CA certificates. + */ + private int parseSecuritySettingsXML(InputStream xmlStream) throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + + DocumentBuilder builder = factory.newDocumentBuilder(); + Document doc = builder.parse(xmlStream); + + NodeList certNodes = doc.getElementsByTagName("Certificate"); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + + int added = 0; + for (int i = 0; i < certNodes.getLength(); i++) { + String base64 = certNodes.item(i).getTextContent().trim(); + if (base64.isEmpty()) continue; + + try { + byte[] certBytes = java.util.Base64.getMimeDecoder().decode(base64); + X509Certificate cert = + (X509Certificate) + cf.generateCertificate(new ByteArrayInputStream(certBytes)); + + // Only add CA certs as anchors + if (isCA(cert)) { + String fingerprint = sha256Fingerprint(cert); + String alias = "aatl-" + fingerprint; + + // avoid duplicates + if (signingTrustAnchors.getCertificate(alias) == null) { + signingTrustAnchors.setCertificateEntry(alias, cert); + added++; + } + } else { + log.debug( + "Skipping non-CA certificate from AATL: {}", + cert.getSubjectX500Principal().getName()); + } + } catch (Exception e) { + log.debug("Failed to parse an AATL certificate node: {}", e.getMessage()); + } + } + return added; + } + + /** + * Download LOTL (List Of Trusted Lists), resolve national TSLs, and import qualified CA + * certificates. + */ + private void loadEUTLCertificates() { + try { + String lotlUrl = + applicationProperties.getSecurity().getValidation().getEutl().getLotlUrl(); + log.info("Loading EU Trusted List (LOTL) from: {}", lotlUrl); + byte[] lotlBytes = downloadXml(lotlUrl); + if (lotlBytes == null) { + log.warn("LOTL download returned no data"); + return; + } + + List tslUrls = parseLotlForTslLocations(lotlBytes); + log.info("Found {} national TSL locations in LOTL", tslUrls.size()); + + int totalAdded = 0; + for (String tslUrl : tslUrls) { + try { + byte[] tslBytes = downloadXml(tslUrl); + if (tslBytes == null) { + log.warn("TSL download failed: {}", tslUrl); + continue; + } + int added = parseTslAndAddCas(tslBytes, tslUrl); + totalAdded += added; + } catch (Exception e) { + log.warn("Failed to parse TSL {}: {}", tslUrl, e.getMessage()); + log.debug("TSL parse error", e); + } + } + + log.info("Imported {} qualified CA certificates from EUTL", totalAdded); + } catch (Exception e) { + log.warn("EUTL load failed: {}", e.getMessage()); + log.debug("EUTL load error", e); + } + } + + /** HTTP(S) GET for XML with sane timeouts. */ + private byte[] downloadXml(String urlStr) { + HttpURLConnection conn = null; + try { + URL url = new URL(urlStr); + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(30_000); + conn.setInstanceFollowRedirects(true); + + int code = conn.getResponseCode(); + if (code == HttpURLConnection.HTTP_OK) { + try (InputStream in = conn.getInputStream(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + byte[] buf = new byte[8192]; + int r; + while ((r = in.read(buf)) != -1) out.write(buf, 0, r); + return out.toByteArray(); + } + } else { + log.warn("XML download failed: HTTP {} for {}", code, urlStr); + return null; + } + } catch (Exception e) { + log.warn("XML download error for {}: {}", urlStr, e.getMessage()); + return null; + } finally { + if (conn != null) conn.disconnect(); + } + } + + /** Parse LOTL and return all TSL URLs from PointersToOtherTSL. */ + private List parseLotlForTslLocations(byte[] lotlBytes) throws Exception { + DocumentBuilderFactory dbf = secureDbfWithNamespaces(); + DocumentBuilder db = dbf.newDocumentBuilder(); + Document doc = db.parse(new ByteArrayInputStream(lotlBytes)); + + List out = new ArrayList<>(); + NodeList ptrs = doc.getElementsByTagNameNS(NS_TSL, "PointersToOtherTSL"); + if (ptrs.getLength() == 0) return out; + + org.w3c.dom.Element ptrRoot = (org.w3c.dom.Element) ptrs.item(0); + NodeList locations = ptrRoot.getElementsByTagNameNS(NS_TSL, "TSLLocation"); + for (int i = 0; i < locations.getLength(); i++) { + String url = locations.item(i).getTextContent().trim(); + if (!url.isEmpty()) out.add(url); + } + return out; + } + + /** + * Parse a single national TSL, import CA certificates for qualified services in an active + * status. Returns count of newly added CA certs. + */ + private int parseTslAndAddCas(byte[] tslBytes, String sourceUrl) throws Exception { + DocumentBuilderFactory dbf = secureDbfWithNamespaces(); + DocumentBuilder db = dbf.newDocumentBuilder(); + Document doc = db.parse(new ByteArrayInputStream(tslBytes)); + + int added = 0; + + NodeList services = doc.getElementsByTagNameNS(NS_TSL, "TSPService"); + for (int i = 0; i < services.getLength(); i++) { + org.w3c.dom.Element svc = (org.w3c.dom.Element) services.item(i); + org.w3c.dom.Element info = firstChildNS(svc, "ServiceInformation"); + if (info == null) continue; + + String type = textOf(info, "ServiceTypeIdentifier"); + if (!EUTL_SERVICE_TYPES.contains(type)) continue; + + String status = textOf(info, "ServiceStatus"); + if (!isActiveStatus(status)) continue; + + org.w3c.dom.Element sdi = firstChildNS(info, "ServiceDigitalIdentity"); + if (sdi == null) continue; + + NodeList digitalIds = sdi.getElementsByTagNameNS(NS_TSL, "DigitalId"); + for (int d = 0; d < digitalIds.getLength(); d++) { + org.w3c.dom.Element did = (org.w3c.dom.Element) digitalIds.item(d); + NodeList certNodes = did.getElementsByTagNameNS(NS_TSL, "X509Certificate"); + for (int c = 0; c < certNodes.getLength(); c++) { + String base64 = certNodes.item(c).getTextContent().trim(); + if (base64.isEmpty()) continue; + + try { + byte[] certBytes = java.util.Base64.getMimeDecoder().decode(base64); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + X509Certificate cert = + (X509Certificate) + cf.generateCertificate(new ByteArrayInputStream(certBytes)); + + if (!isCA(cert)) { + log.debug( + "Skipping non-CA in TSL {}: {}", + sourceUrl, + cert.getSubjectX500Principal().getName()); + continue; + } + + String fp = sha256Fingerprint(cert); + String alias = "eutl-" + fp; + + if (signingTrustAnchors.getCertificate(alias) == null) { + signingTrustAnchors.setCertificateEntry(alias, cert); + added++; + } + } catch (Exception e) { + log.debug( + "Failed to import a certificate from {}: {}", + sourceUrl, + e.getMessage()); + } + } + } + } + + log.debug("TSL {} → imported {} CA certificates", sourceUrl, added); + return added; + } + + /** Check if service status is active (per ETSI TS 119 612). */ + private boolean isActiveStatus(String statusUri) { + if (STATUS_UNDER_SUPERVISION.equals(statusUri)) return true; + if (STATUS_ACCREDITED.equals(statusUri)) return true; + boolean acceptTransitional = + applicationProperties + .getSecurity() + .getValidation() + .getEutl() + .isAcceptTransitional(); + if (acceptTransitional && STATUS_SUPERVISION_IN_CESSATION.equals(statusUri)) return true; + return false; + } + + /** Create secure DocumentBuilderFactory with namespace awareness. */ + private DocumentBuilderFactory secureDbfWithNamespaces() throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + // Secure processing hardening + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + return factory; + } + + /** Get first child element with given local name in TSL namespace. */ + private org.w3c.dom.Element firstChildNS(org.w3c.dom.Element parent, String localName) { + NodeList nl = parent.getElementsByTagNameNS(NS_TSL, localName); + return (nl.getLength() == 0) ? null : (org.w3c.dom.Element) nl.item(0); + } + + /** Get text content of first child with given local name. */ + private String textOf(org.w3c.dom.Element parent, String localName) { + org.w3c.dom.Element e = firstChildNS(parent, localName); + return (e == null) ? "" : e.getTextContent().trim(); + } + + /** Get signing trust store */ + public KeyStore getSigningTrustStore() { + return signingTrustAnchors; + } } diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index 1f4e831df..18e1f4f8a 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -2,7 +2,7 @@ multipart.enabled=true logging.level.org.springframework=WARN logging.level.org.hibernate=WARN logging.level.org.eclipse.jetty=WARN -#logging.level.org.springframework.security.saml2=TRACE +#logging.level.org.springframework.security.oauth2=DEBUG #logging.level.org.springframework.security=DEBUG #logging.level.org.opensaml=DEBUG #logging.level.stirling.software.proprietary.security=DEBUG @@ -35,12 +35,12 @@ spring.datasource.username=sa spring.datasource.password= spring.h2.console.enabled=false spring.jpa.hibernate.ddl-auto=update -# Defer datasource initialization to ensure that the database is fully set up -# before Hibernate attempts to access it. This is particularly useful when +# Defer datasource initialization to ensure that the database is fully set up +# before Hibernate attempts to access it. This is particularly useful when # using database initialization scripts or tools. spring.jpa.defer-datasource-initialization=true -# Disable SQL logging to avoid cluttering the logs in production. Enable this +# Disable SQL logging to avoid cluttering the logs in production. Enable this # property during development if you need to debug SQL queries. spring.jpa.show-sql=false server.servlet.session.timeout:30m @@ -60,4 +60,4 @@ spring.main.allow-bean-definition-overriding=true java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf} # V2 features -v2=false +v2=true diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index b943d15d4..8c75da0da 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -64,7 +64,22 @@ security: enableKeyRotation: true # Set to 'true' to enable key pair rotation enableKeyCleanup: true # Set to 'true' to enable key pair cleanup keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days. - secureCookie: false # Set to 'true' to use secure cookies for JWTs + validation: # PDF signature validation settings + trust: + serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA) + useSystemTrust: true # Trust Java/OS system trust store for PDF signature validation + useMozillaBundle: true # Trust bundled Mozilla CA bundle (~140 CAs) for PDF signature validation + useAATL: false # Trust Adobe Approved Trust List (AATL) for PDF signature validation - downloads from Adobe on startup if enabled + useEUTL: false # Trust EU Trusted List (EUTL) for eIDAS qualified certificates - downloads LOTL and national TSLs on startup if enabled + allowAIA: false # Allow JDK to fetch issuer certificates and revocation information from network (OCSP/CRL/AIA) + aatl: + url: https://trustlist.adobe.com/tl.pdf # Adobe Approved Trust List download URL + eutl: + lotlUrl: https://ec.europa.eu/tools/lotl/eu-lotl.xml # EU List Of Trusted Lists (LOTL) URL + acceptTransitional: false # Accept certificates with 'supervisionincessation' status (transitional state) + revocation: + mode: none # Revocation checking mode: 'none' (disabled), 'ocsp' (OCSP only), 'crl' (CRL only), 'ocsp+crl' (OCSP with CRL fallback) + hardFail: false # Fail validation if revocation status cannot be determined (true=strict, false=soft-fail) premium: key: 00000000-0000-0000-0000-000000000000 @@ -84,6 +99,7 @@ premium: mail: enabled: false # set to 'true' to enable sending emails + enableInvites: false # set to 'true' to enable email invites for user management (requires mail.enabled and security.enableLogin) host: smtp.example.com # SMTP server hostname port: 587 # SMTP server port username: '' # SMTP server username @@ -91,8 +107,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 @@ -105,10 +121,13 @@ 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 + corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. serverCertificate: enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option organizationName: Stirling-PDF # Organization name for generated certificates @@ -155,8 +174,6 @@ system: cron: '0 0 0 * * ?' # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight ui: - appName: '' # application's visible name - homeDescription: '' # short description or tagline shown on the homepage appNameNavbar: '' # name displayed on the navigation bar languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled. diff --git a/app/core/src/test/java/stirling/software/SPDF/service/CertificateValidationServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/service/CertificateValidationServiceTest.java index b518d63fc..c2eb2505c 100644 --- a/app/core/src/test/java/stirling/software/SPDF/service/CertificateValidationServiceTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/service/CertificateValidationServiceTest.java @@ -2,20 +2,20 @@ package stirling.software.SPDF.service; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import java.security.PublicKey; import java.security.cert.CertificateExpiredException; import java.security.cert.X509Certificate; - -import javax.security.auth.x500.X500Principal; +import java.util.Date; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; + +import stirling.software.common.model.ApplicationProperties; /** Tests for the CertificateValidationService using mocked certificates. */ class CertificateValidationServiceTest { @@ -26,121 +26,67 @@ class CertificateValidationServiceTest { @BeforeEach void setUp() throws Exception { - validationService = new CertificateValidationService(); + // Create mock ApplicationProperties with default validation settings + ApplicationProperties applicationProperties = mock(ApplicationProperties.class); + ApplicationProperties.Security security = mock(ApplicationProperties.Security.class); + ApplicationProperties.Security.Validation validation = + mock(ApplicationProperties.Security.Validation.class); + ApplicationProperties.Security.Validation.Trust trust = + mock(ApplicationProperties.Security.Validation.Trust.class); + ApplicationProperties.Security.Validation.Revocation revocation = + mock(ApplicationProperties.Security.Validation.Revocation.class); + + when(applicationProperties.getSecurity()).thenReturn(security); + when(security.getValidation()).thenReturn(validation); + when(validation.getTrust()).thenReturn(trust); + when(validation.getRevocation()).thenReturn(revocation); + when(validation.isAllowAIA()).thenReturn(false); + when(trust.isServerAsAnchor()).thenReturn(false); + when(trust.isUseSystemTrust()).thenReturn(false); + when(trust.isUseMozillaBundle()).thenReturn(false); + when(trust.isUseAATL()).thenReturn(false); + when(trust.isUseEUTL()).thenReturn(false); + when(revocation.getMode()).thenReturn("none"); + when(revocation.isHardFail()).thenReturn(false); + + validationService = new CertificateValidationService(null, applicationProperties); // Create mock certificates validCertificate = mock(X509Certificate.class); expiredCertificate = mock(X509Certificate.class); - // Set up behaviors for valid certificate - doNothing().when(validCertificate).checkValidity(); // No exception means valid + // Set up behaviors for valid certificate (both overloads) + doNothing().when(validCertificate).checkValidity(); + doNothing().when(validCertificate).checkValidity(any(Date.class)); - // Set up behaviors for expired certificate + // Set up behaviors for expired certificate (both overloads) doThrow(new CertificateExpiredException("Certificate expired")) .when(expiredCertificate) .checkValidity(); + doThrow(new CertificateExpiredException("Certificate expired")) + .when(expiredCertificate) + .checkValidity(any(Date.class)); } @Test - void testIsRevoked_ValidCertificate() { + void testIsOutsideValidityPeriod_ValidCertificate() { // When certificate is valid (not expired) - boolean result = validationService.isRevoked(validCertificate); + boolean result = validationService.isOutsideValidityPeriod(validCertificate, new Date()); - // Then it should not be considered revoked - assertFalse(result, "Valid certificate should not be considered revoked"); + // Then it should not be outside validity period + assertFalse(result, "Valid certificate should not be outside validity period"); } @Test - void testIsRevoked_ExpiredCertificate() { + void testIsOutsideValidityPeriod_ExpiredCertificate() { // When certificate is expired - boolean result = validationService.isRevoked(expiredCertificate); + boolean result = validationService.isOutsideValidityPeriod(expiredCertificate, new Date()); - // Then it should be considered revoked - assertTrue(result, "Expired certificate should be considered revoked"); + // Then it should be outside validity period + assertTrue(result, "Expired certificate should be outside validity period"); } - @Test - void testValidateTrustWithCustomCert_Match() { - // Create certificates with matching issuer and subject - X509Certificate issuingCert = mock(X509Certificate.class); - X509Certificate signedCert = mock(X509Certificate.class); - - // Create X500Principal objects for issuer and subject - X500Principal issuerPrincipal = new X500Principal("CN=Test Issuer"); - - // Mock the issuer of the signed certificate to match the subject of the issuing certificate - when(signedCert.getIssuerX500Principal()).thenReturn(issuerPrincipal); - when(issuingCert.getSubjectX500Principal()).thenReturn(issuerPrincipal); - - // When validating trust with custom cert - boolean result = validationService.validateTrustWithCustomCert(signedCert, issuingCert); - - // Then validation should succeed - assertTrue(result, "Certificate with matching issuer and subject should validate"); - } - - @Test - void testValidateTrustWithCustomCert_NoMatch() { - // Create certificates with non-matching issuer and subject - X509Certificate issuingCert = mock(X509Certificate.class); - X509Certificate signedCert = mock(X509Certificate.class); - - // Create X500Principal objects for issuer and subject - X500Principal issuerPrincipal = new X500Principal("CN=Test Issuer"); - X500Principal differentPrincipal = new X500Principal("CN=Different Name"); - - // Mock the issuer of the signed certificate to NOT match the subject of the issuing - // certificate - when(signedCert.getIssuerX500Principal()).thenReturn(issuerPrincipal); - when(issuingCert.getSubjectX500Principal()).thenReturn(differentPrincipal); - - // When validating trust with custom cert - boolean result = validationService.validateTrustWithCustomCert(signedCert, issuingCert); - - // Then validation should fail - assertFalse(result, "Certificate with non-matching issuer and subject should not validate"); - } - - @Test - void testValidateCertificateChainWithCustomCert_Success() throws Exception { - // Setup mock certificates - X509Certificate signedCert = mock(X509Certificate.class); - X509Certificate signingCert = mock(X509Certificate.class); - PublicKey publicKey = mock(PublicKey.class); - - when(signingCert.getPublicKey()).thenReturn(publicKey); - - // When verifying the certificate with the signing cert's public key, don't throw exception - doNothing().when(signedCert).verify(Mockito.any()); - - // When validating certificate chain with custom cert - boolean result = - validationService.validateCertificateChainWithCustomCert(signedCert, signingCert); - - // Then validation should succeed - assertTrue(result, "Certificate chain with proper signing should validate"); - } - - @Test - void testValidateCertificateChainWithCustomCert_Failure() throws Exception { - // Setup mock certificates - X509Certificate signedCert = mock(X509Certificate.class); - X509Certificate signingCert = mock(X509Certificate.class); - PublicKey publicKey = mock(PublicKey.class); - - when(signingCert.getPublicKey()).thenReturn(publicKey); - - // When verifying the certificate with the signing cert's public key, throw exception - // Need to use a specific exception that verify() can throw - doThrow(new java.security.SignatureException("Verification failed")) - .when(signedCert) - .verify(Mockito.any()); - - // When validating certificate chain with custom cert - boolean result = - validationService.validateCertificateChainWithCustomCert(signedCert, signingCert); - - // Then validation should fail - assertFalse(result, "Certificate chain with failed signing should not validate"); - } + // Note: Full integration tests for buildAndValidatePath() would require + // real certificate chains and trust anchors. These would be better as + // integration tests using actual signed PDFs from the test-signed-pdfs directory. } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java index 356048b38..073b2fcc8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java @@ -53,7 +53,6 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry; @Slf4j @ProprietaryUiDataApi -@EnterpriseEndpoint public class ProprietaryUIDataController { private final ApplicationProperties applicationProperties; @@ -89,6 +88,7 @@ public class ProprietaryUIDataController { @GetMapping("/audit-dashboard") @PreAuthorize("hasRole('ADMIN')") + @EnterpriseEndpoint @Operation(summary = "Get audit dashboard data") public ResponseEntity getAuditDashboardData() { AuditDashboardData data = new AuditDashboardData(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java index 51908ef03..028cee685 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java @@ -57,7 +57,6 @@ public class CustomAuthenticationSuccessHandler String jwt = jwtService.generateToken( authentication, Map.of("authType", AuthenticationType.WEB)); - jwtService.addToken(response, jwt); log.debug("JWT generated for user: {}", userName); getRedirectStrategy().sendRedirect(request, response, "/"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java index 077a2c2bb..5901a5168 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java @@ -72,13 +72,14 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { authentication.getClass().getSimpleName()); getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); } - } else if (jwtService != null) { - String token = jwtService.extractToken(request); - if (token != null && !token.isBlank()) { - jwtService.clearToken(response); - getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); - } } else { + if (jwtService != null) { + String token = jwtService.extractToken(request); + if (token != null && !token.isBlank()) { + getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); + return; + } + } // Redirect to login page after logout String path = checkForErrors(request); getRedirectStrategy().sendRedirect(request, response, path); @@ -119,8 +120,12 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { // Set service provider keys for the SamlClient samlClient.setSPKeys(certificate, privateKey); - // Redirect to identity provider for logout. todo: add relay state - samlClient.redirectToIdentityProvider(response, null, nameIdValue); + // Build relay state to return user to login page after IdP logout + String relayState = + UrlUtils.getOrigin(request) + request.getContextPath() + LOGOUT_PATH; + + // Redirect to identity provider for logout with relay state + samlClient.redirectToIdentityProvider(response, relayState, nameIdValue); } catch (Exception e) { log.error( "Error retrieving logout URL from Provider {} for user {}", diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java index 25b3c5096..a1e8112d1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java @@ -13,7 +13,7 @@ public class RateLimitResetScheduler { private final IPRateLimitingFilter rateLimitingFilter; - @Scheduled(cron = "0 0 0 * * MON") // At 00:00 every Monday TODO: configurable + @Scheduled(cron = "${security.rate-limit.reset-schedule:0 0 0 * * MON}") public void resetRateLimit() { rateLimitingFilter.resetRequestCounts(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index aceb3b712..92def884f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -39,7 +39,6 @@ import stirling.software.proprietary.security.CustomLogoutSuccessHandler; import stirling.software.proprietary.security.JwtAuthenticationEntryPoint; import stirling.software.proprietary.security.database.repository.JPATokenRepositoryImpl; import stirling.software.proprietary.security.database.repository.PersistentLoginRepository; -import stirling.software.proprietary.security.filter.FirstLoginFilter; import stirling.software.proprietary.security.filter.IPRateLimitingFilter; import stirling.software.proprietary.security.filter.JwtAuthenticationFilter; import stirling.software.proprietary.security.filter.UserAuthenticationFilter; @@ -74,7 +73,6 @@ public class SecurityConfiguration { private final JwtServiceInterface jwtService; private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final LoginAttemptService loginAttemptService; - private final FirstLoginFilter firstLoginFilter; private final SessionPersistentRegistry sessionRegistry; private final PersistentLoginRepository persistentLoginRepository; private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper; @@ -93,7 +91,6 @@ public class SecurityConfiguration { JwtServiceInterface jwtService, JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, LoginAttemptService loginAttemptService, - FirstLoginFilter firstLoginFilter, SessionPersistentRegistry sessionRegistry, @Autowired(required = false) GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper, @Autowired(required = false) @@ -110,7 +107,6 @@ public class SecurityConfiguration { this.jwtService = jwtService; this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint; this.loginAttemptService = loginAttemptService; - this.firstLoginFilter = firstLoginFilter; this.sessionRegistry = sessionRegistry; this.persistentLoginRepository = persistentLoginRepository; this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper; @@ -132,19 +128,14 @@ public class SecurityConfiguration { if (loginEnabledValue) { boolean v2Enabled = appConfig.v2Enabled(); - if (v2Enabled) { - http.addFilterBefore( - jwtAuthenticationFilter(), - UsernamePasswordAuthenticationFilter.class) - .exceptionHandling( - exceptionHandling -> - exceptionHandling.authenticationEntryPoint( - jwtAuthenticationEntryPoint)); - } http.addFilterBefore( userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - .addFilterAfter(rateLimitingFilter(), UserAuthenticationFilter.class) - .addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class); + .addFilterBefore( + rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class); + + if (v2Enabled) { + http.addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class); + } if (!securityProperties.getCsrfDisabled()) { CookieCsrfTokenRepository cookieRepo = @@ -156,6 +147,13 @@ public class SecurityConfiguration { csrf -> csrf.ignoringRequestMatchers( request -> { + String uri = request.getRequestURI(); + + // Ignore CSRF for auth endpoints + if (uri.startsWith("/api/v1/auth/")) { + return true; + } + String apiKey = request.getHeader("X-API-KEY"); // If there's no API key, don't ignore CSRF // (return false) @@ -238,9 +236,12 @@ public class SecurityConfiguration { : uri; return trimmedUri.startsWith("/login") || trimmedUri.startsWith("/oauth") + || trimmedUri.startsWith("/oauth2") || trimmedUri.startsWith("/saml2") || trimmedUri.endsWith(".svg") || trimmedUri.startsWith("/register") + || trimmedUri.startsWith("/signup") + || trimmedUri.startsWith("/auth/callback") || trimmedUri.startsWith("/error") || trimmedUri.startsWith("/images/") || trimmedUri.startsWith("/public/") @@ -252,6 +253,16 @@ public class SecurityConfiguration { || trimmedUri.startsWith("/favicon") || trimmedUri.startsWith( "/api/v1/info/status") + || trimmedUri.startsWith("/api/v1/config") + || trimmedUri.startsWith( + "/api/v1/auth/register") + || trimmedUri.startsWith( + "/api/v1/user/register") + || trimmedUri.startsWith( + "/api/v1/auth/login") + || trimmedUri.startsWith( + "/api/v1/auth/refresh") + || trimmedUri.startsWith("/api/v1/auth/me") || trimmedUri.startsWith("/v1/api-docs") || uri.contains("/v1/api-docs"); }) @@ -277,33 +288,40 @@ public class SecurityConfiguration { // Handle OAUTH2 Logins if (securityProperties.isOauth2Active()) { http.oauth2Login( - oauth2 -> - oauth2.loginPage("/oauth2") - /* - This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database. - If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser' - is set as true, else login fails with an error message advising the same. - */ - .successHandler( - new CustomOAuth2AuthenticationSuccessHandler( - loginAttemptService, - securityProperties.getOauth2(), - userService, - jwtService)) - .failureHandler( - new CustomOAuth2AuthenticationFailureHandler()) - // Add existing Authorities from the database - .userInfoEndpoint( - userInfoEndpoint -> - userInfoEndpoint - .oidcUserService( - new CustomOAuth2UserService( - securityProperties, - userService, - loginAttemptService)) - .userAuthoritiesMapper( - oAuth2userAuthoritiesMapper)) - .permitAll()); + oauth2 -> { + // v1: Use /oauth2 as login page for Thymeleaf templates + if (!v2Enabled) { + oauth2.loginPage("/oauth2"); + } + + // v2: Don't set loginPage, let default OAuth2 flow handle it + oauth2 + /* + This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database. + If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser' + is set as true, else login fails with an error message advising the same. + */ + .successHandler( + new CustomOAuth2AuthenticationSuccessHandler( + loginAttemptService, + securityProperties.getOauth2(), + userService, + jwtService)) + .failureHandler(new CustomOAuth2AuthenticationFailureHandler()) + // Add existing Authorities from the database + .userInfoEndpoint( + userInfoEndpoint -> + userInfoEndpoint + .oidcUserService( + new CustomOAuth2UserService( + securityProperties + .getOauth2(), + userService, + loginAttemptService)) + .userAuthoritiesMapper( + oAuth2userAuthoritiesMapper)) + .permitAll(); + }); } // Handle SAML if (securityProperties.isSaml2Active() && runningProOrHigher) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index d63495021..27c924ae4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -1,19 +1,27 @@ package stirling.software.proprietary.security.controller.api; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; +import org.springframework.boot.SpringApplication; +import org.springframework.context.ApplicationContext; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @@ -33,7 +41,9 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.annotations.api.AdminApi; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.AppArgsCapture; import stirling.software.common.util.GeneralUtils; +import stirling.software.common.util.JarPathUtil; import stirling.software.common.util.RegexPatternUtils; import stirling.software.proprietary.security.model.api.admin.SettingValueResponse; import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest; @@ -47,6 +57,7 @@ public class AdminSettingsController { private final ApplicationProperties applicationProperties; private final ObjectMapper objectMapper; + private final ApplicationContext applicationContext; // Track settings that have been modified but not yet applied (require restart) private static final ConcurrentHashMap pendingChanges = @@ -203,8 +214,8 @@ public class AdminSettingsController { @Operation( summary = "Get specific settings section", description = - "Retrieve settings for a specific section (e.g., security, system, ui). Admin" - + " access required.") + "Retrieve settings for a specific section (e.g., security, system, ui). " + + "By default includes pending changes with awaitingRestart flags. Admin access required.") @ApiResponses( value = { @ApiResponse( @@ -215,7 +226,9 @@ public class AdminSettingsController { responseCode = "403", description = "Access denied - Admin role required") }) - public ResponseEntity getSettingsSection(@PathVariable String sectionName) { + public ResponseEntity getSettingsSection( + @PathVariable String sectionName, + @RequestParam(defaultValue = "true") boolean includePending) { try { Object sectionData = getSectionData(sectionName); if (sectionData == null) { @@ -226,8 +239,24 @@ public class AdminSettingsController { + ". Valid sections: " + String.join(", ", VALID_SECTION_NAMES)); } - log.debug("Admin requested settings section: {}", sectionName); - return ResponseEntity.ok(sectionData); + + // Convert to Map for manipulation + @SuppressWarnings("unchecked") + Map sectionMap = objectMapper.convertValue(sectionData, Map.class); + + if (includePending && !pendingChanges.isEmpty()) { + // Add pending changes block for this section + Map sectionPending = extractPendingForSection(sectionName); + if (!sectionPending.isEmpty()) { + sectionMap.put("_pending", sectionPending); + } + } + + log.debug( + "Admin requested settings section: {} (includePending={})", + sectionName, + includePending); + return ResponseEntity.ok(sectionMap); } catch (IllegalArgumentException e) { log.error("Invalid section name {}: {}", sectionName, e.getMessage(), e); return ResponseEntity.status(HttpStatus.BAD_REQUEST) @@ -401,6 +430,101 @@ public class AdminSettingsController { } } + @PostMapping("/restart") + @Operation( + summary = "Restart the application", + description = + "Triggers a graceful restart of the Spring Boot application to apply pending settings changes. Uses a restart helper to ensure proper restart. Admin access required.") + @ApiResponses( + value = { + @ApiResponse(responseCode = "200", description = "Restart initiated successfully"), + @ApiResponse( + responseCode = "403", + description = "Access denied - Admin role required"), + @ApiResponse(responseCode = "500", description = "Failed to initiate restart") + }) + public ResponseEntity restartApplication() { + try { + log.warn("Admin initiated application restart"); + + // Get paths to current JAR and restart helper + Path appJar = JarPathUtil.currentJar(); + Path helperJar = JarPathUtil.restartHelperJar(); + + if (appJar == null) { + log.error("Cannot restart: not running from JAR (likely development mode)"); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body( + "Restart not available in development mode. Please restart the application manually."); + } + + if (helperJar == null || !Files.isRegularFile(helperJar)) { + log.error("Cannot restart: restart-helper.jar not found at expected location"); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body("Restart helper not found. Please restart the application manually."); + } + + // Get current application arguments + List appArgs = AppArgsCapture.APP_ARGS.get(); + + // Write args to temp file to avoid command-line quoting issues + Path argsFile = Files.createTempFile("stirling-app-args-", ".txt"); + Files.write(argsFile, appArgs, StandardCharsets.UTF_8); + + // Get current process PID and java executable + long pid = ProcessHandle.current().pid(); + String javaBin = JarPathUtil.javaExecutable(); + + // Build command to launch restart helper + List cmd = new ArrayList<>(); + cmd.add(javaBin); + cmd.add("-jar"); + cmd.add(helperJar.toString()); + cmd.add("--pid"); + cmd.add(Long.toString(pid)); + cmd.add("--app"); + cmd.add(appJar.toString()); + cmd.add("--argsFile"); + cmd.add(argsFile.toString()); + cmd.add("--backoffMs"); + cmd.add("1000"); + + log.info("Launching restart helper: {}", String.join(" ", cmd)); + + // Launch restart helper process + new ProcessBuilder(cmd) + .directory(appJar.getParent().toFile()) + .inheritIO() // Forward logs + .start(); + + // Clear pending changes since we're restarting + pendingChanges.clear(); + + // Give the HTTP response time to complete, then exit + new Thread( + () -> { + try { + Thread.sleep(1000); + log.info("Shutting down for restart..."); + SpringApplication.exit(applicationContext, () -> 0); + System.exit(0); + } catch (InterruptedException e) { + log.error("Restart interrupted: {}", e.getMessage(), e); + Thread.currentThread().interrupt(); + } + }) + .start(); + + return ResponseEntity.ok( + "Application restart initiated. The server will be back online shortly."); + + } catch (Exception e) { + log.error("Failed to initiate restart: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Failed to initiate application restart: " + e.getMessage()); + } + } + private Object getSectionData(String sectionName) { if (sectionName == null || sectionName.trim().isEmpty()) { return null; @@ -639,4 +763,62 @@ public class AdminSettingsController { return mergedSettings; } + + /** + * Extract pending changes for a specific section + * + * @param sectionName The section name (e.g., "security", "system") + * @return Map of pending changes with nested structure for this section + */ + @SuppressWarnings("unchecked") + private Map extractPendingForSection(String sectionName) { + Map result = new HashMap<>(); + String sectionPrefix = sectionName.toLowerCase() + "."; + + // Find all pending changes for this section + for (Map.Entry entry : pendingChanges.entrySet()) { + String pendingKey = entry.getKey(); + + if (pendingKey.toLowerCase().startsWith(sectionPrefix)) { + // Extract the path within the section (e.g., "security.enableLogin" -> + // "enableLogin") + String pathInSection = pendingKey.substring(sectionPrefix.length()); + Object pendingValue = entry.getValue(); + + // Build nested structure from dot notation + setNestedValue(result, pathInSection, pendingValue); + } + } + + return result; + } + + /** + * Set a value in a nested map using dot notation + * + * @param map The root map + * @param dotPath The dot notation path (e.g., "oauth2.clientSecret") + * @param value The value to set + */ + @SuppressWarnings("unchecked") + private void setNestedValue(Map map, String dotPath, Object value) { + String[] parts = dotPath.split("\\."); + Map current = map; + + // Navigate/create nested maps for all parts except the last + for (int i = 0; i < parts.length - 1; i++) { + String part = parts[i]; + Object nested = current.get(part); + + if (!(nested instanceof Map)) { + nested = new HashMap(); + current.put(part, nested); + } + + current = (Map) nested; + } + + // Set the final value + current.put(parts[parts.length - 1], value); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java new file mode 100644 index 000000000..0dd8ee4bf --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -0,0 +1,238 @@ +package stirling.software.proprietary.security.controller.api; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.bind.annotation.*; + +import io.swagger.v3.oas.annotations.tags.Tag; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.security.model.AuthenticationType; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.model.api.user.UsernameAndPass; +import stirling.software.proprietary.security.service.CustomUserDetailsService; +import stirling.software.proprietary.security.service.JwtServiceInterface; +import stirling.software.proprietary.security.service.UserService; + +/** REST API Controller for authentication operations. */ +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +@Slf4j +@Tag(name = "Authentication", description = "Endpoints for user authentication and registration") +public class AuthController { + + private final UserService userService; + private final JwtServiceInterface jwtService; + private final CustomUserDetailsService userDetailsService; + + /** + * Login endpoint - replaces Supabase signInWithPassword + * + * @param request Login credentials (email/username and password) + * @param response HTTP response to set JWT cookie + * @return User and session information + */ + @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") + @PostMapping("/login") + public ResponseEntity login( + @RequestBody UsernameAndPass request, HttpServletResponse response) { + try { + // Validate input parameters + if (request.getUsername() == null || request.getUsername().trim().isEmpty()) { + log.warn("Login attempt with null or empty username"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Username is required")); + } + + if (request.getPassword() == null || request.getPassword().isEmpty()) { + log.warn( + "Login attempt with null or empty password for user: {}", + request.getUsername()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Password is required")); + } + + log.debug("Login attempt for user: {}", request.getUsername()); + + UserDetails userDetails = + userDetailsService.loadUserByUsername(request.getUsername().trim()); + User user = (User) userDetails; + + if (!userService.isPasswordCorrect(user, request.getPassword())) { + log.warn("Invalid password for user: {}", request.getUsername()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Invalid credentials")); + } + + if (!user.isEnabled()) { + log.warn("Disabled user attempted login: {}", request.getUsername()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "User account is disabled")); + } + + Map claims = new HashMap<>(); + claims.put("authType", AuthenticationType.WEB.toString()); + claims.put("role", user.getRolesAsString()); + + String token = jwtService.generateToken(user.getUsername(), claims); + + log.info("Login successful for user: {}", request.getUsername()); + + return ResponseEntity.ok( + Map.of( + "user", buildUserResponse(user), + "session", Map.of("access_token", token, "expires_in", 3600))); + + } catch (UsernameNotFoundException e) { + log.warn("User not found: {}", request.getUsername()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Invalid username or password")); + } catch (AuthenticationException e) { + log.error("Authentication failed for user: {}", request.getUsername(), e); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Invalid credentials")); + } catch (Exception e) { + log.error("Login error for user: {}", request.getUsername(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Internal server error")); + } + } + + /** + * Get current user + * + * @return Current authenticated user information + */ + @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") + @GetMapping("/me") + public ResponseEntity getCurrentUser() { + try { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + + if (auth == null + || !auth.isAuthenticated() + || auth.getPrincipal().equals("anonymousUser")) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Not authenticated")); + } + + UserDetails userDetails = (UserDetails) auth.getPrincipal(); + User user = (User) userDetails; + + return ResponseEntity.ok(Map.of("user", buildUserResponse(user))); + + } catch (Exception e) { + log.error("Get current user error", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Internal server error")); + } + } + + /** + * Logout endpoint + * + * @param response HTTP response + * @return Success message + */ + @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") + @PostMapping("/logout") + public ResponseEntity logout(HttpServletResponse response) { + try { + SecurityContextHolder.clearContext(); + + log.debug("User logged out successfully"); + + return ResponseEntity.ok(Map.of("message", "Logged out successfully")); + + } catch (Exception e) { + log.error("Logout error", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Internal server error")); + } + } + + /** + * Refresh token + * + * @param request HTTP request containing current JWT cookie + * @param response HTTP response to set new JWT cookie + * @return New token information + */ + @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") + @PostMapping("/refresh") + public ResponseEntity refresh(HttpServletRequest request, HttpServletResponse response) { + try { + String token = jwtService.extractToken(request); + + if (token == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "No token found")); + } + + jwtService.validateToken(token); + String username = jwtService.extractUsername(token); + + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + User user = (User) userDetails; + + Map claims = new HashMap<>(); + claims.put("authType", user.getAuthenticationType()); + claims.put("role", user.getRolesAsString()); + + String newToken = jwtService.generateToken(username, claims); + + log.debug("Token refreshed for user: {}", username); + + return ResponseEntity.ok(Map.of("access_token", newToken, "expires_in", 3600)); + + } catch (Exception e) { + log.error("Token refresh error", e); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Token refresh failed")); + } + } + + /** + * Helper method to build user response object + * + * @param user User entity + * @return Map containing user information + */ + private Map buildUserResponse(User user) { + Map userMap = new HashMap<>(); + userMap.put("id", user.getId()); + userMap.put("email", user.getUsername()); // Use username as email + userMap.put("username", user.getUsername()); + userMap.put("role", user.getRolesAsString()); + userMap.put("enabled", user.isEnabled()); + + // Add metadata for OAuth compatibility + Map appMetadata = new HashMap<>(); + appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider + userMap.put("app_metadata", appMetadata); + + return userMap; + } + + // =========================== + // Request/Response DTOs + // =========================== + + /** Login request DTO */ + public record LoginRequest(String email, String password) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/DatabaseController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/DatabaseController.java index d2974dbe8..ecf9b1b19 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/DatabaseController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/DatabaseController.java @@ -2,7 +2,6 @@ package stirling.software.proprietary.security.controller.api; import java.io.IOException; import java.io.InputStream; -import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; @@ -16,7 +15,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.servlet.mvc.support.RedirectAttributes; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; @@ -42,15 +40,19 @@ public class DatabaseController { summary = "Import a database backup file", description = "Uploads and imports a database backup SQL file.") @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "import-database") - public String importDatabase( + public ResponseEntity importDatabase( @Parameter(description = "SQL file to import", required = true) @RequestParam("fileInput") - MultipartFile file, - RedirectAttributes redirectAttributes) + MultipartFile file) throws IOException { if (file == null || file.isEmpty()) { - redirectAttributes.addAttribute("error", "fileNullOrEmpty"); - return "redirect:/database"; + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + java.util.Map.of( + "error", + "fileNullOrEmpty", + "message", + "File is null or empty")); } log.info("Received file: {}", file.getOriginalFilename()); Path tempTemplatePath = Files.createTempFile("backup_", ".sql"); @@ -58,15 +60,31 @@ public class DatabaseController { Files.copy(in, tempTemplatePath, StandardCopyOption.REPLACE_EXISTING); boolean importSuccess = databaseService.importDatabaseFromUI(tempTemplatePath); if (importSuccess) { - redirectAttributes.addAttribute("infoMessage", "importIntoDatabaseSuccessed"); + return ResponseEntity.ok( + java.util.Map.of( + "message", + "importIntoDatabaseSuccessed", + "description", + "Database imported successfully")); } else { - redirectAttributes.addAttribute("error", "failedImportFile"); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + java.util.Map.of( + "error", + "failedImportFile", + "message", + "Failed to import database file")); } } catch (Exception e) { log.error("Error importing database: {}", e.getMessage()); - redirectAttributes.addAttribute("error", "failedImportFile"); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + java.util.Map.of( + "error", + "failedImportFile", + "message", + "Failed to import database: " + e.getMessage())); } - return "redirect:/database"; } @Hidden @@ -74,11 +92,17 @@ public class DatabaseController { summary = "Import database backup by filename", description = "Imports a database backup file from the server using its file name.") @GetMapping("/import-database-file/{fileName}") - public String importDatabaseFromBackupUI( + public ResponseEntity importDatabaseFromBackupUI( @Parameter(description = "Name of the file to import", required = true) @PathVariable String fileName) { if (fileName == null || fileName.isEmpty()) { - return "redirect:/database?error=fileNullOrEmpty"; + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + java.util.Map.of( + "error", + "fileNullOrEmpty", + "message", + "File name is null or empty")); } // Check if the file exists in the backup list boolean fileExists = @@ -86,14 +110,31 @@ public class DatabaseController { .anyMatch(backup -> backup.getFileName().equals(fileName)); if (!fileExists) { log.error("File {} not found in backup list", fileName); - return "redirect:/database?error=fileNotFound"; + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body( + java.util.Map.of( + "error", + "fileNotFound", + "message", + "File not found in backup list")); } log.info("Received file: {}", fileName); if (databaseService.importDatabaseFromUI(fileName)) { log.info("File {} imported to database", fileName); - return "redirect:/database?infoMessage=importIntoDatabaseSuccessed"; + return ResponseEntity.ok( + java.util.Map.of( + "message", + "importIntoDatabaseSuccessed", + "description", + "Database backup imported successfully")); } - return "redirect:/database?error=failedImportFile"; + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + java.util.Map.of( + "error", + "failedImportFile", + "message", + "Failed to import database file")); } @Hidden @@ -101,24 +142,42 @@ public class DatabaseController { summary = "Delete a database backup file", description = "Deletes a specified database backup file from the server.") @GetMapping("/delete/{fileName}") - public String deleteFile( + public ResponseEntity deleteFile( @Parameter(description = "Name of the file to delete", required = true) @PathVariable String fileName) { if (fileName == null || fileName.isEmpty()) { - throw new IllegalArgumentException("File must not be null or empty"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + java.util.Map.of( + "error", + "invalidFileName", + "message", + "File must not be null or empty")); } try { if (databaseService.deleteBackupFile(fileName)) { log.info("Deleted file: {}", fileName); + return ResponseEntity.ok(java.util.Map.of("message", "File deleted successfully")); } else { log.error("Failed to delete file: {}", fileName); - return "redirect:/database?error=failedToDeleteFile"; + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + java.util.Map.of( + "error", + "failedToDeleteFile", + "message", + "Failed to delete backup file")); } } catch (IOException e) { log.error("Error deleting file: {}", e.getMessage()); - return "redirect:/database?error=" + e.getMessage(); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + java.util.Map.of( + "error", + "deleteError", + "message", + "Error deleting file: " + e.getMessage())); } - return "redirect:/database"; } @Hidden @@ -142,22 +201,29 @@ public class DatabaseController { .body(resource); } catch (IOException e) { log.error("Error downloading file: {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.SEE_OTHER) - .location(URI.create("/database?error=downloadFailed")) - .build(); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + java.util.Map.of( + "error", + "downloadFailed", + "message", + "Failed to download file: " + e.getMessage())); } } @Operation( summary = "Create a database backup", - description = - "This endpoint triggers the creation of a database backup and redirects to the" - + " database management page.") + description = "This endpoint triggers the creation of a database backup.") @GetMapping("/createDatabaseBackup") - public String createDatabaseBackup() { + public ResponseEntity createDatabaseBackup() { log.info("Starting database backup creation..."); databaseService.exportDatabase(); log.info("Database backup successfully created."); - return "redirect:/database?infoMessage=backupCreated"; + return ResponseEntity.ok( + java.util.Map.of( + "message", + "backupCreated", + "description", + "Database backup created successfully")); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java index 84066ec69..e4e9c1e87 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java @@ -1,10 +1,12 @@ package stirling.software.proprietary.security.controller.api; +import java.util.Map; import java.util.Optional; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; -import org.springframework.web.servlet.view.RedirectView; import jakarta.transaction.Transactional; @@ -30,98 +32,113 @@ public class TeamController { @PreAuthorize("hasRole('ROLE_ADMIN')") @PostMapping("/create") - public RedirectView createTeam(@RequestParam("name") String name) { + public ResponseEntity createTeam(@RequestParam("name") String name) { if (teamRepository.existsByNameIgnoreCase(name)) { - return new RedirectView("/teams?messageType=teamExists"); + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "Team name already exists.")); } Team team = new Team(); team.setName(name); teamRepository.save(team); - return new RedirectView("/teams?messageType=teamCreated"); + return ResponseEntity.ok(Map.of("message", "Team created successfully")); } @PreAuthorize("hasRole('ROLE_ADMIN')") @PostMapping("/rename") - public RedirectView renameTeam( + public ResponseEntity renameTeam( @RequestParam("teamId") Long teamId, @RequestParam("newName") String newName) { Optional existing = teamRepository.findById(teamId); if (existing.isEmpty()) { - return new RedirectView("/teams?messageType=teamNotFound"); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "Team not found.")); } if (teamRepository.existsByNameIgnoreCase(newName)) { - return new RedirectView("/teams?messageType=teamNameExists"); + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "Team name already exists.")); } Team team = existing.get(); // Prevent renaming the Internal team if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) { - return new RedirectView("/teams?messageType=internalTeamNotAccessible"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot rename Internal team.")); } team.setName(newName); teamRepository.save(team); - return new RedirectView("/teams?messageType=teamRenamed"); + return ResponseEntity.ok(Map.of("message", "Team renamed successfully")); } @PreAuthorize("hasRole('ROLE_ADMIN')") @PostMapping("/delete") @Transactional - public RedirectView deleteTeam(@RequestParam("teamId") Long teamId) { + public ResponseEntity deleteTeam(@RequestParam("teamId") Long teamId) { Optional teamOpt = teamRepository.findById(teamId); if (teamOpt.isEmpty()) { - return new RedirectView("/teams?messageType=teamNotFound"); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "Team not found.")); } Team team = teamOpt.get(); // Prevent deleting the Internal team if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) { - return new RedirectView("/teams?messageType=internalTeamNotAccessible"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot delete Internal team.")); } long memberCount = userRepository.countByTeam(team); if (memberCount > 0) { - return new RedirectView("/teams?messageType=teamHasUsers"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + Map.of( + "error", + "Team must be empty before deletion. Please remove all members first.")); } teamRepository.delete(team); - return new RedirectView("/teams?messageType=teamDeleted"); + return ResponseEntity.ok(Map.of("message", "Team deleted successfully")); } @PreAuthorize("hasRole('ROLE_ADMIN')") @PostMapping("/addUser") @Transactional - public RedirectView addUserToTeam( + public ResponseEntity addUserToTeam( @RequestParam("teamId") Long teamId, @RequestParam("userId") Long userId) { // Find the team - Team team = - teamRepository - .findById(teamId) - .orElseThrow(() -> new RuntimeException("Team not found")); + Optional teamOpt = teamRepository.findById(teamId); + if (teamOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "Team not found.")); + } + Team team = teamOpt.get(); // Prevent adding users to the Internal team if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) { - return new RedirectView("/teams?error=internalTeamNotAccessible"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot add users to Internal team.")); } // Find the user - User user = - userRepository - .findById(userId) - .orElseThrow(() -> new RuntimeException("User not found")); + Optional userOpt = userRepository.findById(userId); + if (userOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "User not found.")); + } + User user = userOpt.get(); // Check if user is in the Internal team - prevent moving them if (user.getTeam() != null && user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) { - return new RedirectView("/teams/" + teamId + "?error=cannotMoveInternalUsers"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot move users from Internal team.")); } // Assign user to team user.setTeam(team); userRepository.save(user); - // Redirect back to team details page - return new RedirectView("/teams/" + teamId + "?messageType=userAdded"); + return ResponseEntity.ok(Map.of("message", "User added to team successfully")); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index 3b9386dc1..a1cd0a928 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.security.controller.api; import java.io.IOException; import java.security.Principal; import java.sql.SQLException; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -15,10 +16,7 @@ import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; -import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; -import org.springframework.web.servlet.mvc.support.RedirectAttributes; -import org.springframework.web.servlet.view.RedirectView; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -41,6 +39,7 @@ import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.api.user.UsernameAndPass; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; +import stirling.software.proprietary.security.service.EmailService; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.session.SessionPersistentRegistry; @@ -56,128 +55,218 @@ public class UserController { private final ApplicationProperties applicationProperties; private final TeamRepository teamRepository; private final UserRepository userRepository; + private final Optional emailService; @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/register") - public String register(@ModelAttribute UsernameAndPass requestModel, Model model) + public ResponseEntity register(@RequestBody UsernameAndPass usernameAndPass) throws SQLException, UnsupportedProviderException { - if (userService.usernameExistsIgnoreCase(requestModel.getUsername())) { - model.addAttribute("error", "Username already exists"); - return "register"; - } try { + log.debug("Registration attempt for user: {}", usernameAndPass.getUsername()); + + if (userService.usernameExistsIgnoreCase(usernameAndPass.getUsername())) { + log.warn( + "Registration failed: username already exists: {}", + usernameAndPass.getUsername()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "User already exists")); + } + + if (!userService.isUsernameValid(usernameAndPass.getUsername())) { + log.warn( + "Registration failed: invalid username format: {}", + usernameAndPass.getUsername()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Invalid username format")); + } + + if (usernameAndPass.getPassword() == null + || usernameAndPass.getPassword().length() < 6) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Password must be at least 6 characters")); + } + Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null); - userService.saveUser( - requestModel.getUsername(), - requestModel.getPassword(), - team, - Role.USER.getRoleId(), - false); + User user = + userService.saveUser( + usernameAndPass.getUsername(), + usernameAndPass.getPassword(), + team, + Role.USER.getRoleId(), + false); + + log.info("User registered successfully: {}", usernameAndPass.getUsername()); + + return ResponseEntity.status(HttpStatus.CREATED) + .body( + Map.of( + "user", + buildUserResponse(user), + "message", + "Account created successfully. Please log in.")); + } catch (IllegalArgumentException e) { - return "redirect:/login?messageType=invalidUsername"; + log.error("Registration validation error: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", e.getMessage())); + } catch (Exception e) { + log.error("Registration error for user: {}", usernameAndPass.getUsername(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Registration failed: " + e.getMessage())); } - return "redirect:/login?registered=true"; + } + + /** + * Helper method to build user response object + * + * @param user User entity + * @return Map containing user information + */ + private Map buildUserResponse(User user) { + Map userMap = new HashMap<>(); + userMap.put("id", user.getId()); + userMap.put("email", user.getUsername()); // Use username as email + userMap.put("username", user.getUsername()); + userMap.put("role", user.getRolesAsString()); + userMap.put("enabled", user.isEnabled()); + + // Add metadata for OAuth compatibility + Map appMetadata = new HashMap<>(); + appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider + userMap.put("app_metadata", appMetadata); + + return userMap; } @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/change-username") @Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC) - public RedirectView changeUsername( + public ResponseEntity changeUsername( Principal principal, @RequestParam(name = "currentPasswordChangeUsername") String currentPassword, @RequestParam(name = "newUsername") String newUsername, HttpServletRequest request, - HttpServletResponse response, - RedirectAttributes redirectAttributes) + HttpServletResponse response) throws IOException, SQLException, UnsupportedProviderException { if (!userService.isUsernameValid(newUsername)) { - return new RedirectView("/account?messageType=invalidUsername", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "invalidUsername", "message", "Invalid username format")); } if (principal == null) { - return new RedirectView("/account?messageType=notAuthenticated", true); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "notAuthenticated", "message", "User not authenticated")); } // The username MUST be unique when renaming Optional userOpt = userService.findByUsername(principal.getName()); if (userOpt == null || userOpt.isEmpty()) { - return new RedirectView("/account?messageType=userNotFound", true); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "userNotFound", "message", "User not found")); } User user = userOpt.get(); if (user.getUsername().equals(newUsername)) { - return new RedirectView("/account?messageType=usernameExists", true); + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "usernameExists", "message", "Username already in use")); } if (!userService.isPasswordCorrect(user, currentPassword)) { - return new RedirectView("/account?messageType=incorrectPassword", true); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "incorrectPassword", "message", "Incorrect password")); } if (!user.getUsername().equals(newUsername) && userService.usernameExists(newUsername)) { - return new RedirectView("/account?messageType=usernameExists", true); + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "usernameExists", "message", "Username already exists")); } if (newUsername != null && newUsername.length() > 0) { try { userService.changeUsername(user, newUsername); } catch (IllegalArgumentException e) { - return new RedirectView("/account?messageType=invalidUsername", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + Map.of( + "error", + "invalidUsername", + "message", + "Invalid username format")); } } // Logout using Spring's utility new SecurityContextLogoutHandler().logout(request, response, null); - return new RedirectView(LOGIN_MESSAGETYPE_CREDSUPDATED, true); + return ResponseEntity.ok( + Map.of( + "message", + "credsUpdated", + "description", + "Username changed successfully. Please log in again.")); } @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/change-password-on-login") @Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC) - public RedirectView changePasswordOnLogin( + public ResponseEntity changePasswordOnLogin( Principal principal, @RequestParam(name = "currentPassword") String currentPassword, @RequestParam(name = "newPassword") String newPassword, HttpServletRequest request, - HttpServletResponse response, - RedirectAttributes redirectAttributes) + HttpServletResponse response) throws SQLException, UnsupportedProviderException { if (principal == null) { - return new RedirectView("/change-creds?messageType=notAuthenticated", true); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "notAuthenticated", "message", "User not authenticated")); } Optional userOpt = userService.findByUsernameIgnoreCase(principal.getName()); if (userOpt.isEmpty()) { - return new RedirectView("/change-creds?messageType=userNotFound", true); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "userNotFound", "message", "User not found")); } User user = userOpt.get(); if (!userService.isPasswordCorrect(user, currentPassword)) { - return new RedirectView("/change-creds?messageType=incorrectPassword", true); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "incorrectPassword", "message", "Incorrect password")); } userService.changePassword(user, newPassword); userService.changeFirstUse(user, false); // Logout using Spring's utility new SecurityContextLogoutHandler().logout(request, response, null); - return new RedirectView(LOGIN_MESSAGETYPE_CREDSUPDATED, true); + return ResponseEntity.ok( + Map.of( + "message", + "credsUpdated", + "description", + "Password changed successfully. Please log in again.")); } @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/change-password") @Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC) - public RedirectView changePassword( + public ResponseEntity changePassword( Principal principal, @RequestParam(name = "currentPassword") String currentPassword, @RequestParam(name = "newPassword") String newPassword, HttpServletRequest request, - HttpServletResponse response, - RedirectAttributes redirectAttributes) + HttpServletResponse response) throws SQLException, UnsupportedProviderException { if (principal == null) { - return new RedirectView("/account?messageType=notAuthenticated", true); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "notAuthenticated", "message", "User not authenticated")); } Optional userOpt = userService.findByUsernameIgnoreCase(principal.getName()); if (userOpt.isEmpty()) { - return new RedirectView("/account?messageType=userNotFound", true); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "userNotFound", "message", "User not found")); } User user = userOpt.get(); if (!userService.isPasswordCorrect(user, currentPassword)) { - return new RedirectView("/account?messageType=incorrectPassword", true); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "incorrectPassword", "message", "Incorrect password")); } userService.changePassword(user, newPassword); // Logout using Spring's utility new SecurityContextLogoutHandler().logout(request, response, null); - return new RedirectView(LOGIN_MESSAGETYPE_CREDSUPDATED, true); + return ResponseEntity.ok( + Map.of( + "message", + "credsUpdated", + "description", + "Password changed successfully. Please log in again.")); } @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @@ -195,23 +284,23 @@ public class UserController { * * Keys not listed above will be ignored. * @param principal The currently authenticated user. - * @return A redirect string to the account page after updating the settings. + * @return A ResponseEntity with success or error information. * @throws SQLException If a database error occurs. * @throws UnsupportedProviderException If the operation is not supported for the user's * provider. */ - public String updateUserSettings(@RequestBody Map updates, Principal principal) + public ResponseEntity updateUserSettings( + @RequestBody Map updates, Principal principal) throws SQLException, UnsupportedProviderException { log.debug("Processed updates: {}", updates); // Assuming you have a method in userService to update the settings for a user userService.updateUserSettings(principal.getName(), updates); - // Redirect to a page of your choice after updating - return "redirect:/account"; + return ResponseEntity.ok(Map.of("message", "Settings updated successfully")); } @PreAuthorize("hasRole('ROLE_ADMIN')") @PostMapping("/admin/saveUser") - public RedirectView saveUser( + public ResponseEntity saveUser( @RequestParam(name = "username", required = true) String username, @RequestParam(name = "password", required = false) String password, @RequestParam(name = "role") String role, @@ -221,33 +310,42 @@ public class UserController { boolean forceChange) throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!userService.isUsernameValid(username)) { - return new RedirectView("/adminSettings?messageType=invalidUsername", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + Map.of( + "error", + "Invalid username format. Username must be 3-50 characters.")); } if (applicationProperties.getPremium().isEnabled() && applicationProperties.getPremium().getMaxUsers() <= userService.getTotalUsersCount()) { - return new RedirectView("/adminSettings?messageType=maxUsersReached", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Maximum number of users reached for your license.")); } Optional userOpt = userService.findByUsernameIgnoreCase(username); if (userOpt.isPresent()) { User user = userOpt.get(); if (user.getUsername().equalsIgnoreCase(username)) { - return new RedirectView("/adminSettings?messageType=usernameExists", true); + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "Username already exists.")); } } if (userService.usernameExistsIgnoreCase(username)) { - return new RedirectView("/adminSettings?messageType=usernameExists", true); + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "Username already exists.")); } try { // Validate the role Role roleEnum = Role.fromString(role); if (roleEnum == Role.INTERNAL_API_USER) { // If the role is INTERNAL_API_USER, reject the request - return new RedirectView("/adminSettings?messageType=invalidRole", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot assign INTERNAL_API_USER role.")); } } catch (IllegalArgumentException e) { - // If the role ID is not valid, redirect with an error message - return new RedirectView("/adminSettings?messageType=invalidRole", true); + // If the role ID is not valid, return error + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Invalid role specified.")); } // Use teamId if provided, otherwise use default team @@ -263,28 +361,144 @@ public class UserController { Team selectedTeam = teamRepository.findById(effectiveTeamId).orElse(null); if (selectedTeam != null && TeamService.INTERNAL_TEAM_NAME.equals(selectedTeam.getName())) { - return new RedirectView( - "/adminSettings?messageType=internalTeamNotAccessible", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot assign users to Internal team.")); } } if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) { userService.saveUser(username, AuthenticationType.SSO, effectiveTeamId, role); } else { - if (password.isBlank()) { - return new RedirectView("/adminSettings?messageType=invalidPassword", true); + if (password == null || password.isBlank()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Password is required.")); + } + if (password.length() < 6) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Password must be at least 6 characters.")); } userService.saveUser(username, password, effectiveTeamId, role, forceChange); } - return new RedirectView( - "/adminSettings", // Redirect to account page after adding the user - true); + return ResponseEntity.ok(Map.of("message", "User created successfully")); + } + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PostMapping("/admin/inviteUsers") + public ResponseEntity inviteUsers( + @RequestParam(name = "emails", required = true) String emails, + @RequestParam(name = "role", defaultValue = "ROLE_USER") String role, + @RequestParam(name = "teamId", required = false) Long teamId) + throws SQLException, UnsupportedProviderException { + + // Check if email invites are enabled + if (!applicationProperties.getMail().isEnableInvites()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Email invites are not enabled")); + } + + // Check if email service is available + if (!emailService.isPresent()) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body( + Map.of( + "error", + "Email service is not configured. Please configure SMTP settings.")); + } + + // Parse comma-separated email addresses + String[] emailArray = emails.split(","); + if (emailArray.length == 0) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "At least one email address is required")); + } + + // Check license limits + if (applicationProperties.getPremium().isEnabled()) { + long currentUserCount = userService.getTotalUsersCount(); + int maxUsers = applicationProperties.getPremium().getMaxUsers(); + long availableSlots = maxUsers - currentUserCount; + if (availableSlots < emailArray.length) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + Map.of( + "error", + "Not enough user slots available. Available: " + + availableSlots + + ", Requested: " + + emailArray.length)); + } + } + + // Validate role + try { + Role roleEnum = Role.fromString(role); + if (roleEnum == Role.INTERNAL_API_USER) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot assign INTERNAL_API_USER role")); + } + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Invalid role specified")); + } + + // Determine team + Long effectiveTeamId = teamId; + if (effectiveTeamId == null) { + Team defaultTeam = + teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null); + if (defaultTeam != null) { + effectiveTeamId = defaultTeam.getId(); + } + } else { + Team selectedTeam = teamRepository.findById(effectiveTeamId).orElse(null); + if (selectedTeam != null + && TeamService.INTERNAL_TEAM_NAME.equals(selectedTeam.getName())) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot assign users to Internal team")); + } + } + + int successCount = 0; + int failureCount = 0; + StringBuilder errors = new StringBuilder(); + + // Process each email + for (String email : emailArray) { + email = email.trim(); + if (email.isEmpty()) { + continue; + } + + InviteResult result = processEmailInvite(email, effectiveTeamId, role); + if (result.isSuccess()) { + successCount++; + } else { + failureCount++; + errors.append(result.getErrorMessage()).append("; "); + } + } + + Map response = new HashMap<>(); + response.put("successCount", successCount); + response.put("failureCount", failureCount); + + if (failureCount > 0) { + response.put("errors", errors.toString()); + } + + if (successCount > 0) { + response.put("message", successCount + " user(s) invited successfully"); + return ResponseEntity.ok(response); + } else { + response.put("error", "Failed to invite any users"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } } @PreAuthorize("hasRole('ROLE_ADMIN')") @PostMapping("/admin/changeRole") @Transactional - public RedirectView changeRole( + public ResponseEntity changeRole( @RequestParam(name = "username") String username, @RequestParam(name = "role") String role, @RequestParam(name = "teamId", required = false) Long teamId, @@ -292,27 +506,32 @@ public class UserController { throws SQLException, UnsupportedProviderException { Optional userOpt = userService.findByUsernameIgnoreCase(username); if (!userOpt.isPresent()) { - return new RedirectView("/adminSettings?messageType=userNotFound", true); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "User not found.")); } if (!userService.usernameExistsIgnoreCase(username)) { - return new RedirectView("/adminSettings?messageType=userNotFound", true); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "User not found.")); } // Get the currently authenticated username String currentUsername = authentication.getName(); // Check if the provided username matches the current session's username if (currentUsername.equalsIgnoreCase(username)) { - return new RedirectView("/adminSettings?messageType=downgradeCurrentUser", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot change your own role.")); } try { // Validate the role Role roleEnum = Role.fromString(role); if (roleEnum == Role.INTERNAL_API_USER) { // If the role is INTERNAL_API_USER, reject the request - return new RedirectView("/adminSettings?messageType=invalidRole", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot assign INTERNAL_API_USER role.")); } } catch (IllegalArgumentException e) { - // If the role ID is not valid, redirect with an error message - return new RedirectView("/adminSettings?messageType=invalidRole", true); + // If the role ID is not valid, return error + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Invalid role specified.")); } User user = userOpt.get(); @@ -322,15 +541,15 @@ public class UserController { if (team != null) { // Prevent assigning to Internal team if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) { - return new RedirectView( - "/adminSettings?messageType=internalTeamNotAccessible", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot assign users to Internal team.")); } // Prevent moving users from Internal team if (user.getTeam() != null && TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) { - return new RedirectView( - "/adminSettings?messageType=cannotMoveInternalUsers", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot move users from Internal team.")); } user.setTeam(team); @@ -339,30 +558,31 @@ public class UserController { } userService.changeRole(user, role); - return new RedirectView( - "/adminSettings", // Redirect to account page after adding the user - true); + return ResponseEntity.ok(Map.of("message", "User role updated successfully")); } @PreAuthorize("hasRole('ROLE_ADMIN')") @PostMapping("/admin/changeUserEnabled/{username}") - public RedirectView changeUserEnabled( + public ResponseEntity changeUserEnabled( @PathVariable("username") String username, @RequestParam("enabled") boolean enabled, Authentication authentication) throws SQLException, UnsupportedProviderException { Optional userOpt = userService.findByUsernameIgnoreCase(username); if (userOpt.isEmpty()) { - return new RedirectView("/adminSettings?messageType=userNotFound", true); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "User not found.")); } if (!userService.usernameExistsIgnoreCase(username)) { - return new RedirectView("/adminSettings?messageType=userNotFound", true); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "User not found.")); } // Get the currently authenticated username String currentUsername = authentication.getName(); // Check if the provided username matches the current session's username if (currentUsername.equalsIgnoreCase(username)) { - return new RedirectView("/adminSettings?messageType=disabledCurrentUser", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot disable your own account.")); } User user = userOpt.get(); userService.changeUserEnabled(user, enabled); @@ -389,23 +609,24 @@ public class UserController { } } } - return new RedirectView( - "/adminSettings", // Redirect to account page after adding the user - true); + return ResponseEntity.ok( + Map.of("message", "User " + (enabled ? "enabled" : "disabled") + " successfully")); } @PreAuthorize("hasRole('ROLE_ADMIN')") @PostMapping("/admin/deleteUser/{username}") - public RedirectView deleteUser( + public ResponseEntity deleteUser( @PathVariable("username") String username, Authentication authentication) { if (!userService.usernameExistsIgnoreCase(username)) { - return new RedirectView("/adminSettings?messageType=deleteUsernameExists", true); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "User not found.")); } // Get the currently authenticated username String currentUsername = authentication.getName(); // Check if the provided username matches the current session's username if (currentUsername.equalsIgnoreCase(username)) { - return new RedirectView("/adminSettings?messageType=deleteCurrentUser", true); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot delete your own account.")); } // Invalidate all sessions before deleting the user List sessionsInformations = @@ -415,7 +636,7 @@ public class UserController { sessionRegistry.removeSessionInformation(sessionsInformation.getSessionId()); } userService.deleteUser(username); - return new RedirectView("/adminSettings", true); + return ResponseEntity.ok(Map.of("message", "User deleted successfully")); } @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @@ -446,4 +667,73 @@ public class UserController { } return ResponseEntity.ok(apiKey); } + + /** + * Helper method to process a single email invitation. + * + * @param email The email address to invite + * @param teamId The team ID to assign the user to + * @param role The role to assign to the user + * @return InviteResult containing success status and optional error message + */ + private InviteResult processEmailInvite(String email, Long teamId, String role) { + try { + // Validate email format (basic check) + if (!email.contains("@") || !email.contains(".")) { + return InviteResult.failure(email + ": Invalid email format"); + } + + // Check if user already exists + if (userService.usernameExistsIgnoreCase(email)) { + return InviteResult.failure(email + ": User already exists"); + } + + // Generate random password + String temporaryPassword = java.util.UUID.randomUUID().toString().substring(0, 12); + + // Create user with forceChange=true + userService.saveUser(email, temporaryPassword, teamId, role, true); + + // Send invite email + try { + emailService.get().sendInviteEmail(email, email, temporaryPassword); + log.info("Sent invite email to: {}", email); + return InviteResult.success(); + } catch (Exception emailEx) { + log.error("Failed to send invite email to {}: {}", email, emailEx.getMessage()); + return InviteResult.failure(email + ": User created but email failed to send"); + } + + } catch (Exception e) { + log.error("Failed to invite user {}: {}", email, e.getMessage()); + return InviteResult.failure(email + ": " + e.getMessage()); + } + } + + /** Result object for individual email invite processing. */ + private static class InviteResult { + private final boolean success; + private final String errorMessage; + + private InviteResult(boolean success, String errorMessage) { + this.success = success; + this.errorMessage = errorMessage; + } + + static InviteResult success() { + return new InviteResult(true, null); + } + + static InviteResult failure(String errorMessage) { + return new InviteResult(false, errorMessage); + } + + boolean isSuccess() { + return success; + } + + String getErrorMessage() { + return errorMessage; + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java index 4d74dbfd8..36fa23a6a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java @@ -22,6 +22,8 @@ public interface UserRepository extends JpaRepository { Optional findByApiKey(String apiKey); + Optional findBySsoProviderAndSsoProviderId(String ssoProvider, String ssoProviderId); + List findByAuthenticationTypeIgnoreCase(String authenticationType); @Query("SELECT u FROM User u WHERE u.team IS NULL") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/FirstLoginFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/FirstLoginFilter.java deleted file mode 100644 index c0295948c..000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/FirstLoginFilter.java +++ /dev/null @@ -1,82 +0,0 @@ -package stirling.software.proprietary.security.filter; - -import java.io.IOException; -import java.time.Instant; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.Optional; - -import org.springframework.context.annotation.Lazy; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; - -import lombok.extern.slf4j.Slf4j; - -import stirling.software.common.util.RequestUriUtils; -import stirling.software.proprietary.security.model.User; -import stirling.software.proprietary.security.service.UserService; - -@Slf4j -@Component -public class FirstLoginFilter extends OncePerRequestFilter { - - @Lazy private final UserService userService; - - public FirstLoginFilter(@Lazy UserService userService) { - this.userService = userService; - } - - @Override - protected void doFilterInternal( - HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - String method = request.getMethod(); - String requestURI = request.getRequestURI(); - String contextPath = request.getContextPath(); - // Check if the request is for static resources - boolean isStaticResource = RequestUriUtils.isStaticResource(contextPath, requestURI); - // If it's a static resource, just continue the filter chain and skip the logic below - if (isStaticResource) { - filterChain.doFilter(request, response); - return; - } - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication != null && authentication.isAuthenticated()) { - Optional user = userService.findByUsernameIgnoreCase(authentication.getName()); - if ("GET".equalsIgnoreCase(method) - && user.isPresent() - && user.get().isFirstLogin() - && !(contextPath + "/change-creds").equals(requestURI)) { - response.sendRedirect(contextPath + "/change-creds"); - return; - } - } - if (log.isDebugEnabled()) { - HttpSession session = request.getSession(true); - DateTimeFormatter timeFormat = DateTimeFormatter.ofPattern("HH:mm:ss"); - String creationTime = - timeFormat.format( - Instant.ofEpochMilli(session.getCreationTime()) - .atZone(ZoneId.systemDefault()) - .toLocalTime()); - log.debug( - "Request Info - New: {}, creationTimeSession {}, ID: {}, IP: {}, User-Agent: {}, Referer: {}, Request URL: {}", - session.isNew(), - creationTime, - session.getId(), - request.getRemoteAddr(), - request.getHeader("User-Agent"), - request.getHeader("Referer"), - request.getRequestURL().toString()); - } - filterChain.doFilter(request, response); - } -} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java index faf50832f..d6a34264f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java @@ -1,8 +1,9 @@ package stirling.software.proprietary.security.filter; import static stirling.software.common.util.RequestUriUtils.isStaticResource; -import static stirling.software.proprietary.security.model.AuthenticationType.*; +import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2; import static stirling.software.proprietary.security.model.AuthenticationType.SAML2; +import static stirling.software.proprietary.security.model.AuthenticationType.WEB; import java.io.IOException; import java.sql.SQLException; @@ -75,29 +76,60 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { String jwtToken = jwtService.extractToken(request); if (jwtToken == null) { - // Any unauthenticated requests should redirect to /login + // Allow specific auth endpoints to pass through without JWT String requestURI = request.getRequestURI(); String contextPath = request.getContextPath(); - if (!requestURI.startsWith(contextPath + "/login")) { - response.sendRedirect("/login"); + // Public auth endpoints that don't require JWT + boolean isPublicAuthEndpoint = + requestURI.startsWith(contextPath + "/login") + || requestURI.startsWith(contextPath + "/signup") + || requestURI.startsWith(contextPath + "/auth/") + || requestURI.startsWith(contextPath + "/oauth2") + || requestURI.startsWith(contextPath + "/api/v1/auth/login") + || requestURI.startsWith(contextPath + "/api/v1/auth/register") + || requestURI.startsWith(contextPath + "/api/v1/auth/refresh"); + + if (!isPublicAuthEndpoint) { + // For API requests, return 401 JSON + String acceptHeader = request.getHeader("Accept"); + if (requestURI.startsWith(contextPath + "/api/") + || (acceptHeader != null + && acceptHeader.contains("application/json"))) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.getWriter().write("{\"error\":\"Authentication required\"}"); + return; + } + + // For HTML requests (SPA routes), let React Router handle it (serve + // index.html) + filterChain.doFilter(request, response); return; } + + // For public auth endpoints without JWT, continue to the endpoint + filterChain.doFilter(request, response); + return; } try { + log.debug("Validating JWT token"); jwtService.validateToken(jwtToken); + log.debug("JWT token validated successfully"); } catch (AuthenticationFailureException e) { - jwtService.clearToken(response); + log.warn("JWT validation failed: {}", e.getMessage()); handleAuthenticationFailure(request, response, e); return; } Map claims = jwtService.extractClaims(jwtToken); String tokenUsername = claims.get("sub").toString(); + log.debug("JWT token username: {}", tokenUsername); try { authenticate(request, claims); + log.debug("Authentication successful for user: {}", tokenUsername); } catch (SQLException | UnsupportedProviderException e) { log.error("Error processing user authentication for user: {}", tokenUsername, e); handleAuthenticationFailure( @@ -175,21 +207,26 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private void processUserAuthenticationType(Map claims, String username) throws SQLException, UnsupportedProviderException { AuthenticationType authenticationType = - AuthenticationType.valueOf(claims.getOrDefault("authType", WEB).toString()); + AuthenticationType.valueOf( + claims.getOrDefault("authType", WEB).toString().toUpperCase()); log.debug("Processing {} login for {} user", authenticationType, username); switch (authenticationType) { case OAUTH2 -> { ApplicationProperties.Security.OAUTH2 oauth2Properties = securityProperties.getOauth2(); + // Provider IDs should already be set during initial authentication + // Pass null here since this is validating an existing JWT token userService.processSSOPostLogin( - username, oauth2Properties.getAutoCreateUser(), OAUTH2); + username, null, null, oauth2Properties.getAutoCreateUser(), OAUTH2); } case SAML2 -> { ApplicationProperties.Security.SAML2 saml2Properties = securityProperties.getSaml2(); + // Provider IDs should already be set during initial authentication + // Pass null here since this is validating an existing JWT token userService.processSSOPostLogin( - username, saml2Properties.getAutoCreateUser(), SAML2); + username, null, null, saml2Properties.getAutoCreateUser(), SAML2); } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index bec6f1d04..6a32511b0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -236,6 +236,10 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { contextPath + "/pdfjs/", contextPath + "/pdfjs-legacy/", contextPath + "/api/v1/info/status", + contextPath + "/api/v1/auth/login", + contextPath + "/api/v1/auth/register", + contextPath + "/api/v1/auth/refresh", + contextPath + "/api/v1/auth/me", contextPath + "/site.webmanifest" }; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java index 616067bed..02bd08a5b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java @@ -1,12 +1,15 @@ package stirling.software.proprietary.security.model; import java.io.Serializable; +import java.time.LocalDateTime; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; import org.springframework.security.core.userdetails.UserDetails; import com.fasterxml.jackson.annotation.JsonIgnore; @@ -59,12 +62,17 @@ public class User implements UserDetails, Serializable { @Column(name = "authenticationtype") private String authenticationType; + @Column(name = "sso_provider_id") + private String ssoProviderId; + + @Column(name = "sso_provider") + private String ssoProvider; + @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user") private Set authorities = new HashSet<>(); @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "team_id") - @JsonIgnore private Team team; @ElementCollection @@ -72,8 +80,17 @@ public class User implements UserDetails, Serializable { @Lob @Column(name = "setting_value", columnDefinition = "text") @CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id")) + @JsonIgnore private Map settings = new HashMap<>(); // Key-value pairs of settings. + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; + public String getRoleName() { return Role.getRoleNameByRoleId(getRolesAsString()); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java index eba2bcc62..2afc43443 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java @@ -10,6 +10,7 @@ import java.util.Map; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; import org.springframework.security.web.savedrequest.SavedRequest; @@ -76,12 +77,6 @@ public class CustomOAuth2AuthenticationSuccessHandler throw new LockedException( "Your account has been locked due to too many failed login attempts."); } - if (jwtService.isJwtEnabled()) { - String jwt = - jwtService.generateToken( - authentication, Map.of("authType", AuthenticationType.OAUTH2)); - jwtService.addToken(response, jwt); - } if (userService.isUserDisabled(username)) { getRedirectStrategy() .sendRedirect(request, response, "/logout?userIsDisabled=true"); @@ -102,14 +97,95 @@ public class CustomOAuth2AuthenticationSuccessHandler response.sendRedirect(contextPath + "/logout?oAuth2AdminBlockedUser=true"); return; } - if (principal instanceof OAuth2User) { + if (principal instanceof OAuth2User oAuth2User) { + // Extract SSO provider information from OAuth2User + String ssoProviderId = oAuth2User.getAttribute("sub"); // OIDC ID + // Extract provider from authentication - need to get it from the token/request + // For now, we'll extract it in a more generic way + String ssoProvider = extractProviderFromAuthentication(authentication); + userService.processSSOPostLogin( - username, oauth2Properties.getAutoCreateUser(), OAUTH2); + username, + ssoProviderId, + ssoProvider, + oauth2Properties.getAutoCreateUser(), + OAUTH2); + } + + // Generate JWT if v2 is enabled + if (jwtService.isJwtEnabled()) { + String jwt = + jwtService.generateToken( + authentication, Map.of("authType", AuthenticationType.OAUTH2)); + + // Build context-aware redirect URL based on the original request + String redirectUrl = buildContextAwareRedirectUrl(request, contextPath, jwt); + + response.sendRedirect(redirectUrl); + } else { + // v1: redirect directly to home + response.sendRedirect(contextPath + "/"); } - response.sendRedirect(contextPath + "/"); } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { response.sendRedirect(contextPath + "/logout?invalidUsername=true"); } } } + + /** + * Extracts the OAuth2 provider registration ID from the authentication object. + * + * @param authentication The authentication object + * @return The provider registration ID (e.g., "google", "github"), or null if not available + */ + private String extractProviderFromAuthentication(Authentication authentication) { + if (authentication instanceof OAuth2AuthenticationToken oauth2Token) { + return oauth2Token.getAuthorizedClientRegistrationId(); + } + return null; + } + + /** + * Builds a context-aware redirect URL based on the request's origin + * + * @param request The HTTP request + * @param contextPath The application context path + * @param jwt The JWT token to include + * @return The appropriate redirect URL + */ + private String buildContextAwareRedirectUrl( + HttpServletRequest request, String contextPath, String jwt) { + // Try to get the origin from the Referer header first + String referer = request.getHeader("Referer"); + if (referer != null && !referer.isEmpty()) { + try { + java.net.URL refererUrl = new java.net.URL(referer); + String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost(); + if (refererUrl.getPort() != -1 + && refererUrl.getPort() != 80 + && refererUrl.getPort() != 443) { + origin += ":" + refererUrl.getPort(); + } + return origin + "/auth/callback#access_token=" + jwt; + } catch (java.net.MalformedURLException e) { + // Fall back to other methods if referer is malformed + } + } + + // Fall back to building from request host/port + String scheme = request.getScheme(); + String serverName = request.getServerName(); + int serverPort = request.getServerPort(); + + StringBuilder origin = new StringBuilder(); + origin.append(scheme).append("://").append(serverName); + + // Only add port if it's not the default port for the scheme + if ((!"http".equals(scheme) || serverPort != 80) + && (!"https".equals(scheme) || serverPort != 443)) { + origin.append(":").append(serverPort); + } + + return origin.toString() + "/auth/callback#access_token=" + jwt; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java index 913dc458a..cd04d6da0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java @@ -10,7 +10,6 @@ import java.util.List; import java.util.Optional; import java.util.Set; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -41,7 +40,7 @@ import stirling.software.proprietary.security.service.UserService; @Slf4j @Configuration -@ConditionalOnBooleanProperty("security.oauth2.enabled") +@ConditionalOnProperty(prefix = "security", name = "oauth2.enabled", havingValue = "true") public class OAuth2Configuration { public static final String REDIRECT_URI_PATH = "{baseUrl}/login/oauth2/code/"; @@ -53,6 +52,9 @@ public class OAuth2Configuration { ApplicationProperties applicationProperties, @Lazy UserService userService) { this.userService = userService; this.applicationProperties = applicationProperties; + log.info( + "OAuth2Configuration initialized - OAuth2 enabled: {}", + applicationProperties.getSecurity().getOauth2().getEnabled()); } @Bean @@ -75,7 +77,7 @@ public class OAuth2Configuration { private Optional keycloakClientRegistration() { OAUTH2 oauth2 = applicationProperties.getSecurity().getOauth2(); - if (isOAuth2Enabled(oauth2) || isClientInitialised(oauth2)) { + if (isOAuth2Disabled(oauth2) || isClientInitialised(oauth2)) { return Optional.empty(); } @@ -105,7 +107,7 @@ public class OAuth2Configuration { private Optional googleClientRegistration() { OAUTH2 oAuth2 = applicationProperties.getSecurity().getOauth2(); - if (isOAuth2Enabled(oAuth2) || isClientInitialised(oAuth2)) { + if (isOAuth2Disabled(oAuth2) || isClientInitialised(oAuth2)) { return Optional.empty(); } @@ -138,12 +140,23 @@ public class OAuth2Configuration { private Optional githubClientRegistration() { OAUTH2 oAuth2 = applicationProperties.getSecurity().getOauth2(); - if (isOAuth2Enabled(oAuth2)) { + if (isOAuth2Disabled(oAuth2)) { + log.debug("OAuth2 is disabled, skipping GitHub client registration"); return Optional.empty(); } Client client = oAuth2.getClient(); + if (client == null) { + log.debug("OAuth2 client configuration is null, skipping GitHub"); + return Optional.empty(); + } + GitHubProvider githubClient = client.getGithub(); + if (githubClient == null) { + log.debug("GitHub client configuration is null"); + return Optional.empty(); + } + Provider github = new GitHubProvider( githubClient.getClientId(), @@ -151,7 +164,15 @@ public class OAuth2Configuration { githubClient.getScopes(), githubClient.getUseAsUsername()); - return validateProvider(github) + boolean isValid = validateProvider(github); + log.info( + "GitHub OAuth2 provider validation: {} (clientId: {}, clientSecret: {}, scopes: {})", + isValid, + githubClient.getClientId(), + githubClient.getClientSecret() != null ? "***" : "null", + githubClient.getScopes()); + + return isValid ? Optional.of( ClientRegistration.withRegistrationId(github.getName()) .clientId(github.getClientId()) @@ -171,7 +192,7 @@ public class OAuth2Configuration { private Optional oidcClientRegistration() { OAUTH2 oauth = applicationProperties.getSecurity().getOauth2(); - if (isOAuth2Enabled(oauth) || isClientInitialised(oauth)) { + if (isOAuth2Disabled(oauth) || isClientInitialised(oauth)) { return Optional.empty(); } @@ -207,7 +228,7 @@ public class OAuth2Configuration { : Optional.empty(); } - private boolean isOAuth2Enabled(OAUTH2 oAuth2) { + private boolean isOAuth2Disabled(OAUTH2 oAuth2) { return oAuth2 == null || !oAuth2.getEnabled(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index 0f0c50d7d..e7a47a391 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -120,13 +120,41 @@ public class CustomSaml2AuthenticationSuccessHandler contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser"); return; } - log.debug("Processing SSO post-login for user: {}", username); + + // Extract SSO provider information from SAML2 assertion + String ssoProviderId = saml2Principal.nameId(); + String ssoProvider = "saml2"; // fixme + + log.debug( + "Processing SSO post-login for user: {} (Provider: {}, ProviderId: {})", + username, + ssoProvider, + ssoProviderId); + userService.processSSOPostLogin( - username, saml2Properties.getAutoCreateUser(), SAML2); + username, + ssoProviderId, + ssoProvider, + saml2Properties.getAutoCreateUser(), + SAML2); log.debug("Successfully processed authentication for user: {}", username); - generateJwt(response, authentication); - response.sendRedirect(contextPath + "/"); + // Generate JWT if v2 is enabled + if (jwtService.isJwtEnabled()) { + String jwt = + jwtService.generateToken( + authentication, + Map.of("authType", AuthenticationType.SAML2)); + + // Build context-aware redirect URL based on the original request + String redirectUrl = + buildContextAwareRedirectUrl(request, contextPath, jwt); + + response.sendRedirect(redirectUrl); + } else { + // v1: redirect directly to home + response.sendRedirect(contextPath + "/"); + } } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { log.debug( "Invalid username detected for user: {}, redirecting to logout", @@ -140,12 +168,48 @@ public class CustomSaml2AuthenticationSuccessHandler } } - private void generateJwt(HttpServletResponse response, Authentication authentication) { - if (jwtService.isJwtEnabled()) { - String jwt = - jwtService.generateToken( - authentication, Map.of("authType", AuthenticationType.SAML2)); - jwtService.addToken(response, jwt); + /** + * Builds a context-aware redirect URL based on the request's origin + * + * @param request The HTTP request + * @param contextPath The application context path + * @param jwt The JWT token to include + * @return The appropriate redirect URL + */ + private String buildContextAwareRedirectUrl( + HttpServletRequest request, String contextPath, String jwt) { + // Try to get the origin from the Referer header first + String referer = request.getHeader("Referer"); + if (referer != null && !referer.isEmpty()) { + try { + java.net.URL refererUrl = new java.net.URL(referer); + String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost(); + if (refererUrl.getPort() != -1 + && refererUrl.getPort() != 80 + && refererUrl.getPort() != 443) { + origin += ":" + refererUrl.getPort(); + } + return origin + "/auth/callback#access_token=" + jwt; + } catch (java.net.MalformedURLException e) { + log.debug( + "Malformed referer URL: {}, falling back to request-based origin", referer); + } } + + // Fall back to building from request host/port + String scheme = request.getScheme(); + String serverName = request.getServerName(); + int serverPort = request.getServerPort(); + + StringBuilder origin = new StringBuilder(); + origin.append(scheme).append("://").append(serverName); + + // Only add port if it's not the default port for the scheme + if ((!"http".equals(scheme) || serverPort != 80) + && (!"https".equals(scheme) || serverPort != 443)) { + origin.append(":").append(serverPort); + } + + return origin + "/auth/callback#access_token=" + jwt; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java index 8f9afbe3d..6592bc95c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java @@ -14,7 +14,6 @@ import org.springframework.security.oauth2.core.oidc.user.OidcUser; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; -import stirling.software.common.model.ApplicationProperties.Security.OAUTH2; import stirling.software.common.model.enumeration.UsernameAttribute; import stirling.software.proprietary.security.model.User; @@ -27,13 +26,13 @@ public class CustomOAuth2UserService implements OAuth2UserService internalUser = - userService.findByUsernameIgnoreCase(user.getAttribute(usernameAttributeKey)); + // Extract SSO provider information + String ssoProviderId = user.getSubject(); // Standard OIDC 'sub' claim + String ssoProvider = userRequest.getClientRegistration().getRegistrationId(); + String username = user.getAttribute(usernameAttributeKey); + + log.debug( + "OAuth2 login - Provider: {}, ProviderId: {}, Username: {}", + ssoProvider, + ssoProviderId, + username); + + Optional internalUser = userService.findByUsernameIgnoreCase(username); if (internalUser.isPresent()) { String internalUsername = internalUser.get().getUsername(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java index 08860a340..1b9fc19bd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java @@ -73,4 +73,90 @@ public class EmailService { // Sends the email via the configured mail sender mailSender.send(message); } + + /** + * Sends a plain text/HTML email without attachments asynchronously. + * + * @param to The recipient email address + * @param subject The email subject + * @param body The email body (can contain HTML) + * @param isHtml Whether the body contains HTML content + * @throws MessagingException If there is an issue with creating or sending the email. + */ + @Async + public void sendPlainEmail(String to, String subject, String body, boolean isHtml) + throws MessagingException { + // Validate recipient email address + if (to == null || to.trim().isEmpty()) { + throw new MessagingException("Invalid recipient email address"); + } + + ApplicationProperties.Mail mailProperties = applicationProperties.getMail(); + + // Creates a MimeMessage to represent the email + MimeMessage message = mailSender.createMimeMessage(); + + // Helper class to set up the message content + MimeMessageHelper helper = new MimeMessageHelper(message, false); + + // Sets the recipient, subject, body, and sender email + helper.addTo(to); + helper.setSubject(subject); + helper.setText(body, isHtml); + helper.setFrom(mailProperties.getFrom()); + + // Sends the email via the configured mail sender + mailSender.send(message); + } + + /** + * Sends an invitation email to a new user with their credentials. + * + * @param to The recipient email address + * @param username The username for the new account + * @param temporaryPassword The temporary password + * @throws MessagingException If there is an issue with creating or sending the email. + */ + @Async + public void sendInviteEmail(String to, String username, String temporaryPassword) + throws MessagingException { + String subject = "Welcome to Stirling PDF"; + + String body = + """ + +
+
+ +
+ Stirling PDF +
+ +
+

Welcome to Stirling PDF!

+

Hi there,

+

You have been invited to join the workspace. Below are your login credentials:

+ +
+

Username: %s

+

Temporary Password: %s

+
+
+

⚠️ Important: You will be required to change your password upon first login for security reasons.

+
+

Please keep these credentials secure and do not share them with anyone.

+

— The Stirling PDF Team

+
+ +
+ © 2025 Stirling PDF. All rights reserved. +
+
+
+ + """ + .formatted(username, temporaryPassword); + + sendPlainEmail(to, subject, body, true); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java index 8724da9a8..a65c79665 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java @@ -14,14 +14,11 @@ import java.util.function.Function; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.ResponseCookie; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; -import io.github.pixee.security.Newlines; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; @@ -29,9 +26,7 @@ import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.UnsupportedJwtException; import io.jsonwebtoken.security.SignatureException; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; @@ -43,13 +38,9 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin @Service public class JwtService implements JwtServiceInterface { - private static final String JWT_COOKIE_NAME = "stirling_jwt"; - private static final String ISSUER = "Stirling PDF"; + private static final String ISSUER = "https://stirling.com"; private static final long EXPIRATION = 3600000; - @Value("${stirling.security.jwt.secureCookie:true}") - private boolean secureCookie; - private final KeyPersistenceServiceInterface keyPersistenceService; private final boolean v2Enabled; @@ -59,6 +50,7 @@ public class JwtService implements JwtServiceInterface { KeyPersistenceServiceInterface keyPersistenceService) { this.v2Enabled = v2Enabled; this.keyPersistenceService = keyPersistenceService; + log.info("JwtService initialized"); } @Override @@ -260,47 +252,18 @@ public class JwtService implements JwtServiceInterface { @Override public String extractToken(HttpServletRequest request) { - Cookie[] cookies = request.getCookies(); - - if (cookies != null) { - for (Cookie cookie : cookies) { - if (JWT_COOKIE_NAME.equals(cookie.getName())) { - return cookie.getValue(); - } - } + // Extract from Authorization header Bearer token + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); // Remove "Bearer " prefix + log.debug("JWT token extracted from Authorization header"); + return token; } + log.debug("No JWT token found in Authorization header"); return null; } - @Override - public void addToken(HttpServletResponse response, String token) { - ResponseCookie cookie = - ResponseCookie.from(JWT_COOKIE_NAME, Newlines.stripAll(token)) - .httpOnly(true) - .secure(secureCookie) - .sameSite("Strict") - .maxAge(EXPIRATION / 1000) - .path("/") - .build(); - - response.addHeader("Set-Cookie", cookie.toString()); - } - - @Override - public void clearToken(HttpServletResponse response) { - ResponseCookie cookie = - ResponseCookie.from(JWT_COOKIE_NAME, "") - .httpOnly(true) - .secure(secureCookie) - .sameSite("None") - .maxAge(0) - .path("/") - .build(); - - response.addHeader("Set-Cookie", cookie.toString()); - } - @Override public boolean isJwtEnabled() { return v2Enabled; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java index 7cdca8209..2107f2ffd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java @@ -5,7 +5,6 @@ import java.util.Map; import org.springframework.security.core.Authentication; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; public interface JwtServiceInterface { @@ -66,21 +65,6 @@ public interface JwtServiceInterface { */ String extractToken(HttpServletRequest request); - /** - * Add JWT token to HTTP response (header and cookie) - * - * @param response HTTP servlet response - * @param token JWT token to add - */ - void addToken(HttpServletResponse response, String token); - - /** - * Clear JWT token from HTTP response (remove cookie) - * - * @param response HTTP servlet response - */ - void clearToken(HttpServletResponse response); - /** * Check if JWT authentication is enabled * diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index 3adbe086c..d13fcc0cd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -61,19 +61,46 @@ public class UserService implements UserServiceInterface { private final ApplicationProperties.Security.OAUTH2 oAuth2; - // Handle OAUTH2 login and user auto creation. public void processSSOPostLogin( - String username, boolean autoCreateUser, AuthenticationType type) + String username, + String ssoProviderId, + String ssoProvider, + boolean autoCreateUser, + AuthenticationType type) throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!isUsernameValid(username)) { return; } - Optional existingUser = findByUsernameIgnoreCase(username); + + // Find user by SSO provider ID first + Optional existingUser; + if (ssoProviderId != null && ssoProvider != null) { + existingUser = + userRepository.findBySsoProviderAndSsoProviderId(ssoProvider, ssoProviderId); + + if (existingUser.isPresent()) { + log.debug("User found by SSO provider ID: {}", ssoProviderId); + return; + } + } + + existingUser = findByUsernameIgnoreCase(username); if (existingUser.isPresent()) { + User user = existingUser.get(); + + // Migrate existing user to use provider ID if not already set + if (user.getSsoProviderId() == null && ssoProviderId != null && ssoProvider != null) { + log.info("Migrating user {} to use SSO provider ID: {}", username, ssoProviderId); + user.setSsoProviderId(ssoProviderId); + user.setSsoProvider(ssoProvider); + userRepository.save(user); + databaseService.exportDatabase(); + } return; } + if (autoCreateUser) { - saveUser(username, type); + saveUser(username, ssoProviderId, ssoProvider, type); } } @@ -155,6 +182,21 @@ public class UserService implements UserServiceInterface { saveUser(username, authenticationType, (Long) null, Role.USER.getRoleId()); } + public void saveUser( + String username, + String ssoProviderId, + String ssoProvider, + AuthenticationType authenticationType) + throws IllegalArgumentException, SQLException, UnsupportedProviderException { + saveUser( + username, + ssoProviderId, + ssoProvider, + authenticationType, + (Long) null, + Role.USER.getRoleId()); + } + private User saveUser(Optional user, String apiKey) { if (user.isPresent()) { user.get().setApiKey(apiKey); @@ -169,6 +211,30 @@ public class UserService implements UserServiceInterface { return saveUserCore( username, // username null, // password + null, // ssoProviderId + null, // ssoProvider + authenticationType, // authenticationType + teamId, // teamId + null, // team + role, // role + false, // firstLogin + true // enabled + ); + } + + public User saveUser( + String username, + String ssoProviderId, + String ssoProvider, + AuthenticationType authenticationType, + Long teamId, + String role) + throws IllegalArgumentException, SQLException, UnsupportedProviderException { + return saveUserCore( + username, // username + null, // password + ssoProviderId, // ssoProviderId + ssoProvider, // ssoProvider authenticationType, // authenticationType teamId, // teamId null, // team @@ -184,6 +250,8 @@ public class UserService implements UserServiceInterface { return saveUserCore( username, // username null, // password + null, // ssoProviderId + null, // ssoProvider authenticationType, // authenticationType null, // teamId team, // team @@ -198,6 +266,8 @@ public class UserService implements UserServiceInterface { return saveUserCore( username, // username password, // password + null, // ssoProviderId + null, // ssoProvider AuthenticationType.WEB, // authenticationType teamId, // teamId null, // team @@ -213,6 +283,8 @@ public class UserService implements UserServiceInterface { return saveUserCore( username, // username password, // password + null, // ssoProviderId + null, // ssoProvider AuthenticationType.WEB, // authenticationType null, // teamId team, // team @@ -228,6 +300,8 @@ public class UserService implements UserServiceInterface { return saveUserCore( username, // username password, // password + null, // ssoProviderId + null, // ssoProvider AuthenticationType.WEB, // authenticationType teamId, // teamId null, // team @@ -248,6 +322,8 @@ public class UserService implements UserServiceInterface { saveUserCore( username, // username password, // password + null, // ssoProviderId + null, // ssoProvider AuthenticationType.WEB, // authenticationType teamId, // teamId null, // team @@ -412,6 +488,8 @@ public class UserService implements UserServiceInterface { * * @param username Username for the new user * @param password Password for the user (may be null for SSO/OAuth users) + * @param ssoProviderId Unique identifier from SSO provider (may be null for non-SSO users) + * @param ssoProvider Name of the SSO provider (may be null for non-SSO users) * @param authenticationType Type of authentication (WEB, SSO, etc.) * @param teamId ID of the team to assign (may be null to use default) * @param team Team object to assign (takes precedence over teamId if both provided) @@ -426,6 +504,8 @@ public class UserService implements UserServiceInterface { private User saveUserCore( String username, String password, + String ssoProviderId, + String ssoProvider, AuthenticationType authenticationType, Long teamId, Team team, @@ -446,6 +526,12 @@ public class UserService implements UserServiceInterface { user.setPassword(passwordEncoder.encode(password)); } + // Set SSO provider details if provided + if (ssoProviderId != null && ssoProvider != null) { + user.setSsoProviderId(ssoProviderId); + user.setSsoProvider(ssoProvider); + } + // Set authentication type user.setAuthenticationType(authenticationType); @@ -562,6 +648,21 @@ public class UserService implements UserServiceInterface { return null; } + public boolean isCurrentUserAdmin() { + try { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null + && authentication.isAuthenticated() + && !"anonymousUser".equals(authentication.getPrincipal())) { + return authentication.getAuthorities().stream() + .anyMatch(auth -> Role.ADMIN.getRoleId().equals(auth.getAuthority())); + } + } catch (Exception e) { + log.debug("Error checking admin status", e); + } + return false; + } + @Transactional public void syncCustomApiUser(String customApiKey) { if (customApiKey == null || customApiKey.trim().isBlank()) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java index a743b21fe..aca98eb75 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java @@ -30,6 +30,8 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.service.ServerCertificateServiceInterface; +import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License; +import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker; @Service @Slf4j @@ -51,6 +53,12 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa @Value("${system.serverCertificate.regenerateOnStartup:false}") private boolean regenerateOnStartup; + private final LicenseKeyChecker licenseKeyChecker; + + public ServerCertificateService(LicenseKeyChecker licenseKeyChecker) { + this.licenseKeyChecker = licenseKeyChecker; + } + static { Security.addProvider(new BouncyCastleProvider()); } @@ -59,8 +67,13 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa return Paths.get(InstallationPathConfig.getConfigPath(), KEYSTORE_FILENAME); } + private boolean hasProOrEnterpriseAccess() { + License license = licenseKeyChecker.getPremiumLicenseEnabledResult(); + return license == License.PRO || license == License.ENTERPRISE; + } + public boolean isEnabled() { - return enabled; + return enabled && hasProOrEnterpriseAccess(); } public boolean hasServerCertificate() { @@ -73,6 +86,11 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa return; } + if (!hasProOrEnterpriseAccess()) { + log.info("Server certificate feature requires Pro or Enterprise license"); + return; + } + Path keystorePath = getKeystorePath(); if (!Files.exists(keystorePath) || regenerateOnStartup) { @@ -88,6 +106,11 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa } public KeyStore getServerKeyStore() throws Exception { + if (!hasProOrEnterpriseAccess()) { + throw new IllegalStateException( + "Server certificate feature requires Pro or Enterprise license"); + } + if (!enabled || !hasServerCertificate()) { throw new IllegalStateException("Server certificate is not available"); } @@ -114,6 +137,11 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa } public void uploadServerCertificate(InputStream p12Stream, String password) throws Exception { + if (!hasProOrEnterpriseAccess()) { + throw new IllegalStateException( + "Server certificate feature requires Pro or Enterprise license"); + } + // Validate the uploaded certificate KeyStore uploadedKeyStore = KeyStore.getInstance("PKCS12"); uploadedKeyStore.load(p12Stream, password.toCharArray()); @@ -174,6 +202,11 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa } private void generateServerCertificate() throws Exception { + if (!hasProOrEnterpriseAccess()) { + throw new IllegalStateException( + "Server certificate feature requires Pro or Enterprise license"); + } + // Generate key pair KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC"); keyPairGenerator.initialize(2048, new SecureRandom()); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java index 7a4076260..c6b6d17e7 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java @@ -1,6 +1,8 @@ package stirling.software.proprietary.security; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.io.IOException; @@ -38,7 +40,6 @@ class CustomLogoutSuccessHandlerTest { when(response.isCommitted()).thenReturn(false); when(jwtService.extractToken(request)).thenReturn(token); - doNothing().when(jwtService).clearToken(response); when(request.getContextPath()).thenReturn(""); when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath); @@ -56,14 +57,12 @@ class CustomLogoutSuccessHandlerTest { when(response.isCommitted()).thenReturn(false); when(jwtService.extractToken(request)).thenReturn(token); - doNothing().when(jwtService).clearToken(response); when(request.getContextPath()).thenReturn(""); when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath); customLogoutSuccessHandler.onLogoutSuccess(request, response, null); verify(response).sendRedirect(logoutPath); - verify(jwtService).clearToken(response); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilterTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilterTest.java index d3f484486..244ce387f 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilterTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilterTest.java @@ -127,7 +127,6 @@ class JwtAuthenticationFilterTest { .setAuthentication(any(UsernamePasswordAuthenticationToken.class)); verify(jwtService) .generateToken(any(UsernamePasswordAuthenticationToken.class), eq(claims)); - verify(jwtService).addToken(response, newToken); verify(filterChain).doFilter(request, response); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java index 6f9af4c54..e8a6d6045 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java @@ -8,8 +8,6 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.atLeast; -import static org.mockito.Mockito.contains; -import static org.mockito.Mockito.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -17,7 +15,6 @@ import static org.mockito.Mockito.when; import java.security.KeyPair; import java.security.KeyPairGenerator; -import java.security.NoSuchAlgorithmException; import java.util.Base64; import java.util.Collections; import java.util.HashMap; @@ -27,13 +24,10 @@ import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.core.Authentication; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -59,7 +53,7 @@ class JwtServiceTest { private JwtVerificationKey testVerificationKey; @BeforeEach - void setUp() throws NoSuchAlgorithmException { + void setUp() throws Exception { // Generate a test keypair KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); @@ -224,7 +218,8 @@ class JwtServiceTest { assertEquals("admin", extractedClaims.get("role")); assertEquals("IT", extractedClaims.get("department")); assertEquals(username, extractedClaims.get("sub")); - assertEquals("Stirling PDF", extractedClaims.get("iss")); + // Verify the constant issuer is set correctly + assertEquals("https://stirling.com", extractedClaims.get("iss")); } @Test @@ -239,62 +234,27 @@ class JwtServiceTest { } @Test - void testExtractTokenWithCookie() { + void testExtractTokenWithAuthorizationHeader() { String token = "test-token"; - Cookie[] cookies = {new Cookie("stirling_jwt", token)}; - when(request.getCookies()).thenReturn(cookies); + when(request.getHeader("Authorization")).thenReturn("Bearer " + token); assertEquals(token, jwtService.extractToken(request)); } @Test - void testExtractTokenWithNoCookies() { - when(request.getCookies()).thenReturn(null); + void testExtractTokenWithNoAuthorizationHeader() { + when(request.getHeader("Authorization")).thenReturn(null); assertNull(jwtService.extractToken(request)); } @Test - void testExtractTokenWithWrongCookie() { - Cookie[] cookies = {new Cookie("OTHER_COOKIE", "value")}; - when(request.getCookies()).thenReturn(cookies); + void testExtractTokenWithInvalidAuthorizationHeaderFormat() { + when(request.getHeader("Authorization")).thenReturn("InvalidFormat token"); assertNull(jwtService.extractToken(request)); } - @Test - void testExtractTokenWithInvalidAuthorizationHeader() { - when(request.getCookies()).thenReturn(null); - - assertNull(jwtService.extractToken(request)); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testAddToken(boolean secureCookie) throws Exception { - String token = "test-token"; - - // Create new JwtService instance with the secureCookie parameter - JwtService testJwtService = createJwtServiceWithSecureCookie(secureCookie); - - testJwtService.addToken(response, token); - - verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt=" + token)); - verify(response).addHeader(eq("Set-Cookie"), contains("HttpOnly")); - - if (secureCookie) { - verify(response).addHeader(eq("Set-Cookie"), contains("Secure")); - } - } - - @Test - void testClearToken() { - jwtService.clearToken(response); - - verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt=")); - verify(response).addHeader(eq("Set-Cookie"), contains("Max-Age=0")); - } - @Test void testGenerateTokenWithKeyId() throws Exception { String username = "testuser"; @@ -373,17 +333,4 @@ class JwtServiceTest { // Verify fallback logic was used verify(keystoreService, atLeast(1)).getActiveKey(); } - - private JwtService createJwtServiceWithSecureCookie(boolean secureCookie) throws Exception { - // Use reflection to create JwtService with custom secureCookie value - JwtService testService = new JwtService(true, keystoreService); - - // Set the secureCookie field using reflection - java.lang.reflect.Field secureCookieField = - JwtService.class.getDeclaredField("secureCookie"); - secureCookieField.setAccessible(true); - secureCookieField.set(testService, secureCookie); - - return testService; - } } diff --git a/build.gradle b/build.gradle index fbbb011fb..9acb8c9f0 100644 --- a/build.gradle +++ b/build.gradle @@ -314,9 +314,40 @@ tasks.named('bootRun') { tasks.named('build') { group = 'build' description = 'Delegates to :stirling-pdf:bootJar' - dependsOn ':stirling-pdf:bootJar' + dependsOn ':stirling-pdf:bootJar', 'buildRestartHelper' doFirst { println "Delegating to :stirling-pdf:bootJar" } } + +// Task to compile RestartHelper.java +tasks.register('compileRestartHelper', JavaCompile) { + group = 'build' + description = 'Compiles the RestartHelper utility' + + source = fileTree(dir: 'scripts', include: 'RestartHelper.java') + classpath = files() + destinationDirectory = file("${buildDir}/restart-helper-classes") + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +// Task to create restart-helper.jar +tasks.register('buildRestartHelper', Jar) { + group = 'build' + description = 'Builds the restart-helper.jar' + dependsOn 'compileRestartHelper' + + from "${buildDir}/restart-helper-classes" + archiveFileName = 'restart-helper.jar' + destinationDirectory = file("${buildDir}/libs") + + manifest { + attributes 'Main-Class': 'RestartHelper' + } + + doLast { + println "restart-helper.jar created at: ${destinationDirectory.get()}/restart-helper.jar" + } +} diff --git a/docker/Dockerfile.unified b/docker/Dockerfile.unified new file mode 100644 index 000000000..0f1c31cf8 --- /dev/null +++ b/docker/Dockerfile.unified @@ -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"] diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 58655dfdb..666e18bd3 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -30,6 +30,7 @@ COPY scripts /scripts COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/ # first /app directory is for the build stage, second is for the final image COPY --from=build /app/app/core/build/libs/*.jar app.jar +COPY --from=build /app/build/libs/restart-helper.jar restart-helper.jar ARG VERSION_TAG @@ -113,7 +114,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a # 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 && \ - chown stirlingpdfuser:stirlingpdfgroup /app.jar + chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar EXPOSE 8080/tcp diff --git a/docker/backend/Dockerfile.fat b/docker/backend/Dockerfile.fat index bd12e3063..4e63393e8 100644 --- a/docker/backend/Dockerfile.fat +++ b/docker/backend/Dockerfile.fat @@ -30,6 +30,7 @@ COPY scripts /scripts COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/ # first /app directory is for the build stage, second is for the final image COPY --from=build /app/app/core/build/libs/*.jar app.jar +COPY --from=build /app/build/libs/restart-helper.jar restart-helper.jar ARG VERSION_TAG @@ -104,7 +105,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a # 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 && \ - chown stirlingpdfuser:stirlingpdfgroup /app.jar + chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar EXPOSE 8080/tcp # Set user and run command diff --git a/docker/backend/Dockerfile.ultra-lite b/docker/backend/Dockerfile.ultra-lite index 0b74e3b0a..0b4b7a939 100644 --- a/docker/backend/Dockerfile.ultra-lite +++ b/docker/backend/Dockerfile.ultra-lite @@ -45,6 +45,7 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \ COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh COPY scripts/installFonts.sh /scripts/installFonts.sh COPY --from=build /app/app/core/build/libs/*.jar app.jar +COPY --from=build /app/build/libs/restart-helper.jar restart-helper.jar # Set up necessary directories and permissions RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \ @@ -65,7 +66,7 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /et chmod +x /scripts/*.sh && \ addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /configs /customFiles /pipeline /tmp/stirling-pdf && \ - chown stirlingpdfuser:stirlingpdfgroup /app.jar + chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar # Set environment variables ENV ENDPOINTS_GROUPS_TO_REMOVE=CLI diff --git a/docker/compose/docker-compose-unified-backend.yml b/docker/compose/docker-compose-unified-backend.yml new file mode 100644 index 000000000..b8ebfd42b --- /dev/null +++ b/docker/compose/docker-compose-unified-backend.yml @@ -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 diff --git a/docker/compose/docker-compose-unified-both.yml b/docker/compose/docker-compose-unified-both.yml new file mode 100644 index 000000000..92e08e4aa --- /dev/null +++ b/docker/compose/docker-compose-unified-both.yml @@ -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 diff --git a/docker/compose/docker-compose-unified-frontend.yml b/docker/compose/docker-compose-unified-frontend.yml new file mode 100644 index 000000000..c7d217b34 --- /dev/null +++ b/docker/compose/docker-compose-unified-frontend.yml @@ -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 diff --git a/docker/unified/README.md b/docker/unified/README.md new file mode 100644 index 000000000..6f0488aa2 --- /dev/null +++ b/docker/unified/README.md @@ -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 diff --git a/docker/unified/build.sh b/docker/unified/build.sh new file mode 100644 index 000000000..5dd59d668 --- /dev/null +++ b/docker/unified/build.sh @@ -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 "===================================" diff --git a/docker/unified/entrypoint.sh b/docker/unified/entrypoint.sh new file mode 100644 index 000000000..92075ff3a --- /dev/null +++ b/docker/unified/entrypoint.sh @@ -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 diff --git a/docker/unified/nginx.conf b/docker/unified/nginx.conf new file mode 100644 index 000000000..77ee17f89 --- /dev/null +++ b/docker/unified/nginx.conf @@ -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; + } +} diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 0b92de05b..89928251b 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -1,9 +1,10 @@ // @ts-check import eslint from '@eslint/js'; -import globals from "globals"; +import globals from 'globals'; import { defineConfig } from 'eslint/config'; import tseslint from 'typescript-eslint'; +import importPlugin from 'eslint-plugin-import'; const srcGlobs = [ 'src/**/*.{js,mjs,jsx,ts,tsx}', @@ -14,35 +15,46 @@ const nodeGlobs = [ ]; export default defineConfig( + { + // Everything that contains 3rd party code that we don't want to lint + ignores: [ + 'dist', + 'node_modules', + 'public', + ], + }, eslint.configs.recommended, tseslint.configs.recommended, - { - ignores: [ - "dist", // Contains 3rd party code - "public", // Contains 3rd party code - ], - }, { rules: { - "@typescript-eslint/no-empty-object-type": [ - "error", + 'no-restricted-imports': [ + 'error', + { + patterns: [ + ".*", // Disallow any relative imports (they should be '@app/x/y/z' or similar) + "src/*", // Disallow any absolute imports (they should be '@app/x/y/z' or similar) + ], + }, + ], + '@typescript-eslint/no-empty-object-type': [ + 'error', { // Allow empty extending interfaces because there's no real reason not to, and it makes it obvious where to put extra attributes in the future allowInterfaces: 'with-single-extends', }, ], - "@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant - "@typescript-eslint/no-require-imports": "off", // Temporarily disabled until codebase conformant - "@typescript-eslint/no-unused-vars": [ - "error", + '@typescript-eslint/no-explicit-any': 'off', // Temporarily disabled until codebase conformant + '@typescript-eslint/no-require-imports': 'off', // Temporarily disabled until codebase conformant + '@typescript-eslint/no-unused-vars': [ + 'error', { - "args": "all", // All function args must be used (or explicitly ignored) - "argsIgnorePattern": "^_", // Allow unused variables beginning with an underscore - "caughtErrors": "all", // Caught errors must be used (or explicitly ignored) - "caughtErrorsIgnorePattern": "^_", // Allow unused variables beginning with an underscore - "destructuredArrayIgnorePattern": "^_", // Allow unused variables beginning with an underscore - "varsIgnorePattern": "^_", // Allow unused variables beginning with an underscore - "ignoreRestSiblings": true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky) + 'args': 'all', // All function args must be used (or explicitly ignored) + 'argsIgnorePattern': '^_', // Allow unused variables beginning with an underscore + 'caughtErrors': 'all', // Caught errors must be used (or explicitly ignored) + 'caughtErrorsIgnorePattern': '^_', // Allow unused variables beginning with an underscore + 'destructuredArrayIgnorePattern': '^_', // Allow unused variables beginning with an underscore + 'varsIgnorePattern': '^_', // Allow unused variables beginning with an underscore + 'ignoreRestSiblings': true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky) }, ], }, @@ -65,4 +77,21 @@ export default defineConfig( } } }, + // Config for import plugin + { + ...importPlugin.flatConfigs.recommended, + ...importPlugin.flatConfigs.typescript, + rules: { + // ...importPlugin.flatConfigs.recommended.rules, // Temporarily disabled until codebase conformant + ...importPlugin.flatConfigs.typescript.rules, + 'import/no-cycle': 'error', + }, + settings: { + 'import/resolver': { + typescript: { + project: './tsconfig.json', + }, + }, + }, + }, ); diff --git a/frontend/index.html b/frontend/index.html index b563bdcd8..b8c8bcc57 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,6 +18,6 @@
- + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 51e500cb7..c884272c4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,24 +10,24 @@ "license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", - "@embedpdf/core": "^1.3.1", - "@embedpdf/engines": "^1.3.1", - "@embedpdf/plugin-annotation": "^1.3.1", - "@embedpdf/plugin-export": "^1.3.1", - "@embedpdf/plugin-history": "^1.3.1", - "@embedpdf/plugin-interaction-manager": "^1.3.1", - "@embedpdf/plugin-loader": "^1.3.1", - "@embedpdf/plugin-pan": "^1.3.1", - "@embedpdf/plugin-render": "^1.3.1", - "@embedpdf/plugin-rotate": "^1.3.1", - "@embedpdf/plugin-scroll": "^1.3.1", - "@embedpdf/plugin-search": "^1.3.1", - "@embedpdf/plugin-selection": "^1.3.1", - "@embedpdf/plugin-spread": "^1.3.1", - "@embedpdf/plugin-thumbnail": "^1.3.1", - "@embedpdf/plugin-tiling": "^1.3.1", - "@embedpdf/plugin-viewport": "^1.3.1", - "@embedpdf/plugin-zoom": "^1.3.1", + "@embedpdf/core": "^1.3.14", + "@embedpdf/engines": "^1.3.14", + "@embedpdf/plugin-annotation": "^1.3.14", + "@embedpdf/plugin-export": "^1.3.14", + "@embedpdf/plugin-history": "^1.3.14", + "@embedpdf/plugin-interaction-manager": "^1.3.14", + "@embedpdf/plugin-loader": "^1.3.14", + "@embedpdf/plugin-pan": "^1.3.14", + "@embedpdf/plugin-render": "^1.3.14", + "@embedpdf/plugin-rotate": "^1.3.14", + "@embedpdf/plugin-scroll": "^1.3.14", + "@embedpdf/plugin-search": "^1.3.14", + "@embedpdf/plugin-selection": "^1.3.14", + "@embedpdf/plugin-spread": "^1.3.14", + "@embedpdf/plugin-thumbnail": "^1.3.14", + "@embedpdf/plugin-tiling": "^1.3.14", + "@embedpdf/plugin-viewport": "^1.3.14", + "@embedpdf/plugin-zoom": "^1.3.14", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@iconify/react": "^6.0.2", @@ -37,6 +37,7 @@ "@mantine/hooks": "^8.3.1", "@mui/icons-material": "^7.3.2", "@mui/material": "^7.3.2", + "@reactour/tour": "^3.8.0", "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-virtual": "^3.13.12", "autoprefixer": "^10.4.21", @@ -79,6 +80,8 @@ "@vitejs/plugin-react-swc": "^4.1.0", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.36.0", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-react-hooks": "^5.2.0", "jsdom": "^27.0.0", "license-checker": "^25.0.1", @@ -87,9 +90,11 @@ "postcss-cli": "^11.0.1", "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", + "puppeteer": "^24.25.0", "typescript": "^5.9.2", "typescript-eslint": "^8.44.1", "vite": "^7.1.7", + "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.2.4" } }, @@ -497,13 +502,13 @@ } }, "node_modules/@embedpdf/core": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz", - "integrity": "sha512-2Az6trhiMMBIv+GFvV8H8UOS1gwQn7NK0KaJMcdsZbUHYLO0P95aVd6Pi/GRzEH4XyF51TDIoTOAUtf07TQ5dQ==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.14.tgz", + "integrity": "sha512-lE/vfhA53CxamaCfGWEibrEPr+JeZT42QCF+cOELUwv4+Zt6b+IE6+4wsznx/8wjjJYwllXJ3GJ/un1UzTqARw==", "license": "MIT", "dependencies": { - "@embedpdf/engines": "1.3.1", - "@embedpdf/models": "1.3.1" + "@embedpdf/engines": "1.3.14", + "@embedpdf/models": "1.3.14" }, "peerDependencies": { "preact": "^10.26.4", @@ -513,13 +518,13 @@ } }, "node_modules/@embedpdf/engines": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.3.1.tgz", - "integrity": "sha512-G3pI+18la7spviUMuA5s9/hV95jlfkA2+CNxqlHBO5ocw3641E3d36Lv+mx+6yU7k0B5vEOQPZDGRMg7KFziBQ==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.3.14.tgz", + "integrity": "sha512-+/FPW2gAzj2lQYvsMH/Oj9+MEXgkyEuyYDC+HFkltTuXvmiP2S/3BD0YslZDX9K4BzcmMxnWB+BiQpNJokbDVg==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.1", - "@embedpdf/pdfium": "1.3.1" + "@embedpdf/models": "1.3.14", + "@embedpdf/pdfium": "1.3.14" }, "peerDependencies": { "preact": "^10.26.4", @@ -529,31 +534,31 @@ } }, "node_modules/@embedpdf/models": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.3.1.tgz", - "integrity": "sha512-OzmO1rQAuOP/Y3aYXmW21dPNAx49olhr9ZO2hDdI0fbNBHTVGxnaKqOISxVmUz7TmhTwVBljERACnaA8Ib4b4Q==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.3.14.tgz", + "integrity": "sha512-BujY4bmr8b2DQdoZkOge03SzoRVoWxzfIQATLSPPtp4WiFh1U4BPp6cADlGuCwGkp6zBcH/aM4h8PwwA75d/eg==", "license": "MIT" }, "node_modules/@embedpdf/pdfium": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.3.1.tgz", - "integrity": "sha512-qYGSS5ntz6DSY9Cxw/aigvHqGB+AKJLEcymNTZOL0GdlBzZpL++dOIYNEYHO2Tm/lOQVpE7I0e+Xh2TvD8O1zQ==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.3.14.tgz", + "integrity": "sha512-TQMZabXzHmzvvfPwopubFcYgQuYV7POvMgjICYu3Pgfn3sgr+UdIUh3aNXR/COcl3q8sXPMFQ2GDuyOHR9QQnA==", "license": "MIT" }, "node_modules/@embedpdf/plugin-annotation": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-1.3.1.tgz", - "integrity": "sha512-mmePRYYBB8v8NIZ95XVfFkpyQ2QiKIGdWyvrPeJXSbL3/K6d6ix+o/jHBVvBWyTsQzdIlzs+FW8+iT0M1zkEow==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-1.3.14.tgz", + "integrity": "sha512-JJYqEWwUKCdBZsXCDq/CW96p3pVLn8N+XZ4W3OyL7djI2fvYC9x6ys9m82vwlSathAVOxk1D7xXiY8AzJQVF0Q==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.1", - "@embedpdf/utils": "1.3.1" + "@embedpdf/models": "1.3.14", + "@embedpdf/utils": "1.3.14" }, "peerDependencies": { - "@embedpdf/core": "1.3.1", - "@embedpdf/plugin-history": "1.3.1", - "@embedpdf/plugin-interaction-manager": "1.3.1", - "@embedpdf/plugin-selection": "1.3.1", + "@embedpdf/core": "1.3.14", + "@embedpdf/plugin-history": "1.3.14", + "@embedpdf/plugin-interaction-manager": "1.3.14", + "@embedpdf/plugin-selection": "1.3.14", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -561,15 +566,15 @@ } }, "node_modules/@embedpdf/plugin-export": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.3.1.tgz", - "integrity": "sha512-reb03vNPFP5GuIAFExMcuYBVYu/deVO2v8EoCwRZ/lzzYMORIkJjpNWDQPo9VfyGBh1x4/o3CHvxisU1Y1tDLg==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.3.14.tgz", + "integrity": "sha512-fMGp2YxvI4uTRIViUKxfnJts2Jw/vktEM45XUNGNSjT/kAW6znVNgdceYjpK++xU8CGs2grAQ1i5UvMd3aRNDA==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.1" + "@embedpdf/models": "1.3.14" }, "peerDependencies": { - "@embedpdf/core": "1.3.1", + "@embedpdf/core": "1.3.14", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -577,15 +582,15 @@ } }, "node_modules/@embedpdf/plugin-history": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.3.1.tgz", - "integrity": "sha512-HrPkWQmAk08mbHiOcIN4htVq5KJMqI9zSjAqaYQEhV/TugeHfWVpK+xMst/PzuFb14HWgk5gWXjtV5E4SDlw9w==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.3.14.tgz", + "integrity": "sha512-77hnNLp0W0FHw8lT7SeqzCgp8bOClfeOAPZdcInu/jPDhVASUGYbtE/0fkLhiaqPH7kyMirNCLif4sF6n4b5vg==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.1" + "@embedpdf/models": "1.3.14" }, "peerDependencies": { - "@embedpdf/core": "1.3.1", + "@embedpdf/core": "1.3.14", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -593,15 +598,15 @@ } }, "node_modules/@embedpdf/plugin-interaction-manager": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.3.1.tgz", - "integrity": "sha512-8h3y5a9tQ1fZlc4mP1/+XKyuHWwcQEm9AujKxy+6f6omtCBzpnKrH95bURgYOzQEBGY7d5C3HvG6JOlh0o1x3A==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.3.14.tgz", + "integrity": "sha512-nR0ZxNoTQtGqOHhweFh6QJ+nUJ4S4Ag1wWur6vAUAi8U95HUOfZhOEa0polZo0zR9WmmblGqRWjFM+mVSOoi1w==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.1" + "@embedpdf/models": "1.3.14" }, "peerDependencies": { - "@embedpdf/core": "1.3.1", + "@embedpdf/core": "1.3.14", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -609,15 +614,15 @@ } }, "node_modules/@embedpdf/plugin-loader": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.3.1.tgz", - "integrity": "sha512-NjNmA7TOs3E/zwb9I+YohzyGkxq8y5NUGu0MKgh2g41lZoFvyqTAjFPar+RjEiLX8iiJiwNZswyJsNrytmS3Xg==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.3.14.tgz", + "integrity": "sha512-KoJX1MacEWE2DrO1OeZeG/Ehz76//u+ida/xb4r9BfwqAp5TfYlksq09cOvcF8LMW5FY4pbAL+AHKI1Hjz+HNA==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.1" + "@embedpdf/models": "1.3.14" }, "peerDependencies": { - "@embedpdf/core": "1.3.1", + "@embedpdf/core": "1.3.14", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -625,17 +630,17 @@ } }, "node_modules/@embedpdf/plugin-pan": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.3.1.tgz", - "integrity": "sha512-lF1gkz/a77G3+Rr8MOefkGnPJ1i5xWnClXm2ZzYAl7PbOScp59/PaP7qeU7eMPC4FHQM81ZhCgVYGXogbaB8ww==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.3.14.tgz", + "integrity": "sha512-7EG+I5nn8yDCV8pT4x/g5mv7zJli2t3wPrh6Kt8uIpUorPHNb6J0Z67gl0uc/8rEasNzuKOuT0er46Y6/UYLzQ==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.1" + "@embedpdf/models": "1.3.14" }, "peerDependencies": { - "@embedpdf/core": "1.3.1", - "@embedpdf/plugin-interaction-manager": "1.3.1", - "@embedpdf/plugin-viewport": "1.3.1", + "@embedpdf/core": "1.3.14", + "@embedpdf/plugin-interaction-manager": "1.3.14", + "@embedpdf/plugin-viewport": "1.3.14", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -643,15 +648,15 @@ } }, "node_modules/@embedpdf/plugin-render": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.3.1.tgz", - "integrity": "sha512-c9oH097e1CVUpYF9RgZRfV/7XCJ0pf+svdT1wyM2MbWby06ti20oCwT9wf7BLY0hPQ7+eO3wunr1I1/y3MnVrw==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.3.14.tgz", + "integrity": "sha512-IPj7GCQXJBsY++JaU+z7y+FwX5NaDBj4YYV6hsHNtSGf42Y1AdlwJzDYetivG2bA84xmk7KgD1X2Y3eIFBhjwA==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.1" + "@embedpdf/models": "1.3.14" }, "peerDependencies": { - "@embedpdf/core": "1.3.1", + "@embedpdf/core": "1.3.14", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -659,15 +664,15 @@ } }, "node_modules/@embedpdf/plugin-rotate": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.3.1.tgz", - "integrity": "sha512-mRAlIW7IZAnCyDuYqN13yDc6yoNIYLUB4uYTUAR7vTIt021C8H5jDHk9TmLwcH0tQ8/R3yHuDm/XPAe0zfs81g==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.3.14.tgz", + "integrity": "sha512-OroEm11x/fPPXI9C0X+nm9LOjwaI0MvsToZRH+HpV60/FbQeOJvt6D8wThCDVLK95Na6A+JeYIMEu+Hiix7H+A==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.1" + "@embedpdf/models": "1.3.14" }, "peerDependencies": { - "@embedpdf/core": "1.3.1", + "@embedpdf/core": "1.3.14", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -675,16 +680,16 @@ } }, "node_modules/@embedpdf/plugin-scroll": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.3.1.tgz", - "integrity": "sha512-mDvK3DyBZC8/8pOEdJsWtSjCmV2ZuZJJ6xfspJpsaDVywo1Vq6M55BtKThkhqED6mqbFWTN9rP9cbWG8KDBWVA==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.3.14.tgz", + "integrity": "sha512-fQbt7OlRMLQJMuZj/Bzh0qpRxMw1ld5Qe/OTw8N54b/plljnFA52joE7cITl3H03huWWyHS3NKOScbw7f34dog==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.1" + "@embedpdf/models": "1.3.14" }, "peerDependencies": { - "@embedpdf/core": "1.3.1", - "@embedpdf/plugin-viewport": "1.3.1", + "@embedpdf/core": "1.3.14", + "@embedpdf/plugin-viewport": "1.3.14", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -692,16 +697,16 @@ } }, "node_modules/@embedpdf/plugin-search": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.3.1.tgz", - "integrity": "sha512-SLwYPQg1NJWytq2sd4MnWFmRVGgzwbohBedB2kH0ALsvdnoRYqgjR5HqAsKgoRJO/pphQhHlk3L1gLW62r6hqQ==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.3.14.tgz", + "integrity": "sha512-tlZEgR2tG+GSNnh2u1SjCxhUHfTDgcr38sE/xRK1bRLDGPZWlr6Ln7qP7JSWqeYBGni75sGrj0iZqcZbPWyJag==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.1" + "@embedpdf/models": "1.3.14" }, "peerDependencies": { - "@embedpdf/core": "1.3.1", - "@embedpdf/plugin-loader": "1.3.1", + "@embedpdf/core": "1.3.14", + "@embedpdf/plugin-loader": "1.3.14", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -709,17 +714,17 @@ } }, "node_modules/@embedpdf/plugin-selection": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.3.1.tgz", - "integrity": "sha512-yef2XB/zR7zjyeUB3Ul0SbTcXqu5isR0GtINkFwL7bJMok6HpYNDnMXSuo55BaxI0dOCnnCSZfoRkAgosnZ1uQ==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.3.14.tgz", + "integrity": "sha512-EXENuaAsse3rT6cjA1nYzyrNvoy62ojJl28wblCng6zcs3HSlGPemIQZAvaYKPUxoY608M+6nKlcMQ5neRnk/A==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.1" + "@embedpdf/models": "1.3.14" }, "peerDependencies": { - "@embedpdf/core": "1.3.1", - "@embedpdf/plugin-interaction-manager": "1.3.1", - "@embedpdf/plugin-viewport": "1.3.1", + "@embedpdf/core": "1.3.14", + "@embedpdf/plugin-interaction-manager": "1.3.14", + "@embedpdf/plugin-viewport": "1.3.14", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -727,16 +732,16 @@ } }, "node_modules/@embedpdf/plugin-spread": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.3.1.tgz", - "integrity": "sha512-RJ/kgJsFRdtWlPMXTW1feUSb6WHIvxtNRLgqzX8dlFIoyc4oZex2Vw+URo/VZuWSe/NvCIihQ20rkNAQJMnNMQ==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.3.14.tgz", + "integrity": "sha512-DVlk6tDgUoDRkp2S4Jc3LrRTuf4DPMlph9vywJw5z6Qpbh0vgcMnObg896/S0Eu5FgACNAj0WGcXpLrcrn5b9Q==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.1" + "@embedpdf/models": "1.3.14" }, "peerDependencies": { - "@embedpdf/core": "1.3.1", - "@embedpdf/plugin-loader": "1.3.1", + "@embedpdf/core": "1.3.14", + "@embedpdf/plugin-loader": "1.3.14", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -744,34 +749,35 @@ } }, "node_modules/@embedpdf/plugin-thumbnail": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.3.1.tgz", - "integrity": "sha512-xv96ESa7JgD5z+TzcOK18/u0gq3d9v7QPv2wpr0ZhcnwLwf4sH0eUJZIsv7z7DMOpBNz7o7jJbrtxDUdCEHGhg==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.3.14.tgz", + "integrity": "sha512-cnwb5dG8Jph8XSArys1WFCQ6kK2R5FKoO0B5mDrHFv9Fcm2pKszlmZC/NDoskX4pgNUgSnwhI1X3cP37ebF9Ng==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.1" + "@embedpdf/models": "1.3.14" }, "peerDependencies": { - "@embedpdf/core": "1.3.1", - "@embedpdf/plugin-render": "1.3.1", + "@embedpdf/core": "1.3.14", + "@embedpdf/plugin-render": "1.3.14", "preact": "^10.26.4", "react": ">=16.8.0", - "react-dom": ">=16.8.0" + "react-dom": ">=16.8.0", + "vue": ">=3.2.0" } }, "node_modules/@embedpdf/plugin-tiling": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.3.1.tgz", - "integrity": "sha512-Q8RF80fb6y9GDAKwvgsu0BsWJlQuhNCtSKWwp3YcZJtIBFm94DVcg0zTgvDmE9/WNOmn4Z1Edt86usmYauHolw==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.3.14.tgz", + "integrity": "sha512-SaCTo2LdZwGeE6jCqkwJxvwt8YKbsI3QGxa9S7Ez+5OcBchlhHeTfLQswcErDQ3WH2p8WHtGuucAcOLrVVOm0A==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.1" + "@embedpdf/models": "1.3.14" }, "peerDependencies": { - "@embedpdf/core": "1.3.1", - "@embedpdf/plugin-render": "1.3.1", - "@embedpdf/plugin-scroll": "1.3.1", - "@embedpdf/plugin-viewport": "1.3.1", + "@embedpdf/core": "1.3.14", + "@embedpdf/plugin-render": "1.3.14", + "@embedpdf/plugin-scroll": "1.3.14", + "@embedpdf/plugin-viewport": "1.3.14", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -779,15 +785,15 @@ } }, "node_modules/@embedpdf/plugin-viewport": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.3.1.tgz", - "integrity": "sha512-gzosrWL18ZhN175Kxocf/p7uqYBhNHvEuV1CpJQmN7ys48aew6Qq8z7MjAsCnJBANXk/8syNdo3qWwBriyjQNg==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.3.14.tgz", + "integrity": "sha512-mfJ7EbbU68eKk6oFvQ4ozGJNpxUxWbjQ5Gm3uuB+Gj5/tWgBocBOX36k/9LgivEEeX7g2S0tOgyErljApmH8Vg==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.1" + "@embedpdf/models": "1.3.14" }, "peerDependencies": { - "@embedpdf/core": "1.3.1", + "@embedpdf/core": "1.3.14", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -795,19 +801,19 @@ } }, "node_modules/@embedpdf/plugin-zoom": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.3.1.tgz", - "integrity": "sha512-3GXpgv6XmZiQnjaPbsxblTqUn84ALFiyONh2gwrEU9apB6STT3TQiY0QRindwrUXdQLpCSjRSB9PpDBCtTww7w==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.3.14.tgz", + "integrity": "sha512-/N5tyMk+8OzhObrS3O9yPkcmX8EPiuTo+WaT2QCVSmIUqKnOO4AnKpHJ6Vl0uVhcuXHCMwLucZKyhJ7tRqavwg==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.1", + "@embedpdf/models": "1.3.14", "hammerjs": "^2.0.8" }, "peerDependencies": { - "@embedpdf/core": "1.3.1", - "@embedpdf/plugin-interaction-manager": "1.3.1", - "@embedpdf/plugin-scroll": "1.3.1", - "@embedpdf/plugin-viewport": "1.3.1", + "@embedpdf/core": "1.3.14", + "@embedpdf/plugin-interaction-manager": "1.3.14", + "@embedpdf/plugin-scroll": "1.3.14", + "@embedpdf/plugin-viewport": "1.3.14", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -815,9 +821,9 @@ } }, "node_modules/@embedpdf/utils": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-1.3.1.tgz", - "integrity": "sha512-6trYysnggwCCTB2q7cX6tkOTbZJNtt2YYZohPCmh0yaDpkfNSgwDwD0jCLtEU2UZLQoH4+2GvNo+4xe+KAGlIQ==", + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-1.3.14.tgz", + "integrity": "sha512-gxEJD12nageCMqAjdbicNfDQolXU3nvnV0EX96OdZITRNj0Q1tisutVYoaxcCiJu3vvIEOzipjsAnQOubbFCEA==", "license": "MIT", "peerDependencies": { "preact": "^10.26.4", @@ -826,6 +832,58 @@ "vue": ">=3.2.0" } }, + "node_modules/@emnapi/core": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", + "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/@emnapi/runtime": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -2454,6 +2512,18 @@ "node": ">= 10" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2553,6 +2623,79 @@ "integrity": "sha512-igElrcnRPJh2nWYACschjH4OwGwzSa6xVFzRDVzpnjirUivdJ8nv4hE+H31nvwE56MFhvvglfHuotnWLMcRW7w==", "license": "MIT" }, + "node_modules/@puppeteer/browsers": { + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.12.tgz", + "integrity": "sha512-mP9iLFZwH+FapKJLeA7/fLqOlSUwYpMwjR1P5J23qd4e7qGJwecJccJqHYrjw33jmIZYV4dtiTHPD/J+1e7cEw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.3", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@reactour/mask": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@reactour/mask/-/mask-1.2.0.tgz", + "integrity": "sha512-XLgBLWfKJybtZjNTSO5lt/SIvRlCZBadB6JfE/hO1ErqURRjYhnv+edC0Ki1haUCqMGFppWk3lwcPCjmK0xNog==", + "license": "MIT", + "dependencies": { + "@reactour/utils": "*" + }, + "peerDependencies": { + "react": "16.x || 17.x || 18.x || 19.x" + } + }, + "node_modules/@reactour/popover": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@reactour/popover/-/popover-1.3.0.tgz", + "integrity": "sha512-YdyjSmHPvEeQEcJM4gcGFa5pI/Yf4nZGqwG4JnT+rK1SyUJBIPnm4Gkl/h7/+1g0KCFMkwNwagS3ZiXvZB7ThA==", + "license": "MIT", + "dependencies": { + "@reactour/utils": "*" + }, + "peerDependencies": { + "react": "16.x || 17.x || 18.x || 19.x" + } + }, + "node_modules/@reactour/tour": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@reactour/tour/-/tour-3.8.0.tgz", + "integrity": "sha512-KZTFi1pAvoTVKKRdBN5+XCYxXBp4k4Ql/acZcXyPvec8VU24fkMSEeV+v8krfYQpoVcewxIu3gM6xWZZLjxi7w==", + "license": "MIT", + "dependencies": { + "@reactour/mask": "*", + "@reactour/popover": "*", + "@reactour/utils": "*" + }, + "peerDependencies": { + "react": "16.x || 17.x || 18.x || 19.x" + } + }, + "node_modules/@reactour/utils": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@reactour/utils/-/utils-0.6.0.tgz", + "integrity": "sha512-GqaLjQi7MJsgtAKjdiw2Eak1toFkADoLRnm1+HZpaD+yl+DkaHpC1N7JAl+kVOO5I17bWInPA+OFbXjO9Co8Qg==", + "license": "MIT", + "dependencies": { + "@rooks/use-mutation-observer": "^4.11.2", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": "16.x || 17.x || 18.x || 19.x" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.35", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz", @@ -2868,6 +3011,22 @@ "win32" ] }, + "node_modules/@rooks/use-mutation-observer": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/@rooks/use-mutation-observer/-/use-mutation-observer-4.11.2.tgz", + "integrity": "sha512-vpsdrZdr6TkB1zZJcHx+fR1YC/pHs2BaqcuYiEGjBVbwY5xcC49+h0hAUtQKHth3oJqXfIX/Ng8S7s5HFHdM/A==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -3501,6 +3660,13 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@ts-graphviz/adapter": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@ts-graphviz/adapter/-/adapter-2.0.6.tgz", @@ -3591,6 +3757,23 @@ "node": ">=18" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", + "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tybys/wasm-util/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -3683,6 +3866,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.5.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", @@ -3733,6 +3923,17 @@ "@types/react": "*" } }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.44.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz", @@ -3968,6 +4169,275 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@vitejs/plugin-react-swc": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.1.0.tgz", @@ -4390,6 +4860,23 @@ "dequal": "^2.0.3" } }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-find-index": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", @@ -4400,6 +4887,111 @@ "node": ">=0.10.0" } }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -4427,14 +5019,34 @@ "node": ">=18" } }, - "node_modules/ast-v8-to-istanbul": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.5.tgz", - "integrity": "sha512-9SdXjNheSiE8bALAQCQQuT6fgQaoxJh7IRYrRGZ8/9nv8WhJeC1aXAwN8TbaOssGOukUvyvnkgD9+Yuykvl1aA==", + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.30", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ast-types/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.7.tgz", + "integrity": "sha512-kr1Hy6YRZBkGQSb6puP+D6FQ59Cx4m0siYhAxygMCAgadiWQ6oxAxQXHOMvJx67SJ63jRoVIIg5eXzUbbct1ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^9.0.1" } @@ -4446,6 +5058,16 @@ "dev": true, "license": "MIT" }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -4498,6 +5120,22 @@ "postcss": "^8.1.0" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/axios": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", @@ -4509,6 +5147,21 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -4531,6 +5184,103 @@ "dev": true, "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.0.tgz", + "integrity": "sha512-AOhh6Bg5QmFIXdViHbMc2tLDsBIRxdkIaIddPslJF9Z5De3APBScuqGP2uThXnIpqFrgoxMNC6km7uXNIMLHXA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.4.11", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.4.11.tgz", + "integrity": "sha512-Bejmm9zRMvMTRoHS+2adgmXw1ANZnCNx+B5dgZpGwlP1E3x6Yuxea8RToddHUbWtVV0iUMWqsgZr8+jcgUI2SA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.0.tgz", + "integrity": "sha512-c+RCqMSZbkz97Mw1LWR0gcOqwK82oyYKfLoHJ8k13ybi1+I80ffdDzUy0TdAburdrR/kI0/VuN8YgEnJqX+Nyw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -4561,6 +5311,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/basic-ftp": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", + "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -4698,6 +5458,16 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -4735,6 +5505,25 @@ "node": ">=18" } }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -4748,6 +5537,23 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -4878,6 +5684,20 @@ "node": ">=18" } }, + "node_modules/chromium-bidi": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-9.1.0.tgz", + "integrity": "sha512-rlUzQ4WzIAWdIbY/viPShhZU2n21CxDUgazXVbw4Hu1MwaeUSEksSeM6DqPgpRjCLXRk702AVRxJxoOz0dw4OA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -5137,6 +5957,16 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/data-urls": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", @@ -5151,6 +5981,60 @@ "node": ">=20" } }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/dayjs": { "version": "1.11.18", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", @@ -5268,6 +6152,57 @@ "node": ">=10" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -5482,6 +6417,13 @@ "typescript": "^5.4.4" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1508733", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz", + "integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -5493,6 +6435,19 @@ "wrappy": "1" } }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -5544,6 +6499,16 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -5570,6 +6535,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eol": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/eol/-/eol-0.10.0.tgz", @@ -5588,6 +6563,75 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -5640,6 +6684,37 @@ "node": ">= 0.4" } }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/esbuild": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", @@ -5797,6 +6872,220 @@ } } }, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz", + "integrity": "sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==", + "dev": true, + "license": "ISC", + "dependencies": { + "debug": "^4.4.1", + "eslint-import-context": "^0.1.8", + "get-tsconfig": "^4.10.1", + "is-bun-module": "^2.0.0", + "stable-hash-x": "^0.2.0", + "tinyglobby": "^0.2.14", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^16.17.0 || >=18.6.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-import/node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, "node_modules/eslint-plugin-react-hooks": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", @@ -5988,6 +7277,16 @@ "node": ">=0.10.0" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/expect-type": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", @@ -6005,6 +7304,43 @@ "dev": true, "license": "MIT" }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6012,6 +7348,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -6066,6 +7409,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fflate": { "version": "0.4.8", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", @@ -6228,6 +7581,22 @@ } } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -6342,6 +7711,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-amd-module-type": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-amd-module-type/-/get-amd-module-type-6.0.1.tgz", @@ -6444,6 +7844,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -6515,6 +7961,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, "node_modules/gonzales-pe": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/gonzales-pe/-/gonzales-pe-4.3.0.tgz", @@ -6590,6 +8060,19 @@ "node": ">=0.8.0" } }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6600,6 +8083,35 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -6899,12 +8411,91 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -6918,6 +8509,46 @@ "node": ">=8" } }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -6933,6 +8564,41 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -6943,6 +8609,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -6953,6 +8635,25 @@ "node": ">=8" } }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -6976,6 +8677,32 @@ "node": ">=8" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -6986,6 +8713,23 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-obj": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", @@ -7003,6 +8747,25 @@ "dev": true, "license": "MIT" }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-regexp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", @@ -7013,6 +8776,35 @@ "node": ">=0.10.0" } }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-stream": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", @@ -7025,6 +8817,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -7058,6 +8901,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -8055,6 +9944,13 @@ "node": ">= 18" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -8170,6 +10066,22 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-postinstall": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz", + "integrity": "sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -8177,6 +10089,16 @@ "dev": true, "license": "MIT" }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -8338,6 +10260,103 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -8438,6 +10457,24 @@ "os-tmpdir": "^1.0.0" } }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-cancelable": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz", @@ -8479,6 +10516,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -8662,6 +10733,13 @@ "@napi-rs/canvas": "^0.1.77" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8745,6 +10823,16 @@ "node": ">=4" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -9177,6 +11265,16 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -9194,12 +11292,53 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -9210,6 +11349,74 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "24.25.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.25.0.tgz", + "integrity": "sha512-P3rUaom2w/Ubrnz3v3kSbxGkN7SpbtQeGRPb7iO86Bv/dAz2WUmGQBHr37W/Rp1fbAocMvu0rHFbCIJvjiNhGw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.10.12", + "chromium-bidi": "9.1.0", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1508733", + "puppeteer-core": "24.25.0", + "typed-query-selector": "^2.12.0" + }, + "bin": { + "puppeteer": "lib/cjs/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core": { + "version": "24.25.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.25.0.tgz", + "integrity": "sha512-8Xs6q3Ut+C8y7sAaqjIhzv1QykGWG4gc2mEZ2mYE7siZFuRp4xQVehOf8uQKSQAkeL7jXUs3mknEeiqnRqUKvQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.10.12", + "chromium-bidi": "9.1.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1508733", + "typed-query-selector": "^2.12.0", + "webdriver-bidi-protocol": "0.3.7", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer/node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -9650,6 +11857,50 @@ "node": ">=8" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -9698,6 +11949,12 @@ "node": ">=10.13.0" } }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -9743,6 +12000,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/responselike": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", @@ -9856,12 +12123,81 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -9916,9 +12252,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -9950,6 +12286,55 @@ "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", "license": "MIT" }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -9979,6 +12364,82 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -10022,6 +12483,47 @@ "node": "*" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -10120,6 +12622,16 @@ "wordwrap": "cli.js" } }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -10134,6 +12646,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/stream-to-array": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/stream-to-array/-/stream-to-array-2.3.0.tgz", @@ -10144,6 +12670,18 @@ "any-promise": "^1.1.0" } }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -10184,6 +12722,65 @@ "node": ">=8" } }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/stringify-object": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", @@ -10423,6 +13020,33 @@ "node": ">=18" } }, + "node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/test-exclude": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", @@ -10459,6 +13083,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -10672,6 +13306,27 @@ "node": ">=18" } }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "dev": true, + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/tsconfig-paths": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", @@ -10718,6 +13373,91 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", @@ -10763,6 +13503,25 @@ "dev": true, "license": "MIT" }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/undici-types": { "version": "7.12.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", @@ -10780,6 +13539,41 @@ "node": ">= 10.0.0" } }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -11063,6 +13857,26 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite-tsconfig-paths": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz", + "integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3" + }, + "peerDependencies": { + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -11272,6 +14086,13 @@ "integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==", "license": "Apache-2.0" }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.7.tgz", + "integrity": "sha512-wIx5Gu/LLTeexxilpk8WxU2cpGAKlfbWRO5h+my6EMD1k5PYqM1qQO1MHUFf4f3KRnhBvpbZU7VkizAgeSEf7g==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", @@ -11335,6 +14156,102 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -11506,6 +14423,17 @@ "node": ">=12" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -11518,6 +14446,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 4eb01f202..892e48569 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,24 +6,24 @@ "proxy": "http://localhost:8080", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", - "@embedpdf/core": "^1.3.1", - "@embedpdf/engines": "^1.3.1", - "@embedpdf/plugin-annotation": "^1.3.1", - "@embedpdf/plugin-export": "^1.3.1", - "@embedpdf/plugin-history": "^1.3.1", - "@embedpdf/plugin-interaction-manager": "^1.3.1", - "@embedpdf/plugin-loader": "^1.3.1", - "@embedpdf/plugin-pan": "^1.3.1", - "@embedpdf/plugin-render": "^1.3.1", - "@embedpdf/plugin-rotate": "^1.3.1", - "@embedpdf/plugin-scroll": "^1.3.1", - "@embedpdf/plugin-search": "^1.3.1", - "@embedpdf/plugin-selection": "^1.3.1", - "@embedpdf/plugin-spread": "^1.3.1", - "@embedpdf/plugin-thumbnail": "^1.3.1", - "@embedpdf/plugin-tiling": "^1.3.1", - "@embedpdf/plugin-viewport": "^1.3.1", - "@embedpdf/plugin-zoom": "^1.3.1", + "@embedpdf/core": "^1.3.14", + "@embedpdf/engines": "^1.3.14", + "@embedpdf/plugin-annotation": "^1.3.14", + "@embedpdf/plugin-export": "^1.3.14", + "@embedpdf/plugin-history": "^1.3.14", + "@embedpdf/plugin-interaction-manager": "^1.3.14", + "@embedpdf/plugin-loader": "^1.3.14", + "@embedpdf/plugin-pan": "^1.3.14", + "@embedpdf/plugin-render": "^1.3.14", + "@embedpdf/plugin-rotate": "^1.3.14", + "@embedpdf/plugin-scroll": "^1.3.14", + "@embedpdf/plugin-search": "^1.3.14", + "@embedpdf/plugin-selection": "^1.3.14", + "@embedpdf/plugin-spread": "^1.3.14", + "@embedpdf/plugin-thumbnail": "^1.3.14", + "@embedpdf/plugin-tiling": "^1.3.14", + "@embedpdf/plugin-viewport": "^1.3.14", + "@embedpdf/plugin-zoom": "^1.3.14", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@iconify/react": "^6.0.2", @@ -33,6 +33,7 @@ "@mantine/hooks": "^8.3.1", "@mui/icons-material": "^7.3.2", "@mui/material": "^7.3.2", + "@reactour/tour": "^3.8.0", "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-virtual": "^3.13.12", "autoprefixer": "^10.4.21", @@ -56,16 +57,20 @@ }, "scripts": { "predev": "npm run generate-icons", - "dev": "npm run typecheck && vite", + "dev": "vite", "prebuild": "npm run generate-icons", - "lint": "eslint", - "build": "npm run typecheck && vite build", + "lint": "eslint --max-warnings=0", + "build": "vite build", "preview": "vite preview", "typecheck": "tsc --noEmit", + "typecheck:core": "tsc --noEmit --project tsconfig.core.json", + "typecheck:proprietary": "tsc --noEmit --project tsconfig.proprietary.json", + "typecheck:all": "npm run typecheck:core && npm run typecheck:proprietary", "check": "npm run typecheck && npm run lint && npm run test:run", "generate-licenses": "node scripts/generate-licenses.js", "generate-icons": "node scripts/generate-icons.js", "generate-icons:verbose": "node scripts/generate-icons.js --verbose", + "generate-sample-pdf": "node scripts/sample-pdf/generate.mjs", "test": "vitest", "test:run": "vitest run", "test:watch": "vitest --watch", @@ -118,6 +123,8 @@ "@vitejs/plugin-react-swc": "^4.1.0", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.36.0", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-react-hooks": "^5.2.0", "jsdom": "^27.0.0", "license-checker": "^25.0.1", @@ -126,9 +133,11 @@ "postcss-cli": "^11.0.1", "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", + "puppeteer": "^24.25.0", "typescript": "^5.9.2", "typescript-eslint": "^8.44.1", "vite": "^7.1.7", + "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.2.4" }, "depcheck": { diff --git a/frontend/public/Login/AddToPDF.png b/frontend/public/Login/AddToPDF.png new file mode 100644 index 000000000..94e9a0ded Binary files /dev/null and b/frontend/public/Login/AddToPDF.png differ diff --git a/frontend/public/Login/Firstpage.png b/frontend/public/Login/Firstpage.png new file mode 100644 index 000000000..f12133f4f Binary files /dev/null and b/frontend/public/Login/Firstpage.png differ diff --git a/frontend/public/Login/LoginBackgroundPanel.png b/frontend/public/Login/LoginBackgroundPanel.png new file mode 100644 index 000000000..4ea0e0ccf Binary files /dev/null and b/frontend/public/Login/LoginBackgroundPanel.png differ diff --git a/frontend/public/Login/SecurePDF.png b/frontend/public/Login/SecurePDF.png new file mode 100644 index 000000000..6184440e9 Binary files /dev/null and b/frontend/public/Login/SecurePDF.png differ diff --git a/frontend/public/Login/apple.svg b/frontend/public/Login/apple.svg new file mode 100644 index 000000000..b947f4b6b --- /dev/null +++ b/frontend/public/Login/apple.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/Login/azure.svg b/frontend/public/Login/azure.svg new file mode 100644 index 000000000..fc1130cbb --- /dev/null +++ b/frontend/public/Login/azure.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/Login/github.svg b/frontend/public/Login/github.svg new file mode 100644 index 000000000..651eaac2b --- /dev/null +++ b/frontend/public/Login/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/Login/google.svg b/frontend/public/Login/google.svg new file mode 100644 index 000000000..27e4a4ac9 --- /dev/null +++ b/frontend/public/Login/google.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/Login/microsoft.svg b/frontend/public/Login/microsoft.svg new file mode 100644 index 000000000..fc1130cbb --- /dev/null +++ b/frontend/public/Login/microsoft.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 8294d34f5..b72f5cc54 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -13,7 +13,19 @@ "dismiss": "Maybe later" }, "fullscreen": { - "showDetails": "Show Details" + "showDetails": "Show Details", + "comingSoon": "Coming soon:", + "favorite": "Add to favourites", + "favorites": "Favourites", + "heading": "All tools (fullscreen view)", + "noResults": "Try adjusting your search or toggle descriptions to find what you need.", + "recommended": "Recommended", + "unfavorite": "Remove from favourites" + }, + "placeholder": "Choose a tool to get started", + "toggle": { + "fullscreen": "Switch to fullscreen mode", + "sidebar": "Switch to sidebar mode" } }, "unsavedChanges": "You have unsaved changes to your PDF.", @@ -56,7 +68,7 @@ "preview": "Position Selection", "previewDisclaimer": "Preview is approximate. Final output may vary due to PDF font metrics." }, - "pageSelectionPrompt": "Specify which pages to add numbers to. Examples: \"1,3,5\" for specific pages, \"1-5\" for ranges, \"2n\" for even pages, or leave blank for all pages.", + "pageSelectionPrompt": "Custom Page Selection (Enter a comma-separated list of page numbers 1,5,6 or Functions like 2n+1)", "startingNumberTooltip": "The first number to display. Subsequent pages will increment from this number.", "marginTooltip": "Distance between the page number and the edge of the page.", "fontSizeTooltip": "Size of the page number text in points. Larger numbers create bigger text.", @@ -72,7 +84,6 @@ "uploadLimitExceededPlural": "are too large. Maximum allowed size is", "processTimeWarning": "Warning: This process can take up to a minute depending on file-size", "pageOrderPrompt": "Custom Page Order (Enter a comma-separated list of page numbers or Functions like 2n+1) :", - "pageSelectionPrompt": "Custom Page Selection (Enter a comma-separated list of page numbers 1,5,6 or Functions like 2n+1) :", "goToPage": "Go", "true": "True", "false": "False", @@ -83,13 +94,18 @@ "save": "Save", "saveToBrowser": "Save to Browser", "download": "Download", - "pin": "Pin", - "unpin": "Unpin", + "pin": "Pin File (keep active after tool run)", + "unpin": "Unpin File (replace after tool run)", "undoOperationTooltip": "Click to undo the last operation and restore the original files", "undo": "Undo", "moreOptions": "More Options", "editYourNewFiles": "Edit your new file(s)", "close": "Close", + "openInViewer": "Open in Viewer", + "confirmClose": "Confirm Close", + "confirmCloseMessage": "Are you sure you want to close this file?", + "confirmCloseCancel": "Cancel", + "confirmCloseConfirm": "Close File", "fileSelected": "Selected: {{filename}}", "chooseFile": "Choose File", "filesSelected": "{{count}} files selected", @@ -99,7 +115,9 @@ "uploadFiles": "Upload Files", "addFiles": "Add files", "selectFromWorkbench": "Select files from the workbench or ", - "selectMultipleFromWorkbench": "Select at least {{count}} files from the workbench or " + "selectMultipleFromWorkbench": "Select at least {{count}} files from the workbench or ", + "created": "Created", + "size": "File Size" }, "noFavourites": "No favourites added", "downloadComplete": "Download Complete", @@ -232,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" @@ -284,7 +303,13 @@ "autoUnzipTooltip": "Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.", "autoUnzipFileLimit": "Auto-unzip file limit", "autoUnzipFileLimitDescription": "Maximum number of files to extract from ZIP", - "autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs." + "autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.", + "defaultToolPickerMode": "Default tool picker mode", + "defaultToolPickerModeDescription": "Choose whether the tool picker opens in fullscreen or sidebar by default", + "mode": { + "fullscreen": "Fullscreen", + "sidebar": "Sidebar" + } }, "hotkeys": { "title": "Keyboard Shortcuts", @@ -301,7 +326,8 @@ "change": "Change shortcut", "reset": "Reset", "shortcut": "Shortcut", - "noShortcut": "No shortcut set" + "noShortcut": "No shortcut set", + "searchPlaceholder": "Search tools..." } }, "changeCreds": { @@ -430,6 +456,9 @@ "alphabetical": "Alphabetical", "globalPopularity": "Global Popularity", "sortBy": "Sort by:", + "mobile": { + "brandAlt": "Stirling PDF logo" + }, "multiTool": { "tags": "multiple,tools", "title": "PDF Multi Tool", @@ -660,9 +689,9 @@ "title": "Manage Certificates", "desc": "Import, export, or delete digital certificate files used for signing PDFs." }, - "read": { - "tags": "view,open,display", - "title": "Read", + "read": { + "tags": "view,open,display", + "title": "Read", "desc": "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration." }, "reorganizePages": { @@ -719,6 +748,20 @@ "tags": "workflow,sequence,automation", "title": "Automate", "desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks." + }, + "mobile": { + "brandAlt": "Stirling PDF logo", + "openFiles": "Open files", + "swipeHint": "Swipe left or right to switch views", + "tools": "Tools", + "toolsSlide": "Tool selection panel", + "viewSwitcher": "Switch workspace view", + "workbenchSlide": "Workspace panel", + "workspace": "Workspace" + }, + "overlay-pdfs": { + "desc": "Overlay one PDF on top of another", + "title": "Overlay PDFs" } }, "landing": { @@ -904,13 +947,50 @@ "bullet1": "Bookmark Level: Which level to split on (1=top level)", "bullet2": "Include Metadata: Preserve document properties", "bullet3": "Allow Duplicates: Handle repeated bookmark names" + }, + "byDocCount": { + "bullet1": "Enter the number of output files you want", + "bullet2": "Pages are distributed as evenly as possible", + "bullet3": "Useful when you need a specific number of files", + "text": "Create a specific number of output files by evenly distributing pages across them.", + "title": "Split by Document Count" + }, + "byPageCount": { + "bullet1": "Enter the number of pages per output file", + "bullet2": "Last file may have fewer pages if not evenly divisible", + "bullet3": "Useful for batch processing workflows", + "text": "Create multiple PDFs with a specific number of pages each. Perfect for creating uniform document chunks.", + "title": "Split by Page Count" + }, + "byPageDivider": { + "bullet1": "Print divider sheets from the download link", + "bullet2": "Insert divider sheets between your documents", + "bullet3": "Scan all documents together as one PDF", + "bullet4": "Upload - divider pages are automatically detected and removed", + "bullet5": "Enable Duplex Mode if scanning both sides of divider sheets", + "text": "Automatically split scanned documents using physical divider sheets with QR codes. Perfect for processing multiple documents scanned together.", + "title": "Split by Page Divider" } - } + }, + "methodSelection": { + "tooltip": { + "bullet1": "Click on a method card to select it", + "bullet2": "Hover over each card to see a quick description", + "bullet3": "The settings step will appear after you select a method", + "bullet4": "You can change methods at any time before processing", + "header": { + "text": "Choose how you want to split your PDF document. Each method is optimized for different use cases and document types.", + "title": "Split Method Selection" + }, + "title": "Choose Your Split Method" + } + }, + "selectMethod": "Select a split method" }, "rotate": { "title": "Rotate PDF", "submit": "Apply Rotation", - "selectRotation": "Select Rotation Angle (Clockwise)", + "selectRotation": "Select Rotation Angle (Clockwise)", "error": { "failed": "An error occurred while rotating the PDF." }, @@ -998,7 +1078,8 @@ "imagesExt": "Images (JPG, PNG, etc.)", "markdown": "Markdown", "textRtf": "Text/RTF", - "grayscale": "Greyscale" + "grayscale": "Greyscale", + "errorConversion": "An error occurred while converting the file." }, "imageToPdf": { "tags": "conversion,img,jpg,picture,photo" @@ -1025,7 +1106,7 @@ "header": "PDF Page Organiser", "submit": "Rearrange Pages", "mode": { - "_value": "Organization mode", + "_value": "Organisation mode", "1": "Custom Page Order", "2": "Reverse Order", "3": "Duplex Sort", @@ -1036,7 +1117,20 @@ "8": "Remove Last", "9": "Remove First and Last", "10": "Odd-Even Merge", - "11": "Duplicate all pages" + "11": "Duplicate all pages", + "desc": { + "BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).", + "CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.", + "DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).", + "DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).", + "ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.", + "ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.", + "REMOVE_FIRST": "Remove the first page from the document.", + "REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document.", + "REMOVE_LAST": "Remove the last page from the document.", + "REVERSE_ORDER": "Flip the document so the last page becomes first and so on.", + "SIDE_STITCH_BOOKLET_SORT": "Arrange pages for side‑stitch booklet printing (optimized for binding on the side)." + } }, "desc": { "CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.", @@ -1102,7 +1196,9 @@ "opacity": "Opacity (%)", "spacing": { "horizontal": "Horizontal Spacing", - "vertical": "Vertical Spacing" + "vertical": "Vertical Spacing", + "height": "Height Spacing", + "width": "Width Spacing" }, "convertToImage": "Flatten PDF pages to images" }, @@ -1245,6 +1341,10 @@ "bullet4": "Best for sensitive or copyrighted content" } } + }, + "type": { + "1": "Text", + "2": "Image" } }, "permissions": { @@ -1358,6 +1458,38 @@ }, "examples": { "title": "Examples" + }, + "complex": { + "bullet1": "1,3-5,8,2n → pages 1, 3–5, 8, plus evens", + "bullet2": "10-,2n-1 → from page 10 to end + odd pages", + "description": "Mix different types.", + "title": "Complex Combinations" + }, + "description": "Choose which pages to use for the operation. Supports single pages, ranges, formulas, and the all keyword.", + "individual": { + "bullet1": "1,3,5 → selects pages 1, 3, 5", + "bullet2": "2,7,12 → selects pages 2, 7, 12", + "description": "Enter numbers separated by commas.", + "title": "Individual Pages" + }, + "mathematical": { + "bullet1": "2n → all even pages (2, 4, 6…)", + "bullet2": "2n-1 → all odd pages (1, 3, 5…)", + "bullet3": "3n → every 3rd page (3, 6, 9…)", + "bullet4": "4n-1 → pages 3, 7, 11, 15…", + "description": "Use n in formulas for patterns.", + "title": "Mathematical Functions" + }, + "ranges": { + "bullet1": "3-6 → selects pages 3–6", + "bullet2": "10-15 → selects pages 10–15", + "bullet3": "5- → selects pages 5 to end", + "description": "Use - for consecutive pages.", + "title": "Page Ranges" + }, + "special": { + "bullet1": "all → selects all pages", + "title": "Special Keywords" } } }, @@ -1667,6 +1799,9 @@ "text": "Post-processes the final PDF by removing OCR artefacts and optimising the text layer for better readability and smaller file size." } } + }, + "error": { + "failed": "OCR operation failed" } }, "extractImages": { @@ -1787,8 +1922,14 @@ "title": "Sign", "header": "Sign PDFs", "upload": "Upload Image", - "draw": "Draw Signature", - "text": "Text Input", + "draw": { + "title": "Draw your signature", + "clear": "Clear" + }, + "text": { + "name": "Signer Name", + "placeholder": "Enter your full name" + }, "clear": "Clear", "add": "Add", "saved": "Saved Signatures", @@ -1817,19 +1958,11 @@ "image": "Image", "text": "Text" }, - "draw": { - "title": "Draw your signature", - "clear": "Clear" - }, "image": { "label": "Upload signature image", "placeholder": "Select image file", "hint": "Upload a PNG or JPG image of your signature" }, - "text": { - "name": "Signer Name", - "placeholder": "Enter your full name" - }, "instructions": { "title": "How to add signature", "canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.", @@ -1956,7 +2089,13 @@ "bullet3": "Can be disabled to reduce output file size" } }, - "submit": "Remove blank pages" + "submit": "Remove blank pages", + "error": { + "failed": "Failed to remove blank pages" + }, + "results": { + "title": "Removed Blank Pages" + } }, "removeAnnotations": { "tags": "comments,highlight,notes,markup,remove", @@ -2058,7 +2197,12 @@ "bullet3": "Choose which page to place the signature", "bullet4": "Optional logo can be included" } - } + }, + "invisible": "Invisible", + "options": { + "title": "Signature Details" + }, + "visible": "Visible" }, "sign": { "submit": "Sign PDF", @@ -2119,7 +2263,22 @@ "text": "Convert your file to a Java keystore (.jks) with keytool, then pick JKS." } } - } + }, + "chooseCertificate": "Choose Certificate File", + "chooseJksFile": "Choose JKS File", + "chooseP12File": "Choose PKCS12 File", + "choosePfxFile": "Choose PFX File", + "choosePrivateKey": "Choose Private Key File", + "location": "Location", + "logoTitle": "Logo", + "name": "Name", + "noLogo": "No Logo", + "pageNumber": "Page Number", + "password": "Certificate Password", + "passwordOptional": "Leave empty if no password", + "reason": "Reason", + "serverCertMessage": "Using server certificate - no files or password required", + "showLogo": "Show Logo" }, "removeCertSign": { "tags": "authenticate,PEM,P12,official,decrypt", @@ -2145,7 +2304,17 @@ "header": "Multi Page Layout", "pagesPerSheet": "Pages per sheet:", "addBorder": "Add Borders", - "submit": "Submit" + "submit": "Submit", + "desc": { + "2": "Place 2 pages side-by-side on a single sheet.", + "3": "Place 3 pages on a single sheet in a single row.", + "4": "Place 4 pages on a single sheet (2 × 2 grid).", + "9": "Place 9 pages on a single sheet (3 × 3 grid).", + "16": "Place 16 pages on a single sheet (4 × 4 grid)." + }, + "error": { + "failed": "An error occurred while creating the multi-page layout." + } }, "bookletImposition": { "tags": "booklet,imposition,printing,binding,folding,signature", @@ -2331,10 +2500,22 @@ "reset": "Reset to full PDF", "coordinates": { "title": "Position and Size", - "x": "X Position", - "y": "Y Position", - "width": "Width", - "height": "Height" + "x": { + "label": "X Position", + "desc": "Left edge (points)" + }, + "y": { + "label": "Y Position", + "desc": "Bottom edge (points)" + }, + "width": { + "label": "Width", + "desc": "Crop width (points)" + }, + "height": { + "label": "Height", + "desc": "Crop height (points)" + } }, "error": { "invalidArea": "Crop area extends beyond PDF boundaries", @@ -2352,6 +2533,10 @@ }, "results": { "title": "Crop Results" + }, + "automation": { + "info": "Enter crop coordinates in PDF points. Origin (0,0) is at bottom-left. These values will be applied to all PDFs processed in this automation.", + "reference": "Reference: A4 page is 595.28 × 841.89 points (210mm × 297mm). 1 inch = 72 points." } }, "autoSplitPDF": { @@ -2581,7 +2766,8 @@ "counts": { "label": "Overlay Counts (for Fixed Repeat Mode)", "placeholder": "Enter comma-separated counts (e.g., 2,3,1)", - "item": "Count for file" + "item": "Count for file", + "noFiles": "Add overlay files to configure counts" }, "position": { "label": "Select Overlay Position", @@ -2622,6 +2808,9 @@ "title": "Counts (Fixed Repeat only)", "text": "Provide a positive number for each overlay file showing how many pages to take before moving to the next. Required when mode is Fixed Repeat." } + }, + "error": { + "failed": "An error occurred while overlaying PDFs." } }, "split-by-sections": { @@ -2657,7 +2846,18 @@ "customMargin": "Custom Margin", "customColor": "Custom Text Colour", "submit": "Submit", - "noStampSelected": "No stamp selected. Return to Step 1." + "noStampSelected": "No stamp selected. Return to Step 1.", + "customPosition": "Drag the stamp to the desired location in the preview window.", + "error": { + "failed": "An error occurred while adding stamp to the PDF." + }, + "imageSize": "Image Size", + "margin": "Margin", + "positionAndFormatting": "Position & Formatting", + "quickPosition": "Select a position on the page to place the stamp.", + "results": { + "title": "Stamp Results" + } }, "removeImagePdf": { "tags": "Remove Image,Page operations,Back end,server side" @@ -2675,7 +2875,8 @@ "status": { "_value": "Status", "valid": "Valid", - "invalid": "Invalid" + "invalid": "Invalid", + "complete": "Validation complete" }, "signer": "Signer", "date": "Date", @@ -2702,16 +2903,71 @@ "version": "Version", "keyUsage": "Key Usage", "selfSigned": "Self-Signed", - "bits": "bits" + "bits": "bits", + "details": "Certificate Details" }, "signature": { "info": "Signature Information", "_value": "Signature", "mathValid": "Signature is mathematically valid BUT:" }, - "selectCustomCert": "Custom Certificate File X.509 (Optional)" + "selectCustomCert": "Custom Certificate File X.509 (Optional)", + "downloadCsv": "Download CSV", + "downloadJson": "Download JSON", + "downloadPdf": "Download PDF Report", + "downloadType": { + "csv": "CSV", + "json": "JSON", + "pdf": "PDF" + }, + "error": { + "allFailed": "Unable to validate the selected files.", + "partial": "Some files could not be validated.", + "reportGeneration": "Could not generate the PDF report. JSON and CSV are available.", + "unexpected": "Unexpected error during validation." + }, + "finalizing": "Preparing downloads...", + "issue": { + "certExpired": "Certificate expired", + "certRevocationUnknown": "Certificate revocation status unknown", + "certRevoked": "Certificate revoked", + "chainInvalid": "Certificate chain invalid", + "signatureInvalid": "Signature cryptographic check failed", + "trustInvalid": "Certificate not trusted" + }, + "noResults": "Run the validation to generate a report.", + "noSignaturesShort": "No signatures", + "processing": "Validating signatures...", + "report": { + "continued": "Continued", + "downloads": "Downloads", + "entryLabel": "Signature Summary", + "fields": { + "created": "Created", + "fileSize": "File Size", + "signatureCount": "Total Signatures", + "signatureDate": "Signature Date" + }, + "filesEvaluated": "{{count}} files evaluated", + "footer": "Validated via Stirling PDF", + "generatedAt": "Generated", + "noPdf": "PDF report will be available after a successful validation.", + "page": "Page", + "shortTitle": "Signature Summary", + "signatureCountLabel": "{{count}} signatures", + "signaturesFound": "{{count}} signatures detected", + "signaturesValid": "{{count}} fully valid", + "title": "Signature Validation Report" + }, + "settings": { + "certHint": "Upload a trusted X.509 certificate to validate against a custom trust source.", + "title": "Validation Settings" + }, + "signatureDate": "Signature Date", + "totalSignatures": "Total Signatures" }, "replaceColor": { + "tags": "Replace Colour,Page operations,Back end,server side", "labels": { "settings": "Settings", "colourOperation": "Colour operation" @@ -2752,9 +3008,6 @@ "failed": "An error occurred while processing the colour replacement." } }, - "replaceColor": { - "tags": "Replace Colour,Page operations,Back end,server side" - }, "login": { "title": "Sign in", "header": "Sign in", @@ -2786,6 +3039,11 @@ "enterEmail": "Enter your email", "enterPassword": "Enter your password", "loggingIn": "Logging In...", + "username": "Username", + "enterUsername": "Enter username", + "useEmailInstead": "Login with email", + "forgotPassword": "Forgot your password?", + "logIn": "Log In", "signingIn": "Signing in...", "login": "Login", "or": "Or", @@ -2824,6 +3082,10 @@ "passwordsDoNotMatch": "Passwords do not match", "passwordTooShort": "Password must be at least 6 characters long", "invalidEmail": "Please enter a valid email address", + "nameRequired": "Name is required", + "emailRequired": "Email is required", + "passwordRequired": "Password is required", + "confirmPasswordRequired": "Confirm password is required", "checkEmailConfirmation": "Check your email for a confirmation link to complete your registration.", "accountCreatedSuccessfully": "Account created successfully! You can now sign in.", "unexpectedError": "Unexpected error: {{message}}" @@ -2870,7 +3132,19 @@ "contrast": "Contrast:", "brightness": "Brightness:", "saturation": "Saturation:", - "download": "Download" + "download": "Download", + "adjustColors": "Adjust Colors", + "blue": "Blue", + "confirm": "Confirm", + "error": { + "failed": "Failed to adjust colors/contrast" + }, + "green": "Green", + "noPreview": "Select a PDF to preview", + "red": "Red", + "results": { + "title": "Adjusted PDF" + } }, "compress": { "title": "Compress", @@ -3017,7 +3291,13 @@ "title": "Remove image", "header": "Remove image", "removeImage": "Remove image", - "submit": "Remove image" + "submit": "Remove image", + "error": { + "failed": "Failed to remove images from the PDF." + }, + "results": { + "title": "Remove Images Results" + } }, "splitByChapters": { "title": "Split PDF by Chapters", @@ -3092,6 +3372,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": { @@ -3162,7 +3446,9 @@ }, "search": { "title": "Search PDF", - "placeholder": "Enter search term..." + "placeholder": "Enter search term...", + "noResults": "No results found", + "searching": "Searching..." }, "guestBanner": { "title": "You're using Stirling PDF as a guest!", @@ -3200,10 +3486,361 @@ "automate": "Automate", "files": "Files", "activity": "Activity", + "help": "Help", "account": "Account", "config": "Config", + "adminSettings": "Admin Settings", "allTools": "All Tools" }, + "admin": { + "error": "Error", + "success": "Success", + "expand": "Expand", + "close": "Close", + "status": { + "active": "Active", + "inactive": "Inactive" + }, + "settings": { + "title": "Admin Settings", + "workspace": "Workspace", + "fetchError": "Failed to load settings", + "saveError": "Failed to save settings", + "saved": "Settings saved successfully", + "saveSuccess": "Settings saved successfully", + "save": "Save Changes", + "restartRequired": "Restart Required", + "restart": { + "title": "Restart Required", + "message": "Settings have been saved successfully. A server restart is required for the changes to take effect.", + "question": "Would you like to restart the server now or later?", + "now": "Restart Now", + "later": "Restart Later" + }, + "restarting": "Restarting Server", + "restartingMessage": "The server is restarting. Please wait a moment...", + "restartError": "Failed to restart server. Please restart manually.", + "general": { + "title": "General", + "description": "Configure general application settings including branding and default behaviour.", + "ui": "User Interface", + "system": "System", + "appName": "Application Name", + "appName.description": "The name displayed in the browser tab and home page", + "appNameNavbar": "Navbar Brand", + "appNameNavbar.description": "The name displayed in the navigation bar", + "homeDescription": "Home Description", + "homeDescription.description": "The description text shown on the home page", + "defaultLocale": "Default Locale", + "defaultLocale.description": "The default language for new users (e.g., en_US, es_ES)", + "fileUploadLimit": "File Upload Limit", + "fileUploadLimit.description": "Maximum file upload size (e.g., 100MB, 1GB)", + "showUpdate": "Show Update Notifications", + "showUpdate.description": "Display notifications when a new version is available", + "showUpdateOnlyAdmin": "Show Updates to Admins Only", + "showUpdateOnlyAdmin.description": "Restrict update notifications to admin users only", + "customHTMLFiles": "Custom HTML Files", + "customHTMLFiles.description": "Allow serving custom HTML files from the customFiles directory", + "languages": "Available Languages", + "languages.description": "Languages that users can select from (leave empty to enable all languages)", + "customMetadata": "Custom Metadata", + "customMetadata.autoUpdate": "Auto Update Metadata", + "customMetadata.autoUpdate.description": "Automatically update PDF metadata on all processed documents", + "customMetadata.author": "Default Author", + "customMetadata.author.description": "Default author for PDF metadata (e.g., username)", + "customMetadata.creator": "Default Creator", + "customMetadata.creator.description": "Default creator for PDF metadata", + "customMetadata.producer": "Default Producer", + "customMetadata.producer.description": "Default producer for PDF metadata", + "customPaths": "Custom Paths", + "customPaths.description": "Configure custom file system paths for pipeline processing and external tools", + "customPaths.pipeline": "Pipeline Directories", + "customPaths.pipeline.watchedFoldersDir": "Watched Folders Directory", + "customPaths.pipeline.watchedFoldersDir.description": "Directory where pipeline monitors for incoming PDFs (leave empty for default: /pipeline/watchedFolders)", + "customPaths.pipeline.finishedFoldersDir": "Finished Folders Directory", + "customPaths.pipeline.finishedFoldersDir.description": "Directory where processed PDFs are outputted (leave empty for default: /pipeline/finishedFolders)", + "customPaths.operations": "External Tool Paths", + "customPaths.operations.weasyprint": "WeasyPrint Executable", + "customPaths.operations.weasyprint.description": "Path to WeasyPrint executable for HTML to PDF conversion (leave empty for default: /opt/venv/bin/weasyprint)", + "customPaths.operations.unoconvert": "Unoconvert Executable", + "customPaths.operations.unoconvert.description": "Path to LibreOffice unoconvert for document conversions (leave empty for default: /opt/venv/bin/unoconvert)" + }, + "security": { + "title": "Security", + "description": "Configure authentication, login behaviour, and security policies.", + "ssoNotice": { + "title": "Looking for SSO/SAML settings?", + "message": "OAuth2 and SAML2 authentication providers have been moved to the Connections menu for easier management." + }, + "authentication": "Authentication", + "enableLogin": "Enable Login", + "enableLogin.description": "Require users to log in before accessing the application", + "loginMethod": "Login Method", + "loginMethod.description": "The authentication method to use for user login", + "loginMethod.all": "All Methods", + "loginMethod.normal": "Username/Password Only", + "loginMethod.oauth2": "OAuth2 Only", + "loginMethod.saml2": "SAML2 Only", + "loginAttemptCount": "Login Attempt Limit", + "loginAttemptCount.description": "Maximum number of failed login attempts before account lockout", + "loginResetTimeMinutes": "Login Reset Time (minutes)", + "loginResetTimeMinutes.description": "Time before failed login attempts are reset", + "csrfDisabled": "Disable CSRF Protection", + "csrfDisabled.description": "Disable Cross-Site Request Forgery protection (not recommended)", + "initialLogin": "Initial Login", + "initialLogin.username": "Initial Username", + "initialLogin.username.description": "The username for the initial admin account", + "initialLogin.password": "Initial Password", + "initialLogin.password.description": "The password for the initial admin account", + "jwt": "JWT Configuration", + "jwt.secureCookie": "Secure Cookie", + "jwt.secureCookie.description": "Require HTTPS for JWT cookies (recommended for production)", + "jwt.keyRetentionDays": "Key Retention Days", + "jwt.keyRetentionDays.description": "Number of days to retain old JWT keys for verification", + "jwt.persistence": "Enable Key Persistence", + "jwt.persistence.description": "Store JWT keys persistently to survive server restarts", + "jwt.enableKeyRotation": "Enable Key Rotation", + "jwt.enableKeyRotation.description": "Automatically rotate JWT signing keys periodically", + "jwt.enableKeyCleanup": "Enable Key Cleanup", + "jwt.enableKeyCleanup.description": "Automatically remove expired JWT keys", + "audit": "Audit Logging", + "audit.enabled": "Enable Audit Logging", + "audit.enabled.description": "Track user actions and system events for compliance and security monitoring", + "audit.level": "Audit Level", + "audit.level.description": "0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE", + "audit.retentionDays": "Audit Retention (days)", + "audit.retentionDays.description": "Number of days to retain audit logs", + "htmlUrlSecurity": "HTML URL Security", + "htmlUrlSecurity.description": "Configure URL access restrictions for HTML processing to prevent SSRF attacks", + "htmlUrlSecurity.enabled": "Enable URL Security", + "htmlUrlSecurity.enabled.description": "Enable URL security restrictions for HTML to PDF conversions", + "htmlUrlSecurity.level": "Security Level", + "htmlUrlSecurity.level.description": "MAX: whitelist only, MEDIUM: block internal networks, OFF: no restrictions", + "htmlUrlSecurity.level.max": "Maximum (Whitelist Only)", + "htmlUrlSecurity.level.medium": "Medium (Block Internal)", + "htmlUrlSecurity.level.off": "Off (No Restrictions)", + "htmlUrlSecurity.advanced": "Advanced Settings", + "htmlUrlSecurity.allowedDomains": "Allowed Domains (Whitelist)", + "htmlUrlSecurity.allowedDomains.description": "One domain per line (e.g., cdn.example.com). Only these domains allowed when level is MAX", + "htmlUrlSecurity.blockedDomains": "Blocked Domains (Blacklist)", + "htmlUrlSecurity.blockedDomains.description": "One domain per line (e.g., malicious.com). Additional domains to block", + "htmlUrlSecurity.internalTlds": "Internal TLDs", + "htmlUrlSecurity.internalTlds.description": "One TLD per line (e.g., .local, .internal). Block domains with these TLD patterns", + "htmlUrlSecurity.networkBlocking": "Network Blocking", + "htmlUrlSecurity.blockPrivateNetworks": "Block Private Networks", + "htmlUrlSecurity.blockPrivateNetworks.description": "Block RFC 1918 private networks (10.x.x.x, 192.168.x.x, 172.16-31.x.x)", + "htmlUrlSecurity.blockLocalhost": "Block Localhost", + "htmlUrlSecurity.blockLocalhost.description": "Block localhost and loopback addresses (127.x.x.x, ::1)", + "htmlUrlSecurity.blockLinkLocal": "Block Link-Local Addresses", + "htmlUrlSecurity.blockLinkLocal.description": "Block link-local addresses (169.254.x.x, fe80::/10)", + "htmlUrlSecurity.blockCloudMetadata": "Block Cloud Metadata Endpoints", + "htmlUrlSecurity.blockCloudMetadata.description": "Block cloud provider metadata endpoints (169.254.169.254)" + }, + "connections": { + "title": "Connections", + "description": "Configure external authentication providers like OAuth2 and SAML.", + "linkedServices": "Linked Services", + "unlinkedServices": "Unlinked Services", + "connect": "Connect", + "disconnect": "Disconnect", + "disconnected": "Provider disconnected successfully", + "disconnectError": "Failed to disconnect provider", + "ssoAutoLogin": "SSO Auto Login", + "ssoAutoLogin.enable": "Enable SSO Auto Login", + "ssoAutoLogin.description": "Automatically redirect to SSO login when authentication is required", + "oauth2": "OAuth2", + "oauth2.enabled": "Enable OAuth2", + "oauth2.enabled.description": "Allow users to authenticate using OAuth2 providers", + "oauth2.provider": "Provider", + "oauth2.provider.description": "The OAuth2 provider to use for authentication", + "oauth2.issuer": "Issuer URL", + "oauth2.issuer.description": "The OAuth2 provider issuer URL", + "oauth2.clientId": "Client ID", + "oauth2.clientId.description": "The OAuth2 client ID from your provider", + "oauth2.clientSecret": "Client Secret", + "oauth2.clientSecret.description": "The OAuth2 client secret from your provider", + "oauth2.useAsUsername": "Use as Username", + "oauth2.useAsUsername.description": "The OAuth2 claim to use as the username (e.g., email, sub)", + "oauth2.autoCreateUser": "Auto Create Users", + "oauth2.autoCreateUser.description": "Automatically create user accounts on first OAuth2 login", + "oauth2.blockRegistration": "Block Registration", + "oauth2.blockRegistration.description": "Prevent new user registration via OAuth2", + "oauth2.scopes": "OAuth2 Scopes", + "oauth2.scopes.description": "Comma-separated list of OAuth2 scopes to request (e.g., openid, profile, email)", + "saml2": "SAML2", + "saml2.enabled": "Enable SAML2", + "saml2.enabled.description": "Allow users to authenticate using SAML2 providers", + "saml2.provider": "Provider", + "saml2.provider.description": "The SAML2 provider name", + "saml2.registrationId": "Registration ID", + "saml2.registrationId.description": "The SAML2 registration identifier", + "saml2.autoCreateUser": "Auto Create Users", + "saml2.autoCreateUser.description": "Automatically create user accounts on first SAML2 login", + "saml2.blockRegistration": "Block Registration", + "saml2.blockRegistration.description": "Prevent new user registration via SAML2" + }, + "database": { + "title": "Database", + "description": "Configure custom database connection settings for enterprise deployments.", + "configuration": "Database Configuration", + "enableCustom": "Enable Custom Database", + "enableCustom.description": "Use your own custom database configuration instead of the default embedded database", + "customUrl": "Custom Database URL", + "customUrl.description": "Full JDBC connection string (e.g., jdbc:postgresql://localhost:5432/postgres). If provided, individual connection settings below are not used.", + "type": "Database Type", + "type.description": "Type of database (not used if custom URL is provided)", + "hostName": "Host Name", + "hostName.description": "Database server hostname (not used if custom URL is provided)", + "port": "Port", + "port.description": "Database server port (not used if custom URL is provided)", + "name": "Database Name", + "name.description": "Name of the database (not used if custom URL is provided)", + "username": "Username", + "username.description": "Database authentication username", + "password": "Password", + "password.description": "Database authentication password" + }, + "privacy": { + "title": "Privacy", + "description": "Configure privacy and data collection settings.", + "analytics": "Analytics & Tracking", + "enableAnalytics": "Enable Analytics", + "enableAnalytics.description": "Collect anonymous usage analytics to help improve the application", + "metricsEnabled": "Enable Metrics", + "metricsEnabled.description": "Enable collection of performance and usage metrics", + "searchEngine": "Search Engine Visibility", + "googleVisibility": "Google Visibility", + "googleVisibility.description": "Allow search engines to index this application" + }, + "advanced": { + "title": "Advanced", + "description": "Configure advanced features and experimental functionality.", + "features": "Feature Flags", + "processing": "Processing", + "endpoints": "Endpoints", + "endpoints.manage": "Manage API Endpoints", + "endpoints.description": "Endpoint management is configured via YAML. See documentation for details on enabling/disabling specific endpoints.", + "enableAlphaFunctionality": "Enable Alpha Features", + "enableAlphaFunctionality.description": "Enable experimental and alpha-stage features (may be unstable)", + "enableUrlToPDF": "Enable URL to PDF", + "enableUrlToPDF.description": "Allow conversion of web pages to PDF documents", + "maxDPI": "Maximum DPI", + "maxDPI.description": "Maximum DPI for image processing (0 = unlimited)", + "tessdataDir": "Tessdata Directory", + "tessdataDir.description": "Path to the tessdata directory for OCR language files", + "disableSanitize": "Disable HTML Sanitization", + "disableSanitize.description": "WARNING: Security risk - disabling HTML sanitization can lead to XSS vulnerabilities", + "tempFileManagement": "Temp File Management", + "tempFileManagement.description": "Configure temporary file storage and cleanup behavior", + "tempFileManagement.baseTmpDir": "Base Temp Directory", + "tempFileManagement.baseTmpDir.description": "Base directory for temporary files (leave empty for default: java.io.tmpdir/stirling-pdf)", + "tempFileManagement.libreofficeDir": "LibreOffice Temp Directory", + "tempFileManagement.libreofficeDir.description": "Directory for LibreOffice temp files (leave empty for default: baseTmpDir/libreoffice)", + "tempFileManagement.systemTempDir": "System Temp Directory", + "tempFileManagement.systemTempDir.description": "System temp directory to clean (only used if cleanupSystemTemp is enabled)", + "tempFileManagement.prefix": "Temp File Prefix", + "tempFileManagement.prefix.description": "Prefix for temp file names", + "tempFileManagement.maxAgeHours": "Max Age (hours)", + "tempFileManagement.maxAgeHours.description": "Maximum age in hours before temp files are cleaned up", + "tempFileManagement.cleanupIntervalMinutes": "Cleanup Interval (minutes)", + "tempFileManagement.cleanupIntervalMinutes.description": "How often to run cleanup (in minutes)", + "tempFileManagement.startupCleanup": "Startup Cleanup", + "tempFileManagement.startupCleanup.description": "Clean up old temp files on application startup", + "tempFileManagement.cleanupSystemTemp": "Cleanup System Temp", + "tempFileManagement.cleanupSystemTemp.description": "Whether to clean broader system temp directory (use with caution)", + "processExecutor": "Process Executor Limits", + "processExecutor.description": "Configure session limits and timeouts for each process executor", + "processExecutor.sessionLimit": "Session Limit", + "processExecutor.sessionLimit.description": "Maximum concurrent instances", + "processExecutor.timeout": "Timeout (minutes)", + "processExecutor.timeout.description": "Maximum execution time", + "processExecutor.libreOffice": "LibreOffice", + "processExecutor.pdfToHtml": "PDF to HTML", + "processExecutor.qpdf": "QPDF", + "processExecutor.tesseract": "Tesseract OCR", + "processExecutor.pythonOpenCv": "Python OpenCV", + "processExecutor.weasyPrint": "WeasyPrint", + "processExecutor.installApp": "Install App", + "processExecutor.calibre": "Calibre", + "processExecutor.ghostscript": "Ghostscript", + "processExecutor.ocrMyPdf": "OCRmyPDF" + }, + "mail": { + "title": "Mail Server", + "description": "Configure SMTP settings for sending email notifications.", + "smtp": "SMTP Configuration", + "enabled": "Enable Mail", + "enabled.description": "Enable email notifications and SMTP functionality", + "host": "SMTP Host", + "host.description": "The hostname or IP address of your SMTP server", + "port": "SMTP Port", + "port.description": "The port number for SMTP connection (typically 25, 465, or 587)", + "username": "SMTP Username", + "username.description": "Username for SMTP authentication", + "password": "SMTP Password", + "password.description": "Password for SMTP authentication", + "from": "From Address", + "from.description": "The email address to use as the sender", + "enableInvites": "Enable Email Invites", + "enableInvites.description": "Allow admins to invite users via email with auto-generated passwords" + }, + "legal": { + "title": "Legal Documents", + "description": "Configure links to legal documents and policies.", + "disclaimer": { + "title": "Legal Responsibility Warning", + "message": "By customizing these legal documents, you assume full responsibility for ensuring compliance with all applicable laws and regulations, including but not limited to GDPR and other EU data protection requirements. Only modify these settings if: (1) you are operating a personal/private instance, (2) you are outside EU jurisdiction and understand your local legal obligations, or (3) you have obtained proper legal counsel and accept sole responsibility for all user data and legal compliance. Stirling-PDF and its developers assume no liability for your legal obligations." + }, + "termsAndConditions": "Terms and Conditions", + "termsAndConditions.description": "URL or filename to terms and conditions", + "privacyPolicy": "Privacy Policy", + "privacyPolicy.description": "URL or filename to privacy policy", + "accessibilityStatement": "Accessibility Statement", + "accessibilityStatement.description": "URL or filename to accessibility statement", + "cookiePolicy": "Cookie Policy", + "cookiePolicy.description": "URL or filename to cookie policy", + "impressum": "Impressum", + "impressum.description": "URL or filename to impressum (required in some jurisdictions)" + }, + "premium": { + "title": "Premium & Enterprise", + "description": "Configure your premium or enterprise license key.", + "license": "License Configuration", + "key": "License Key", + "key.description": "Enter your premium or enterprise license key", + "enabled": "Enable Premium Features", + "enabled.description": "Enable license key checks for pro/enterprise features", + "movedFeatures": { + "title": "Premium Features Distributed", + "message": "Premium and Enterprise features are now organized in their respective sections:" + } + }, + "features": { + "title": "Features", + "description": "Configure optional features and functionality.", + "serverCertificate": "Server Certificate", + "serverCertificate.description": "Configure server-side certificate generation for \"Sign with Stirling-PDF\" functionality", + "serverCertificate.enabled": "Enable Server Certificate", + "serverCertificate.enabled.description": "Enable server-side certificate for \"Sign with Stirling-PDF\" option", + "serverCertificate.organizationName": "Organization Name", + "serverCertificate.organizationName.description": "Organization name for generated certificates", + "serverCertificate.validity": "Certificate Validity (days)", + "serverCertificate.validity.description": "Number of days the certificate will be valid", + "serverCertificate.regenerateOnStartup": "Regenerate on Startup", + "serverCertificate.regenerateOnStartup.description": "Generate new certificate on each application startup" + }, + "endpoints": { + "title": "API Endpoints", + "description": "Control which API endpoints and endpoint groups are available.", + "management": "Endpoint Management", + "toRemove": "Disabled Endpoints", + "toRemove.description": "Select individual endpoints to disable", + "groupsToRemove": "Disabled Endpoint Groups", + "groupsToRemove.description": "Select endpoint groups to disable", + "note": "Note: Disabling endpoints restricts API access but does not remove UI components. Restart required for changes to take effect." + } + } + }, "fileUpload": { "selectFile": "Select a file", "selectFiles": "Select files", @@ -3284,7 +3921,17 @@ "selectedCount": "{{count}} selected", "download": "Download", "delete": "Delete", - "unsupported": "Unsupported" + "unsupported": "Unsupported", + "addToUpload": "Add to Upload", + "deleteAll": "Delete All", + "loadingFiles": "Loading files...", + "noFiles": "No files available", + "noFilesFound": "No files found matching your search", + "openInPageEditor": "Open in Page Editor", + "showAll": "Show All", + "sortByDate": "Sort by Date", + "sortByName": "Sort by Name", + "sortBySize": "Sort by Size" }, "storage": { "temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically", @@ -3539,16 +4186,6 @@ "processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images." } }, - "viewer": { - "firstPage": "First Page", - "lastPage": "Last Page", - "previousPage": "Previous Page", - "nextPage": "Next Page", - "zoomIn": "Zoom In", - "zoomOut": "Zoom Out", - "singlePageView": "Single Page View", - "dualPageView": "Dual Page View" - }, "common": { "copy": "Copy", "copied": "Copied!", @@ -3557,9 +4194,14 @@ "remaining": "remaining", "used": "used", "available": "available", - "cancel": "Cancel" + "cancel": "Cancel", + "preview": "Preview" }, "config": { + "overview": { + "title": "Application Configuration", + "description": "Current application settings and configuration details." + }, "account": { "overview": { "title": "Account Settings", @@ -3615,8 +4257,340 @@ "submit": "Add Attachments", "results": { "title": "Attachment Results" + }, + "error": { + "failed": "Add attachments operation failed" } }, "termsAndConditions": "Terms & Conditions", - "logOut": "Log out" + "logOut": "Log out", + "addAttachments": { + "error": { + "failed": "An error occurred while adding attachments to the PDF." + } + }, + "autoRename": { + "description": "This tool will automatically rename PDF files based on their content. It analyzes the document to find the most suitable title from the text." + }, + "customPosition": "Custom Position", + "details": "Details", + "downloadUnavailable": "Download unavailable for this item", + "invalidUndoData": "Cannot undo: invalid operation data", + "margin": { + "large": "Large", + "medium": "Medium", + "small": "Small", + "xLarge": "Extra Large" + }, + "noFilesToUndo": "Cannot undo: no files were processed in the last operation", + "noOperationToUndo": "No operation to undo", + "noValidFiles": "No valid files to process", + "operationCancelled": "Operation cancelled", + "pageEdit": { + "deselectAll": "Select None", + "selectAll": "Select All" + }, + "quickPosition": "Quick Position", + "reorganizePages": { + "error": { + "failed": "Failed to reorganize pages" + }, + "results": { + "title": "Pages Reorganized" + }, + "settings": { + "title": "Settings" + }, + "submit": "Reorganize Pages" + }, + "replace-color": { + "options": { + "fill": "Fill colour", + "gradient": "Gradient" + }, + "previewOverlayOpacity": "Preview overlay opacity", + "previewOverlayTransparency": "Preview overlay transparency", + "previewOverlayVisibility": "Show preview overlay", + "selectText": { + "1": "Replace or invert colour options", + "2": "Default (preset high contrast colours)", + "3": "Custom (choose your own colours)", + "4": "Full invert (invert all colours)", + "5": "High contrast color options", + "6": "White text on black background", + "7": "Black text on white background", + "8": "Yellow text on black background", + "9": "Green text on black background", + "10": "Choose text Color", + "11": "Choose background Color", + "12": "Choose start colour", + "13": "Choose end colour" + }, + "submit": "Replace", + "title": "Replace-Invert-Color" + }, + "size": "Size", + "submit": "Submit", + "success": "Success", + "tools": { + "noSearchResults": "No tools found", + "noTools": "No tools available" + }, + "undoDataMismatch": "Cannot undo: operation data is corrupted", + "undoFailed": "Failed to undo operation", + "undoQuotaError": "Cannot undo: insufficient storage space", + "undoStorageError": "Undo completed but some files could not be saved to storage", + "undoSuccess": "Operation undone successfully", + "unsupported": "Unsupported", + "signup": { + "title": "Create an account", + "subtitle": "Join Stirling PDF to get started", + "name": "Name", + "email": "Email", + "password": "Password", + "confirmPassword": "Confirm password", + "enterName": "Enter your name", + "enterEmail": "Enter your email", + "enterPassword": "Enter your password", + "confirmPasswordPlaceholder": "Confirm password", + "or": "or", + "creatingAccount": "Creating Account...", + "signUp": "Sign Up", + "alreadyHaveAccount": "Already have an account? Sign in", + "pleaseFillAllFields": "Please fill in all fields", + "passwordsDoNotMatch": "Passwords do not match", + "passwordTooShort": "Password must be at least 6 characters long", + "invalidEmail": "Please enter a valid email address", + "checkEmailConfirmation": "Check your email for a confirmation link to complete your registration.", + "accountCreatedSuccessfully": "Account created successfully! You can now sign in.", + "unexpectedError": "Unexpected error: {{message}}", + "useEmailInstead": "Use Email Instead", + "nameRequired": "Name is required", + "emailRequired": "Email is required", + "passwordRequired": "Password is required", + "confirmPasswordRequired": "Please confirm your password" + }, + "onboarding": { + "welcomeModal": { + "title": "Welcome to Stirling PDF!", + "description": "Would you like to take a quick 1-minute tour to learn the key features and how to get started?", + "helpHint": "You can always access this tour later from the Help button in the bottom left.", + "startTour": "Start Tour", + "maybeLater": "Maybe Later", + "dontShowAgain": "Don't Show Again" + }, + "allTools": "This is the All Tools panel, where you can browse and select from all available PDF tools.", + "selectCropTool": "Let's select the Crop tool to demonstrate how to use one of the tools.", + "toolInterface": "This is the Crop tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet.", + "filesButton": "The Files button on the Quick Access bar allows you to upload PDFs to use the tools on.", + "fileSources": "You can upload new files or access recent files from here. For the tour, we'll just use a sample file.", + "workbench": "This is the Workbench - the main area where you view and edit your PDFs.", + "viewSwitcher": "Use these controls to select how you want to view your PDFs.", + "viewer": "The Viewer lets you read and annotate your PDFs.", + "pageEditor": "The Page Editor allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting.", + "activeFiles": "The Active Files view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process.", + "fileCheckbox": "Clicking one of the files selects it for processing. You can select multiple files for batch operations.", + "selectControls": "The Right Rail contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language.", + "cropSettings": "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to.", + "runButton": "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs.", + "results": "After the tool has finished running, the Review step will show a preview of the results in this panel, and allow you to undo the operation or download the file. ", + "fileReplacement": "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools.", + "pinButton": "You can use the Pin button if you'd rather your files stay active after running tools on them.", + "wrapUp": "You're all set! You've learnt about the main areas of the app and how to use them. Click the Help button whenever you like to see this tour again.", + "previous": "Previous", + "next": "Next", + "finish": "Finish", + "startTour": "Start Tour", + "startTourDescription": "Take a guided tour of Stirling PDF's key features" + }, + "workspace": { + "title": "Workspace", + "people": { + "title": "People", + "description": "Manage workspace members and their permissions", + "loading": "Loading people...", + "searchMembers": "Search members...", + "addMembers": "Add Members", + "inviteMembers": "Invite Members", + "inviteMembers.subtitle": "Type or paste in emails below, separated by commas. Your workspace will be billed by members.", + "user": "User", + "role": "Role", + "team": "Team", + "status": "Status", + "actions": "Actions", + "noMembersFound": "No members found", + "active": "Active", + "disabled": "Disabled", + "activeSession": "Active session", + "member": "Member", + "admin": "Admin", + "roleDescriptions": { + "admin": "Can manage settings and invite members, with full administrative access.", + "member": "Can view and edit shared files, but cannot manage workspace settings or members." + }, + "editRole": "Edit Role", + "enable": "Enable", + "disable": "Disable", + "deleteUser": "Delete User", + "deleteUserSuccess": "User deleted successfully", + "deleteUserError": "Failed to delete user", + "confirmDelete": "Are you sure you want to delete this user? This action cannot be undone.", + "addMember": { + "title": "Add Member", + "username": "Username (Email)", + "usernamePlaceholder": "user@example.com", + "password": "Password", + "passwordPlaceholder": "Enter password", + "role": "Role", + "team": "Team (Optional)", + "teamPlaceholder": "Select a team", + "forcePasswordChange": "Force password change on first login", + "cancel": "Cancel", + "submit": "Add Member", + "usernameRequired": "Username and password are required", + "passwordTooShort": "Password must be at least 6 characters", + "success": "User created successfully", + "error": "Failed to create user" + }, + "editMember": { + "title": "Edit Member", + "editing": "Editing:", + "role": "Role", + "team": "Team (Optional)", + "teamPlaceholder": "Select a team", + "cancel": "Cancel", + "submit": "Update Member", + "success": "User updated successfully", + "error": "Failed to update user" + }, + "toggleEnabled": { + "success": "User status updated successfully", + "error": "Failed to update user status" + }, + "delete": { + "success": "User deleted successfully", + "error": "Failed to delete user" + }, + "emailInvite": { + "tab": "Email Invite", + "description": "Type or paste in emails below, separated by commas. Users will receive login credentials via email.", + "emails": "Email Addresses", + "emailsPlaceholder": "user1@example.com, user2@example.com", + "emailsRequired": "At least one email address is required", + "submit": "Send Invites", + "success": "user(s) invited successfully", + "partialSuccess": "Some invites failed", + "allFailed": "Failed to invite users", + "error": "Failed to send invites" + }, + "directInvite": { + "tab": "Direct Create" + }, + "inviteMode": { + "username": "Username", + "email": "Email", + "emailDisabled": "Email invites require SMTP configuration and mail.enableInvites=true in settings" + } + }, + "teams": { + "title": "Teams", + "description": "Manage teams and organize workspace members", + "loading": "Loading teams...", + "loadingDetails": "Loading team details...", + "createNewTeam": "Create New Team", + "teamName": "Team Name", + "totalMembers": "Total Members", + "actions": "Actions", + "noTeamsFound": "No teams found", + "noMembers": "No members in this team", + "system": "System", + "addMember": "Add Member", + "viewTeam": "View Team", + "removeMember": "Remove from team", + "cannotRemoveFromSystemTeam": "Cannot remove from system team", + "renameTeamLabel": "Rename Team", + "deleteTeamLabel": "Delete Team", + "cannotDeleteInternal": "Cannot delete the Internal team", + "confirmDelete": "Are you sure you want to delete this team? This team must be empty to delete.", + "confirmRemove": "Remove user from this team?", + "cannotRenameInternal": "Cannot rename the Internal team", + "cannotAddToInternal": "Cannot add members to the Internal team", + "teamNotFound": "Team not found", + "backToTeams": "Back to Teams", + "memberCount": "{{count}} members", + "removeMemberSuccess": "User removed from team", + "removeMemberError": "Failed to remove user from team", + "createTeam": { + "title": "Create New Team", + "teamName": "Team Name", + "teamNamePlaceholder": "Enter team name", + "cancel": "Cancel", + "submit": "Create Team", + "nameRequired": "Team name is required", + "success": "Team created successfully", + "error": "Failed to create team" + }, + "renameTeam": { + "title": "Rename Team", + "renaming": "Renaming:", + "newTeamName": "New Team Name", + "newTeamNamePlaceholder": "Enter new team name", + "cancel": "Cancel", + "submit": "Rename Team", + "nameRequired": "Team name is required", + "success": "Team renamed successfully", + "error": "Failed to rename team" + }, + "deleteTeam": { + "success": "Team deleted successfully", + "error": "Failed to delete team. Make sure the team is empty.", + "teamMustBeEmpty": "Team must be empty before deletion" + }, + "addMemberToTeam": { + "title": "Add Member to Team", + "addingTo": "Adding to", + "selectUser": "Select User", + "selectUserPlaceholder": "Choose a user", + "selectUserRequired": "Please select a user", + "currentlyIn": "currently in", + "willBeMoved": "Note: This user will be moved from their current team to this team.", + "cancel": "Cancel", + "submit": "Add Member", + "userRequired": "Please select a user", + "success": "Member added to team successfully", + "error": "Failed to add member to team" + }, + "changeTeam": { + "label": "Change Team", + "title": "Change Team", + "changing": "Moving", + "selectTeam": "Select Team", + "selectTeamPlaceholder": "Choose a team", + "selectTeamRequired": "Please select a team", + "success": "Team changed successfully", + "error": "Failed to change team", + "submit": "Change Team" + } + } + }, + "firstLogin": { + "title": "First Time Login", + "welcomeTitle": "Welcome!", + "welcomeMessage": "For security reasons, you must change your password on your first login.", + "loggedInAs": "Logged in as", + "error": "Error", + "currentPassword": "Current Password", + "enterCurrentPassword": "Enter your current password", + "newPassword": "New Password", + "enterNewPassword": "Enter new password (min 8 characters)", + "confirmPassword": "Confirm New Password", + "reEnterNewPassword": "Re-enter new password", + "changePassword": "Change Password", + "allFieldsRequired": "All fields are required", + "passwordsDoNotMatch": "New passwords do not match", + "passwordTooShort": "Password must be at least 8 characters", + "passwordMustBeDifferent": "New password must be different from current password", + "passwordChangedSuccess": "Password changed successfully! Please log in again.", + "passwordChangeFailed": "Failed to change password. Please check your current password." + } } diff --git a/frontend/public/samples/Sample.pdf b/frontend/public/samples/Sample.pdf new file mode 100644 index 000000000..d78d9e1ef Binary files /dev/null and b/frontend/public/samples/Sample.pdf differ diff --git a/frontend/scripts/sample-pdf/generate.mjs b/frontend/scripts/sample-pdf/generate.mjs new file mode 100755 index 000000000..93e5cf7ee --- /dev/null +++ b/frontend/scripts/sample-pdf/generate.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node + +/** + * Stirling PDF Sample Document Generator + * + * This script uses Puppeteer to generate a sample PDF from a HTML template. + * The output is used in the onboarding tour and as a demo document + * for users to experiment with Stirling PDF's features. + */ + +import puppeteer from 'puppeteer'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { existsSync, mkdirSync, statSync } from 'fs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const TEMPLATE_PATH = join(__dirname, 'template.html'); +const OUTPUT_DIR = join(__dirname, '../../public/samples'); +const OUTPUT_PATH = join(OUTPUT_DIR, 'Sample.pdf'); + +async function generatePDF() { + console.log('🚀 Starting Stirling PDF sample document generation...\n'); + + // Ensure output directory exists + if (!existsSync(OUTPUT_DIR)) { + mkdirSync(OUTPUT_DIR, { recursive: true }); + console.log(`✅ Created output directory: ${OUTPUT_DIR}`); + } + + // Check if template exists + if (!existsSync(TEMPLATE_PATH)) { + console.error(`❌ Template file not found: ${TEMPLATE_PATH}`); + process.exit(1); + } + + console.log(`📄 Reading template: ${TEMPLATE_PATH}`); + + let browser; + try { + // Launch Puppeteer + console.log('🌐 Launching browser...'); + browser = await puppeteer.launch({ + headless: 'new', + args: ['--no-sandbox', '--disable-setuid-sandbox'] + }); + + const page = await browser.newPage(); + + // Set viewport to match A4 proportions + await page.setViewport({ + width: 794, // A4 width in pixels at 96 DPI + height: 1123, // A4 height in pixels at 96 DPI + deviceScaleFactor: 2 // Higher quality rendering + }); + + // Navigate to the template file + const fileUrl = `file://${TEMPLATE_PATH}`; + console.log('📖 Loading HTML template...'); + await page.goto(fileUrl, { + waitUntil: 'networkidle0' // Wait for all resources to load + }); + + // Generate PDF with A4 dimensions + console.log('📝 Generating PDF...'); + await page.pdf({ + path: OUTPUT_PATH, + format: 'A4', + printBackground: true, + margin: { + top: 0, + right: 0, + bottom: 0, + left: 0 + }, + preferCSSPageSize: true + }); + + console.log('\n✅ PDF generated successfully!'); + console.log(`📦 Output: ${OUTPUT_PATH}`); + + // Get file size + const stats = statSync(OUTPUT_PATH); + const fileSizeInKB = (stats.size / 1024).toFixed(2); + console.log(`📊 File size: ${fileSizeInKB} KB`); + + } catch (error) { + console.error('\n❌ Error generating PDF:', error.message); + process.exit(1); + } finally { + if (browser) { + await browser.close(); + console.log('🔒 Browser closed.'); + } + } + + console.log('\n🎉 Done! Sample PDF is ready for use in Stirling PDF.\n'); +} + +// Run the generator +generatePDF().catch(error => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/frontend/scripts/sample-pdf/styles.css b/frontend/scripts/sample-pdf/styles.css new file mode 100644 index 000000000..067452833 --- /dev/null +++ b/frontend/scripts/sample-pdf/styles.css @@ -0,0 +1,432 @@ +/* Stirling PDF Sample Document Styles */ + +:root { + /* Brand Colors */ + --brand-red: #8e3231; + --brand-blue: #3b82f6; + + /* Category Colors */ + --color-general: #3b82f6; + --color-security: #f59e0b; + --color-formatting: #8b5cf6; + --color-automation: #ec4899; + + /* Neutral Colors */ + --color-black: #111827; + --color-gray-dark: #4b5563; + --color-gray-medium: #6b7280; + --color-gray-light: #e5e7eb; + --color-gray-lighter: #f3f4f6; + --color-white: #ffffff; + + /* Font Stack */ + --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font-family); + color: var(--color-black); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Page Structure - A4 Dimensions */ +.page { + width: 210mm; + height: 297mm; + background: white; + page-break-after: always; + position: relative; + overflow: hidden; +} + +.page:last-child { + page-break-after: auto; +} + +/* Page 1: Hero / Cover */ +.page-1 { + background: var(--brand-red); + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; +} + +/* Decorative shapes container */ +.decorative-shapes { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: hidden; + z-index: 0; +} + +.shape { + position: absolute; +} + +/* Logo SVG shape - top-right */ +.shape-1 { + top: -120px; + right: -100px; + width: 450px; + height: auto; + opacity: 0.12; +} + +/* Logo SVG shape - top-left */ +.shape-2 { + top: -80px; + left: -80px; + width: 350px; + height: auto; + opacity: 0.08; +} + +/* Logo SVG shape - bottom-left */ +.shape-3 { + bottom: -180px; + left: -150px; + width: 550px; + height: auto; + opacity: 0.15; +} + +/* Logo SVG shape - bottom-right */ +.shape-4 { + bottom: -100px; + right: -120px; + width: 400px; + height: auto; + opacity: 0.1; +} + +/* Small accent shape center-right */ +.shape-5 { + top: 50%; + right: -30px; + width: 200px; + height: auto; + opacity: 0.08; + transform: translateY(-50%); +} + +.hero-content { + text-align: center; + padding: 60px; + position: relative; + z-index: 1; +} + +.logo-container { + margin-bottom: 48px; + position: relative; +} + +.hero-logo { + width: 280px; + height: auto; +} + +.hero-tagline { + font-size: 32px; + font-weight: 600; + color: var(--color-white); + margin-bottom: 32px; + line-height: 1.3; +} + +.hero-stats { + margin-bottom: 40px; +} + +.stat-badge { + display: inline-flex; + flex-direction: column; + align-items: center; + padding: 24px 48px; + background: rgba(255, 255, 255, 0.95); + border-radius: 16px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); +} + +.stat-number { + font-size: 48px; + font-weight: 700; + color: var(--brand-red); + line-height: 1; +} + +.stat-label { + font-size: 18px; + color: var(--color-gray-dark); + margin-top: 8px; + font-weight: 500; +} + +.hero-features { + display: flex; + justify-content: center; + gap: 16px; + flex-wrap: wrap; +} + +.feature-pill { + padding: 12px 24px; + background: rgba(255, 255, 255, 0.2); + color: white; + border-radius: 24px; + font-size: 16px; + font-weight: 500; + border: 2px solid rgba(255, 255, 255, 0.3); + backdrop-filter: blur(10px); +} + +/* Page 2: What is Stirling PDF */ +.page-2 { + padding: 60px; +} + +.content-wrapper { + max-width: 700px; + margin: 0 auto; +} + +.page-title { + font-size: 36px; + font-weight: 700; + color: var(--brand-red); + margin-bottom: 24px; + border-bottom: 4px solid var(--brand-red); + padding-bottom: 16px; +} + +.intro-text { + font-size: 16px; + color: var(--color-gray-dark); + margin-bottom: 48px; + line-height: 1.8; +} + +.value-props { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; +} + +.value-prop { + display: flex; + flex-direction: column; + gap: 12px; +} + +.value-icon { + width: 48px; + height: 48px; + color: var(--brand-red); + margin-bottom: 8px; +} + +.value-icon svg { + width: 100%; + height: 100%; +} + +.value-prop h3 { + font-size: 20px; + font-weight: 600; + color: var(--color-black); +} + +.value-prop p { + font-size: 14px; + color: var(--color-gray-dark); + line-height: 1.6; +} + +/* Page 3: Key Features */ +.page-3 { + padding: 60px; +} + +.features-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-bottom: 32px; +} + +.feature-card { + background: white; + border: 2px solid var(--color-gray-light); + border-radius: 12px; + padding: 24px; + transition: all 0.2s ease; +} + +.feature-card[data-category="general"] { + border-color: var(--color-general); +} + +.feature-card[data-category="security"] { + border-color: var(--color-security); +} + +.feature-card[data-category="formatting"] { + border-color: var(--color-formatting); +} + +.feature-card[data-category="automation"] { + border-color: var(--color-automation); +} + +.feature-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 16px; +} + +.feature-icon-large { + width: 40px; + height: 40px; + flex-shrink: 0; +} + +.feature-card[data-category="general"] .feature-icon-large { + color: var(--color-general); +} + +.feature-card[data-category="security"] .feature-icon-large { + color: var(--color-security); +} + +.feature-card[data-category="formatting"] .feature-icon-large { + color: var(--color-formatting); +} + +.feature-card[data-category="automation"] .feature-icon-large { + color: var(--color-automation); +} + +.feature-icon-large svg { + width: 100%; + height: 100%; +} + +.feature-card h3 { + font-size: 18px; + font-weight: 600; + color: var(--color-black); +} + +.feature-list { + list-style: none; + padding: 0; +} + +.feature-list li { + font-size: 14px; + color: var(--color-gray-dark); + padding: 6px 0; + padding-left: 20px; + position: relative; +} + +.feature-list li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--brand-red); + font-weight: bold; +} + +.additional-features { + background: white; + border: 2px solid var(--brand-red); + padding: 24px; + border-radius: 12px; + margin-top: 24px; +} + +.additional-features-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 16px; +} + +.additional-features-icon { + width: 40px; + height: 40px; + color: var(--brand-red); + flex-shrink: 0; +} + +.additional-features-icon svg { + width: 100%; + height: 100%; +} + +.additional-features h3 { + font-size: 18px; + font-weight: 600; + color: var(--color-black); + margin: 0; +} + +.additional-features-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; +} + +.additional-features-grid ul { + list-style: none; + padding: 0; + margin: 0; +} + +.additional-features-grid li { + font-size: 15px; + color: var(--color-gray-dark); + padding: 4px 0; + padding-left: 24px; + position: relative; + line-height: 1.5; +} + +.additional-features-grid li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--brand-red); + font-weight: bold; + font-size: 18px; +} + +/* Print Styles */ +@media print { + body { + margin: 0; + padding: 0; + } + + .page { + margin: 0; + border: none; + box-shadow: none; + } +} diff --git a/frontend/scripts/sample-pdf/template.html b/frontend/scripts/sample-pdf/template.html new file mode 100644 index 000000000..e4ae57e50 --- /dev/null +++ b/frontend/scripts/sample-pdf/template.html @@ -0,0 +1,234 @@ + + + + + + Stirling PDF - Sample Document + + + + +
+
+ + + + + +
+
+
+ +
+

The Free Adobe Acrobat Alternative

+
+
+ 10M+ + Downloads +
+
+
+
Open Source
+
Privacy First
+
Self-Hosted
+
+
+
+ + +
+
+

What is Stirling PDF?

+

+ Stirling PDF is a robust, web-based PDF manipulation tool. + It enables you to carry out various operations on PDF files, including splitting, + merging, converting, rearranging, adding images, rotating, compressing, and more. +

+ +
+
+
+ + + +
+

50+ PDF Operations

+

Comprehensive toolkit covering all your PDF needs. From basic operations to advanced processing.

+
+ +
+
+ + + +
+

Workflow Automation

+

Chain multiple operations together and save them as reusable workflows. Perfect for recurring tasks.

+
+ +
+
+ + + + + +
+

Multi-Language Support

+

Available in over 30 languages with community-contributed translations. Accessible to users worldwide.

+
+ +
+
+ + + + + +
+

Privacy First

+

Self-hosted solution means your data stays on your infrastructure. You have full control over your documents.

+
+ +
+
+ + + + +
+

Open Source

+

Transparent, community-driven development. Inspect the code, contribute features, and adapt as needed.

+
+ +
+
+ + + + +
+

API Access

+

RESTful API for integration with external tools and scripts. Automate PDF operations programmatically.

+
+
+
+
+ + +
+
+

Key Features

+ +
+
+
+
+ + + + + + + +
+

Page Operations

+
+
    +
  • Merge & split PDFs
  • +
  • Rearrange pages
  • +
  • Rotate & crop
  • +
  • Extract pages
  • +
  • Multi-page layout
  • +
+
+ +
+
+
+ + + + +
+

Security & Signing

+
+
    +
  • Password protection
  • +
  • Digital signatures
  • +
  • Watermarks
  • +
  • Permission controls
  • +
  • Redaction tools
  • +
+
+ +
+
+
+ + + +
+

File Conversions

+
+
    +
  • PDF to/from images
  • +
  • Office documents
  • +
  • HTML to PDF
  • +
  • Markdown to PDF
  • +
  • PDF to Word/Excel
  • +
+
+ +
+
+
+ + + +
+

Automation

+
+
    +
  • Multi-step workflows
  • +
  • Chain PDF operations
  • +
  • Save recurring tasks
  • +
  • Batch file processing
  • +
  • API integration
  • +
+
+
+ +
+
+
+ + + +
+

Plus Many More

+
+
+
    +
  • OCR text recognition
  • +
  • Compress PDFs
  • +
  • Add images & stamps
  • +
  • Detect blank pages
  • +
  • Extract images
  • +
  • Edit metadata
  • +
+
    +
  • Flatten forms
  • +
  • PDF/A conversion
  • +
  • Add page numbers
  • +
  • Remove pages
  • +
  • Repair PDFs
  • +
  • And 40+ more tools
  • +
+
+
+
+
+ + + diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index 74b5e0534..000000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,38 +0,0 @@ -.App { - text-align: center; -} - -.App-logo { - height: 40vmin; - pointer-events: none; -} - -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } -} - -.App-header { - background-color: #282c34; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); - color: white; -} - -.App-link { - color: #61dafb; -} - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index 88c19649f..000000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { Suspense } from "react"; -import { RainbowThemeProvider } from "./components/shared/RainbowThemeProvider"; -import { FileContextProvider } from "./contexts/FileContext"; -import { NavigationProvider } from "./contexts/NavigationContext"; -import { FilesModalProvider } from "./contexts/FilesModalContext"; -import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext"; -import { HotkeyProvider } from "./contexts/HotkeyContext"; -import { SidebarProvider } from "./contexts/SidebarContext"; -import { PreferencesProvider } from "./contexts/PreferencesContext"; -import ErrorBoundary from "./components/shared/ErrorBoundary"; -import HomePage from "./pages/HomePage"; - -// Import global styles -import "./styles/tailwind.css"; -import "./styles/cookieconsent.css"; -import "./index.css"; -import { RightRailProvider } from "./contexts/RightRailContext"; -import { ViewerProvider } from "./contexts/ViewerContext"; -import { SignatureProvider } from "./contexts/SignatureContext"; - -// Import file ID debugging helpers (development only) -import "./utils/fileIdSafety"; - -// Loading component for i18next suspense -const LoadingFallback = () => ( -
- Loading... -
-); - -export default function App() { - return ( - }> - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/frontend/src/components/shared/RightRail.tsx b/frontend/src/components/shared/RightRail.tsx deleted file mode 100644 index 44af1e215..000000000 --- a/frontend/src/components/shared/RightRail.tsx +++ /dev/null @@ -1,497 +0,0 @@ -import React, { useCallback, useState, useEffect, useMemo } from 'react'; -import { ActionIcon, Divider, Popover } from '@mantine/core'; -import LocalIcon from './LocalIcon'; -import './rightRail/RightRail.css'; -import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; -import { useRightRail } from '../../contexts/RightRailContext'; -import { useFileState, useFileSelection, useFileManagement, useFileContext } from '../../contexts/FileContext'; -import { useNavigationState } from '../../contexts/NavigationContext'; -import { useTranslation } from 'react-i18next'; - -import LanguageSelector from '../shared/LanguageSelector'; -import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; -import { Tooltip } from '../shared/Tooltip'; -import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel'; -import { SearchInterface } from '../viewer/SearchInterface'; -import { ViewerContext } from '../../contexts/ViewerContext'; -import { useSignature } from '../../contexts/SignatureContext'; -import ViewerAnnotationControls from './rightRail/ViewerAnnotationControls'; - -import { parseSelection } from '../../utils/bulkselection/parseSelection'; - - -import { useSidebarContext } from '../../contexts/SidebarContext'; - -export default function RightRail() { - const { sidebarRefs } = useSidebarContext(); - const { t } = useTranslation(); - const [isPanning, setIsPanning] = useState(false); - - // Viewer context for PDF controls - safely handle when not available - const viewerContext = React.useContext(ViewerContext); - const { toggleTheme } = useRainbowThemeContext(); - const { buttons, actions, allButtonsDisabled } = useRightRail(); - - const topButtons = useMemo(() => buttons.filter(b => (b.section || 'top') === 'top' && (b.visible ?? true)), [buttons]); - - // Access PageEditor functions for page-editor-specific actions - const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow(); - const disableForFullscreen = toolPanelMode === 'fullscreen' && leftPanelView === 'toolPicker'; - - // CSV input state for page selection - const [csvInput, setCsvInput] = useState(""); - - // Navigation view - const { workbench: currentView } = useNavigationState(); - - // File state and selection - const { state, selectors } = useFileState(); - const { actions: fileActions } = useFileContext(); - const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection(); - const { removeFiles } = useFileManagement(); - - // Signature context for checking if signatures have been applied - const { signaturesApplied } = useSignature(); - - const activeFiles = selectors.getFiles(); - const filesSignature = selectors.getFilesSignature(); - - // Compute selection state and total items - const getSelectionState = useCallback(() => { - if (currentView === 'fileEditor' || currentView === 'viewer') { - const totalItems = activeFiles.length; - const selectedCount = selectedFileIds.length; - return { totalItems, selectedCount }; - } - - if (currentView === 'pageEditor') { - // Use PageEditor's own state - const totalItems = pageEditorFunctions?.totalPages || 0; - const selectedCount = pageEditorFunctions?.selectedPageIds?.length || 0; - return { totalItems, selectedCount }; - } - - return { totalItems: 0, selectedCount: 0 }; - }, [currentView, activeFiles, selectedFileIds, pageEditorFunctions]); - - const { totalItems, selectedCount } = getSelectionState(); - - // Get export state for viewer mode - const exportState = viewerContext?.getExportState?.(); - - const handleSelectAll = useCallback(() => { - if (currentView === 'fileEditor' || currentView === 'viewer') { - // Select all file IDs - const allIds = state.files.ids; - setSelectedFiles(allIds); - // Clear any previous error flags when selecting all - try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; } - return; - } - - if (currentView === 'pageEditor') { - // Use PageEditor's select all function - pageEditorFunctions?.handleSelectAll?.(); - } - }, [currentView, state.files.ids, setSelectedFiles, pageEditorFunctions]); - - const handleDeselectAll = useCallback(() => { - if (currentView === 'fileEditor' || currentView === 'viewer') { - setSelectedFiles([]); - // Clear any previous error flags when deselecting all - try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; } - return; - } - if (currentView === 'pageEditor') { - // Use PageEditor's deselect all function - pageEditorFunctions?.handleDeselectAll?.(); - } - }, [currentView, setSelectedFiles, pageEditorFunctions]); - - const handleExportAll = useCallback(async () => { - if (currentView === 'viewer') { - // Check if signatures have been applied - if (!signaturesApplied) { - alert('You have unapplied signatures. Please use "Apply Signatures" first before exporting.'); - return; - } - - // Use EmbedPDF export functionality for viewer mode - viewerContext?.exportActions?.download(); - } else if (currentView === 'fileEditor') { - // Download selected files (or all if none selected) - const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles; - - filesToDownload.forEach(file => { - const link = document.createElement('a'); - link.href = URL.createObjectURL(file); - link.download = file.name; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(link.href); - }); - } else if (currentView === 'pageEditor') { - // Export all pages (not just selected) - pageEditorFunctions?.onExportAll?.(); - } - }, [currentView, activeFiles, selectedFiles, pageEditorFunctions, viewerContext, signaturesApplied, selectors, fileActions]); - - const handleCloseSelected = useCallback(() => { - if (currentView !== 'fileEditor') return; - if (selectedFileIds.length === 0) return; - - // Close only selected files (do not delete from storage) - removeFiles(selectedFileIds, false); - - // Clear selection after closing - setSelectedFiles([]); - }, [currentView, selectedFileIds, removeFiles, setSelectedFiles]); - - const updatePagesFromCSV = useCallback((override?: string) => { - const maxPages = pageEditorFunctions?.totalPages || 0; - const normalized = parseSelection(override ?? csvInput, maxPages); - pageEditorFunctions?.handleSetSelectedPages?.(normalized); - }, [csvInput, pageEditorFunctions]); - - // Do not overwrite user's expression input when selection changes. - - // Clear CSV input when files change (use stable signature to avoid ref churn) - useEffect(() => { - setCsvInput(""); - }, [filesSignature]); - - // Mount/visibility for page-editor-only buttons to allow exit animation, then remove to avoid flex gap - const [pageControlsMounted, setPageControlsMounted] = useState(currentView === 'pageEditor'); - const [pageControlsVisible, setPageControlsVisible] = useState(currentView === 'pageEditor'); - - useEffect(() => { - if (currentView === 'pageEditor') { - // Mount and show - setPageControlsMounted(true); - // Next tick to ensure transition applies - requestAnimationFrame(() => setPageControlsVisible(true)); - } else { - // Start exit animation - setPageControlsVisible(false); - // After transition, unmount to remove flex gap - const timer = setTimeout(() => setPageControlsMounted(false), 240); - return () => clearTimeout(timer); - } - }, [currentView]); - - return ( -
-
- {topButtons.length > 0 && ( - <> -
- {topButtons.map(btn => ( - - actions[btn.id]?.()} - disabled={btn.disabled || allButtonsDisabled || disableForFullscreen} - > - {btn.icon} - - - ))} -
- - - )} - - {/* Group: PDF Viewer Controls - visible only in viewer mode */} -
-
- {/* Search */} - - - -
- - - -
-
- -
- {}} - /> -
-
-
-
- - - {/* Pan Mode */} - - { - viewerContext?.panActions.togglePan(); - setIsPanning(!isPanning); - }} - disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen} - > - - - - - {/* Rotate Left */} - - { - viewerContext?.rotationActions.rotateBackward(); - }} - disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen} - > - - - - - {/* Rotate Right */} - - { - viewerContext?.rotationActions.rotateForward(); - }} - disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen} - > - - - - - {/* Sidebar Toggle */} - - { - viewerContext?.toggleThumbnailSidebar(); - }} - disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen} - > - - - - - {/* Annotation Controls */} - -
- -
- - {/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */} -
-
- {/* Select All Button */} - -
- - - -
-
- - {/* Deselect All Button */} - -
- - - -
-
- - {/* Select by Numbers - page editor only, with animated presence */} - {pageControlsMounted && ( - - -
- - -
- - - -
-
- - -
- -
-
-
-
-
- - )} - - {/* Delete Selected Pages - page editor only, with animated presence */} - {pageControlsMounted && ( - - -
-
- { pageEditorFunctions?.handleDelete?.(); }} - disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || allButtonsDisabled || disableForFullscreen} - aria-label={typeof t === 'function' ? t('rightRail.deleteSelected', 'Delete Selected Pages') : 'Delete Selected Pages'} - > - - -
-
-
- - )} - - {/* Export Selected Pages - page editor only */} - {pageControlsMounted && ( - -
-
- { pageEditorFunctions?.onExportSelected?.(); }} - disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || pageEditorFunctions?.exportLoading || allButtonsDisabled || disableForFullscreen} - aria-label={typeof t === 'function' ? t('rightRail.exportSelected', 'Export Selected Pages') : 'Export Selected Pages'} - > - - -
-
-
- )} - - {/* Close (File Editor: Close Selected | Page Editor: Close PDF) */} - -
- pageEditorFunctions?.closePdf?.() : handleCloseSelected} - disabled={ - currentView === 'viewer' || - (currentView === 'fileEditor' && selectedCount === 0) || - (currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf)) || - allButtonsDisabled || disableForFullscreen - } - > - - -
-
-
- - -
- - {/* Theme toggle and Language dropdown */} -
- - - - - - - -
- -
-
- - 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All')) - } position="left" offset={12} arrow portalTarget={document.body}> -
- - - -
-
-
- -
-
-
- ); -} - diff --git a/frontend/src/components/shared/config/configNavSections.tsx b/frontend/src/components/shared/config/configNavSections.tsx deleted file mode 100644 index ac5b670ee..000000000 --- a/frontend/src/components/shared/config/configNavSections.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react'; -import { NavKey } from './types'; -import HotkeysSection from './configSections/HotkeysSection'; -import GeneralSection from './configSections/GeneralSection'; - -export interface ConfigNavItem { - key: NavKey; - label: string; - icon: string; - component: React.ReactNode; -} - -export interface ConfigNavSection { - title: string; - items: ConfigNavItem[]; -} - -export interface ConfigColors { - navBg: string; - sectionTitle: string; - navItem: string; - navItemActive: string; - navItemActiveBg: string; - contentBg: string; - headerBorder: string; -} - -export const createConfigNavSections = ( - Overview: React.ComponentType<{ onLogoutClick: () => void }>, - onLogoutClick: () => void -): ConfigNavSection[] => { - const sections: ConfigNavSection[] = [ - { - title: 'Account', - items: [ - { - key: 'overview', - label: 'Overview', - icon: 'person-rounded', - component: - }, - ], - }, - { - title: 'Preferences', - items: [ - { - key: 'general', - label: 'General', - icon: 'settings-rounded', - component: - }, - { - key: 'hotkeys', - label: 'Keyboard Shortcuts', - icon: 'keyboard-rounded', - component: - }, - ], - }, - ]; - - return sections; -}; \ No newline at end of file diff --git a/frontend/src/contexts/PreferencesContext.tsx b/frontend/src/contexts/PreferencesContext.tsx deleted file mode 100644 index b5e8068f4..000000000 --- a/frontend/src/contexts/PreferencesContext.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; -import { preferencesService, UserPreferences, DEFAULT_PREFERENCES } from '../services/preferencesService'; - -interface PreferencesContextValue { - preferences: UserPreferences; - updatePreference: ( - key: K, - value: UserPreferences[K] - ) => Promise; - resetPreferences: () => Promise; - isLoading: boolean; -} - -const PreferencesContext = createContext(undefined); - -export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - const loadPreferences = async () => { - try { - await preferencesService.initialize(); - const loadedPreferences = await preferencesService.getAllPreferences(); - setPreferences(loadedPreferences); - } catch (error) { - console.error('Failed to load preferences:', error); - // Keep default preferences on error - } finally { - setIsLoading(false); - } - }; - - loadPreferences(); - }, []); - - const updatePreference = useCallback( - async (key: K, value: UserPreferences[K]) => { - await preferencesService.setPreference(key, value); - setPreferences((prev) => ({ - ...prev, - [key]: value, - })); - }, - [] - ); - - const resetPreferences = useCallback(async () => { - await preferencesService.clearAllPreferences(); - setPreferences(DEFAULT_PREFERENCES); - }, []); - - return ( - - {children} - - ); -}; - -export const usePreferences = (): PreferencesContextValue => { - const context = useContext(PreferencesContext); - if (!context) { - throw new Error('usePreferences must be used within a PreferencesProvider'); - } - return context; -}; diff --git a/frontend/src/core/App.tsx b/frontend/src/core/App.tsx new file mode 100644 index 000000000..e87843325 --- /dev/null +++ b/frontend/src/core/App.tsx @@ -0,0 +1,24 @@ +import { Suspense } from "react"; +import { AppProviders } from "@app/components/AppProviders"; +import { LoadingFallback } from "@app/components/shared/LoadingFallback"; +import HomePage from "@app/pages/HomePage"; +import OnboardingTour from "@app/components/onboarding/OnboardingTour"; + +// Import global styles +import "@app/styles/tailwind.css"; +import "@app/styles/cookieconsent.css"; +import "@app/styles/index.css"; + +// Import file ID debugging helpers (development only) +import "@app/utils/fileIdSafety"; + +export default function App() { + return ( + }> + + + + + + ); +} diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx new file mode 100644 index 000000000..4dbd632b9 --- /dev/null +++ b/frontend/src/core/components/AppProviders.tsx @@ -0,0 +1,67 @@ +import { ReactNode } from "react"; +import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { FilesModalProvider } from "@app/contexts/FilesModalContext"; +import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; +import { HotkeyProvider } from "@app/contexts/HotkeyContext"; +import { SidebarProvider } from "@app/contexts/SidebarContext"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { RightRailProvider } from "@app/contexts/RightRailContext"; +import { ViewerProvider } from "@app/contexts/ViewerContext"; +import { SignatureProvider } from "@app/contexts/SignatureContext"; +import { OnboardingProvider } from "@app/contexts/OnboardingContext"; +import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext"; +import ErrorBoundary from "@app/components/shared/ErrorBoundary"; +import { useScarfTracking } from "@app/hooks/useScarfTracking"; + +// Component to initialize scarf tracking (must be inside AppConfigProvider) +function ScarfTrackingInitializer() { + useScarfTracking(); + return null; +} + +/** + * Core application providers + * Contains all providers needed for the core + */ +export function AppProviders({ children }: { children: ReactNode }) { + return ( + + + + + + + + + + + + + + + + + + {children} + + + + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/components/FileManager.tsx b/frontend/src/core/components/FileManager.tsx similarity index 88% rename from frontend/src/components/FileManager.tsx rename to frontend/src/core/components/FileManager.tsx index 4f8c203ca..440ab4acb 100644 --- a/frontend/src/components/FileManager.tsx +++ b/frontend/src/core/components/FileManager.tsx @@ -1,17 +1,17 @@ import React, { useState, useCallback, useEffect } from 'react'; import { Modal } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; -import { StirlingFileStub } from '../types/fileContext'; -import { useFileManager } from '../hooks/useFileManager'; -import { useFilesModalContext } from '../contexts/FilesModalContext'; -import { Tool } from '../types/tool'; -import MobileLayout from './fileManager/MobileLayout'; -import DesktopLayout from './fileManager/DesktopLayout'; -import DragOverlay from './fileManager/DragOverlay'; -import { FileManagerProvider } from '../contexts/FileManagerContext'; -import { Z_INDEX_FILE_MANAGER_MODAL } from '../styles/zIndex'; -import { isGoogleDriveConfigured } from '../services/googleDrivePickerService'; -import { loadScript } from '../utils/scriptLoader'; +import { StirlingFileStub } from '@app/types/fileContext'; +import { useFileManager } from '@app/hooks/useFileManager'; +import { useFilesModalContext } from '@app/contexts/FilesModalContext'; +import { Tool } from '@app/types/tool'; +import MobileLayout from '@app/components/fileManager/MobileLayout'; +import DesktopLayout from '@app/components/fileManager/DesktopLayout'; +import DragOverlay from '@app/components/fileManager/DragOverlay'; +import { FileManagerProvider } from '@app/contexts/FileManagerContext'; +import { Z_INDEX_FILE_MANAGER_MODAL } from '@app/styles/zIndex'; +import { isGoogleDriveConfigured } from '@app/services/googleDrivePickerService'; +import { loadScript } from '@app/utils/scriptLoader'; interface FileManagerProps { selectedTool?: Tool | null; diff --git a/frontend/src/components/StorageStatsCard.tsx b/frontend/src/core/components/StorageStatsCard.tsx similarity index 92% rename from frontend/src/components/StorageStatsCard.tsx rename to frontend/src/core/components/StorageStatsCard.tsx index 31c991208..d04fd11ac 100644 --- a/frontend/src/components/StorageStatsCard.tsx +++ b/frontend/src/core/components/StorageStatsCard.tsx @@ -3,9 +3,9 @@ import { Card, Group, Text, Button, Progress } from "@mantine/core"; import { useTranslation } from "react-i18next"; import StorageIcon from "@mui/icons-material/Storage"; import DeleteIcon from "@mui/icons-material/Delete"; -import { StorageStats } from "../services/fileStorage"; -import { formatFileSize } from "../utils/fileUtils"; -import { getStorageUsagePercent } from "../utils/storageUtils"; +import { StorageStats } from "@app/services/fileStorage"; +import { formatFileSize } from "@app/utils/fileUtils"; +import { getStorageUsagePercent } from "@app/utils/storageUtils"; interface StorageStatsCardProps { storageStats: StorageStats | null; diff --git a/frontend/src/components/annotation/providers/PDFAnnotationProvider.tsx b/frontend/src/core/components/annotation/providers/PDFAnnotationProvider.tsx similarity index 100% rename from frontend/src/components/annotation/providers/PDFAnnotationProvider.tsx rename to frontend/src/core/components/annotation/providers/PDFAnnotationProvider.tsx diff --git a/frontend/src/components/annotation/shared/BaseAnnotationTool.tsx b/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx similarity index 90% rename from frontend/src/components/annotation/shared/BaseAnnotationTool.tsx rename to frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx index cf30f2e7e..c61b61cfd 100644 --- a/frontend/src/components/annotation/shared/BaseAnnotationTool.tsx +++ b/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx @@ -1,9 +1,9 @@ import React, { useState } from 'react'; import { Stack, Alert, Text } from '@mantine/core'; import { useTranslation } from 'react-i18next'; -import { DrawingControls } from './DrawingControls'; -import { ColorPicker } from './ColorPicker'; -import { usePDFAnnotation } from '../providers/PDFAnnotationProvider'; +import { DrawingControls } from '@app/components/annotation/shared/DrawingControls'; +import { ColorPicker } from '@app/components/annotation/shared/ColorPicker'; +import { usePDFAnnotation } from '@app/components/annotation/providers/PDFAnnotationProvider'; export interface AnnotationToolConfig { enableDrawing?: boolean; diff --git a/frontend/src/components/annotation/shared/ColorPicker.tsx b/frontend/src/core/components/annotation/shared/ColorPicker.tsx similarity index 100% rename from frontend/src/components/annotation/shared/ColorPicker.tsx rename to frontend/src/core/components/annotation/shared/ColorPicker.tsx diff --git a/frontend/src/components/annotation/shared/DrawingCanvas.tsx b/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx similarity index 98% rename from frontend/src/components/annotation/shared/DrawingCanvas.tsx rename to frontend/src/core/components/annotation/shared/DrawingCanvas.tsx index 87362f74d..4b9de1cbb 100644 --- a/frontend/src/components/annotation/shared/DrawingCanvas.tsx +++ b/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx @@ -1,7 +1,7 @@ import React, { useRef, useState } from 'react'; import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColorPicker } from '@mantine/core'; -import { ColorSwatchButton } from './ColorPicker'; -import PenSizeSelector from '../../tools/sign/PenSizeSelector'; +import { ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker'; +import PenSizeSelector from '@app/components/tools/sign/PenSizeSelector'; import SignaturePad from 'signature_pad'; interface DrawingCanvasProps { diff --git a/frontend/src/components/annotation/shared/DrawingControls.tsx b/frontend/src/core/components/annotation/shared/DrawingControls.tsx similarity index 100% rename from frontend/src/components/annotation/shared/DrawingControls.tsx rename to frontend/src/core/components/annotation/shared/DrawingControls.tsx diff --git a/frontend/src/components/annotation/shared/ImageUploader.tsx b/frontend/src/core/components/annotation/shared/ImageUploader.tsx similarity index 100% rename from frontend/src/components/annotation/shared/ImageUploader.tsx rename to frontend/src/core/components/annotation/shared/ImageUploader.tsx diff --git a/frontend/src/components/annotation/shared/TextInputWithFont.tsx b/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx similarity index 98% rename from frontend/src/components/annotation/shared/TextInputWithFont.tsx rename to frontend/src/core/components/annotation/shared/TextInputWithFont.tsx index b7af60295..aca7430ce 100644 --- a/frontend/src/components/annotation/shared/TextInputWithFont.tsx +++ b/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core'; import { useTranslation } from 'react-i18next'; -import { ColorPicker } from './ColorPicker'; +import { ColorPicker } from '@app/components/annotation/shared/ColorPicker'; interface TextInputWithFontProps { text: string; diff --git a/frontend/src/components/annotation/tools/DrawingTool.tsx b/frontend/src/core/components/annotation/tools/DrawingTool.tsx similarity index 87% rename from frontend/src/components/annotation/tools/DrawingTool.tsx rename to frontend/src/core/components/annotation/tools/DrawingTool.tsx index 80b950a33..f3643de35 100644 --- a/frontend/src/components/annotation/tools/DrawingTool.tsx +++ b/frontend/src/core/components/annotation/tools/DrawingTool.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { Stack } from '@mantine/core'; -import { BaseAnnotationTool } from '../shared/BaseAnnotationTool'; -import { DrawingCanvas } from '../shared/DrawingCanvas'; +import { BaseAnnotationTool } from '@app/components/annotation/shared/BaseAnnotationTool'; +import { DrawingCanvas } from '@app/components/annotation/shared/DrawingCanvas'; interface DrawingToolProps { onDrawingChange?: (data: string | null) => void; diff --git a/frontend/src/components/annotation/tools/ImageTool.tsx b/frontend/src/core/components/annotation/tools/ImageTool.tsx similarity index 90% rename from frontend/src/components/annotation/tools/ImageTool.tsx rename to frontend/src/core/components/annotation/tools/ImageTool.tsx index 744882b43..d9b4defec 100644 --- a/frontend/src/components/annotation/tools/ImageTool.tsx +++ b/frontend/src/core/components/annotation/tools/ImageTool.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { Stack } from '@mantine/core'; -import { BaseAnnotationTool } from '../shared/BaseAnnotationTool'; -import { ImageUploader } from '../shared/ImageUploader'; +import { BaseAnnotationTool } from '@app/components/annotation/shared/BaseAnnotationTool'; +import { ImageUploader } from '@app/components/annotation/shared/ImageUploader'; interface ImageToolProps { onImageChange?: (data: string | null) => void; diff --git a/frontend/src/components/annotation/tools/TextTool.tsx b/frontend/src/core/components/annotation/tools/TextTool.tsx similarity index 88% rename from frontend/src/components/annotation/tools/TextTool.tsx rename to frontend/src/core/components/annotation/tools/TextTool.tsx index 0f9aa2883..471198d8e 100644 --- a/frontend/src/components/annotation/tools/TextTool.tsx +++ b/frontend/src/core/components/annotation/tools/TextTool.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { Stack } from '@mantine/core'; -import { BaseAnnotationTool } from '../shared/BaseAnnotationTool'; -import { TextInputWithFont } from '../shared/TextInputWithFont'; +import { BaseAnnotationTool } from '@app/components/annotation/shared/BaseAnnotationTool'; +import { TextInputWithFont } from '@app/components/annotation/shared/TextInputWithFont'; interface TextToolProps { onTextChange?: (text: string) => void; diff --git a/frontend/src/components/fileEditor/AddFileCard.tsx b/frontend/src/core/components/fileEditor/AddFileCard.tsx similarity index 96% rename from frontend/src/components/fileEditor/AddFileCard.tsx rename to frontend/src/core/components/fileEditor/AddFileCard.tsx index a873b7211..fe833c17f 100644 --- a/frontend/src/components/fileEditor/AddFileCard.tsx +++ b/frontend/src/core/components/fileEditor/AddFileCard.tsx @@ -2,10 +2,10 @@ import React, { useRef, useState } from 'react'; import { Button, Group, useMantineColorScheme } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import AddIcon from '@mui/icons-material/Add'; -import { useFilesModalContext } from '../../contexts/FilesModalContext'; -import LocalIcon from '../shared/LocalIcon'; -import { BASE_PATH } from '../../constants/app'; -import styles from './FileEditor.module.css'; +import { useFilesModalContext } from '@app/contexts/FilesModalContext'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import { BASE_PATH } from '@app/constants/app'; +import styles from '@app/components/fileEditor/FileEditor.module.css'; interface AddFileCardProps { onFileSelect: (files: File[]) => void; diff --git a/frontend/src/components/fileEditor/FileEditor.module.css b/frontend/src/core/components/fileEditor/FileEditor.module.css similarity index 99% rename from frontend/src/components/fileEditor/FileEditor.module.css rename to frontend/src/core/components/fileEditor/FileEditor.module.css index 17184bbf4..4f26c8bce 100644 --- a/frontend/src/components/fileEditor/FileEditor.module.css +++ b/frontend/src/core/components/fileEditor/FileEditor.module.css @@ -9,7 +9,7 @@ transition: box-shadow 0.18s ease, outline-color 0.18s ease, transform 0.18s ease; max-width: 100%; max-height: 100%; - overflow: hidden; + overflow: visible; margin-left: 0.5rem; margin-right: 0.5rem; } diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/core/components/fileEditor/FileEditor.tsx similarity index 65% rename from frontend/src/components/fileEditor/FileEditor.tsx rename to frontend/src/core/components/fileEditor/FileEditor.tsx index 5c76d2248..fbafa8b89 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/core/components/fileEditor/FileEditor.tsx @@ -1,19 +1,19 @@ -import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; +import { useState, useCallback, useRef, useMemo, useEffect } from 'react'; import { - Text, Center, Box, LoadingOverlay, Stack, Group + Text, Center, Box, LoadingOverlay, Stack } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; -import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext'; -import { useNavigationActions } from '../../contexts/NavigationContext'; -import { zipFileService } from '../../services/zipFileService'; -import { detectFileExtension } from '../../utils/fileUtils'; -import FileEditorThumbnail from './FileEditorThumbnail'; -import AddFileCard from './AddFileCard'; -import FilePickerModal from '../shared/FilePickerModal'; -import SkeletonLoader from '../shared/SkeletonLoader'; -import { FileId, StirlingFile } from '../../types/fileContext'; -import { alert } from '../toast'; -import { downloadBlob } from '../../utils/downloadUtils'; +import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from '@app/contexts/FileContext'; +import { useNavigationActions } from '@app/contexts/NavigationContext'; +import { zipFileService } from '@app/services/zipFileService'; +import { detectFileExtension } from '@app/utils/fileUtils'; +import FileEditorThumbnail from '@app/components/fileEditor/FileEditorThumbnail'; +import AddFileCard from '@app/components/fileEditor/AddFileCard'; +import FilePickerModal from '@app/components/shared/FilePickerModal'; +import { FileId, StirlingFile } from '@app/types/fileContext'; +import { alert } from '@app/components/toast'; +import { downloadBlob } from '@app/utils/downloadUtils'; +import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons'; interface FileEditorProps { @@ -37,11 +37,15 @@ const FileEditor = ({ // Use optimized FileContext hooks const { state, selectors } = useFileState(); const { addFiles, removeFiles, reorderFiles } = useFileManagement(); - const { actions } = useFileActions(); + const { actions: fileActions } = useFileActions(); + const { actions: fileContextActions } = useFileContext(); + const { clearAllFileErrors } = fileContextActions; // Extract needed values from state (memoized to prevent infinite loops) const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]); const selectedFileIds = state.ui.selectedFileIds; + const totalItems = state.files.ids.length; + const selectedCount = selectedFileIds.length; // Get navigation actions const { actions: navActions } = useNavigationActions(); @@ -68,19 +72,6 @@ const FileEditor = ({ } }, [toolMode]); const [showFilePickerModal, setShowFilePickerModal] = useState(false); - const [zipExtractionProgress, setZipExtractionProgress] = useState<{ - isExtracting: boolean; - currentFile: string; - progress: number; - extractedCount: number; - totalFiles: number; - }>({ - isExtracting: false, - currentFile: '', - progress: 0, - extractedCount: 0, - totalFiles: 0 - }); // Get selected file IDs from context (defensive programming) const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : []; @@ -91,107 +82,63 @@ const FileEditor = ({ // Use activeStirlingFileStubs directly - no conversion needed const localSelectedIds = contextSelectedIds; + const handleSelectAllFiles = useCallback(() => { + setSelectedFiles(state.files.ids); + try { + clearAllFileErrors(); + } catch (error) { + if (process.env.NODE_ENV === 'development') { + console.warn('Failed to clear file errors on select all:', error); + } + } + }, [state.files.ids, setSelectedFiles, clearAllFileErrors]); + + const handleDeselectAllFiles = useCallback(() => { + setSelectedFiles([]); + try { + clearAllFileErrors(); + } catch (error) { + if (process.env.NODE_ENV === 'development') { + console.warn('Failed to clear file errors on deselect:', error); + } + } + }, [setSelectedFiles, clearAllFileErrors]); + + const handleCloseSelectedFiles = useCallback(() => { + if (selectedFileIds.length === 0) return; + void removeFiles(selectedFileIds, false); + setSelectedFiles([]); + }, [selectedFileIds, removeFiles, setSelectedFiles]); + + useFileEditorRightRailButtons({ + totalItems, + selectedCount, + onSelectAll: handleSelectAllFiles, + onDeselectAll: handleDeselectAllFiles, + onCloseSelected: handleCloseSelectedFiles, + }); + // Process uploaded files using context + // ZIP extraction is now handled automatically in FileContext based on user preferences const handleFileUpload = useCallback(async (uploadedFiles: File[]) => { _setError(null); try { - const allExtractedFiles: File[] = []; - const errors: string[] = []; - - for (const file of uploadedFiles) { - if (file.type === 'application/pdf') { - // Handle PDF files normally - allExtractedFiles.push(file); - } else if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || file.name.toLowerCase().endsWith('.zip')) { - // Handle ZIP files - only expand if they contain PDFs - try { - // Validate ZIP file first - const validation = await zipFileService.validateZipFile(file); - - if (validation.isValid && validation.containsPDFs) { - // ZIP contains PDFs - extract them - setZipExtractionProgress({ - isExtracting: true, - currentFile: file.name, - progress: 0, - extractedCount: 0, - totalFiles: validation.fileCount - }); - - const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => { - setZipExtractionProgress({ - isExtracting: true, - currentFile: progress.currentFile, - progress: progress.progress, - extractedCount: progress.extractedCount, - totalFiles: progress.totalFiles - }); - }); - - // Reset extraction progress - setZipExtractionProgress({ - isExtracting: false, - currentFile: '', - progress: 0, - extractedCount: 0, - totalFiles: 0 - }); - - if (extractionResult.success) { - allExtractedFiles.push(...extractionResult.extractedFiles); - - if (extractionResult.errors.length > 0) { - errors.push(...extractionResult.errors); - } - } else { - errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`); - } - } else { - // ZIP doesn't contain PDFs or is invalid - treat as regular file - allExtractedFiles.push(file); - } - } catch (zipError) { - errors.push(`Failed to process ZIP file "${file.name}": ${zipError instanceof Error ? zipError.message : 'Unknown error'}`); - setZipExtractionProgress({ - isExtracting: false, - currentFile: '', - progress: 0, - extractedCount: 0, - totalFiles: 0 - }); - } - } else { - allExtractedFiles.push(file); - } - } - - // Show any errors - if (errors.length > 0) { - showError(errors.join('\n')); - } - - // Process all extracted files - if (allExtractedFiles.length > 0) { - // Add files to context and select them automatically - await addFiles(allExtractedFiles, { selectFiles: true }); - showStatus(`Added ${allExtractedFiles.length} files`, 'success'); + if (uploadedFiles.length > 0) { + // FileContext will automatically handle ZIP extraction based on user preferences + // - Respects autoUnzip setting + // - Respects autoUnzipFileLimit + // - HTML ZIPs stay intact + // - Non-ZIP files pass through unchanged + await addFiles(uploadedFiles, { selectFiles: true }); + showStatus(`Added ${uploadedFiles.length} file(s)`, 'success'); } } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to process files'; showError(errorMessage); console.error('File processing error:', err); - - // Reset extraction progress on error - setZipExtractionProgress({ - isExtracting: false, - currentFile: '', - progress: 0, - extractedCount: 0, - totalFiles: 0 - }); } - }, [addFiles]); + }, [addFiles, showStatus, showError]); const toggleFile = useCallback((fileId: FileId) => { const currentSelectedIds = contextSelectedIdsRef.current; @@ -320,7 +267,7 @@ const FileEditor = ({ if (result.success && result.extractedStubs.length > 0) { // Add extracted file stubs to FileContext - await actions.addStirlingFileStubs(result.extractedStubs); + await fileActions.addStirlingFileStubs(result.extractedStubs); // Remove the original ZIP file removeFiles([fileId], false); @@ -350,7 +297,7 @@ const FileEditor = ({ }); } } - }, [activeStirlingFileStubs, selectors, actions, removeFiles]); + }, [activeStirlingFileStubs, selectors, fileActions, removeFiles]); const handleViewFile = useCallback((fileId: FileId) => { const record = activeStirlingFileStubs.find(r => r.id === fileId); @@ -394,7 +341,7 @@ const FileEditor = ({ - {activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? ( + {activeStirlingFileStubs.length === 0 ? (
📁 @@ -402,43 +349,6 @@ const FileEditor = ({ Upload PDF files, ZIP archives, or load from storage to get started
- ) : activeStirlingFileStubs.length === 0 && zipExtractionProgress.isExtracting ? ( - - - - {/* ZIP Extraction Progress */} - {zipExtractionProgress.isExtracting && ( - - - Extracting ZIP archive... - {Math.round(zipExtractionProgress.progress)}% - - - {zipExtractionProgress.currentFile || 'Processing files...'} - - - {zipExtractionProgress.extractedCount} of {zipExtractionProgress.totalFiles} files extracted - -
-
-
- - )} - - - - ) : (
(null); - const [actionsWidth, setActionsWidth] = useState(undefined); - const [showActions, setShowActions] = useState(false); + const [showHoverMenu, setShowHoverMenu] = useState(false); + const isMobile = useMediaQuery('(max-width: 1024px)'); + const [showCloseModal, setShowCloseModal] = useState(false); // Resolve the actual File object for pin/unpin operations const actualFile = useMemo(() => { @@ -154,46 +158,66 @@ const FileEditorThumbnail = ({ }; }, [file.id, file.name, selectedFiles, onReorderFiles]); - // Update dropdown width on resize - useEffect(() => { - const update = () => { - if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth); - }; - update(); - window.addEventListener('resize', update); - return () => window.removeEventListener('resize', update); + // Handle close with confirmation + const handleCloseWithConfirmation = useCallback(() => { + setShowCloseModal(true); }, []); - // Close the actions dropdown when hovering outside this file card (and its dropdown) - useEffect(() => { - if (!showActions) return; + const handleConfirmClose = useCallback(() => { + onCloseFile(file.id); + alert({ alertType: 'neutral', title: `Closed ${file.name}`, expandable: false, durationMs: 3500 }); + setShowCloseModal(false); + }, [file.id, file.name, onCloseFile]); - const isInsideCard = (target: EventTarget | null) => { - const container = dragElementRef.current; - if (!container) return false; - return target instanceof Node && container.contains(target); - }; + const handleCancelClose = useCallback(() => { + setShowCloseModal(false); + }, []); - const handleMouseMove = (e: MouseEvent) => { - if (!isInsideCard(e.target)) { - setShowActions(false); - } - }; - - const handleTouchStart = (e: TouchEvent) => { - // On touch devices, close if the touch target is outside the card - if (!isInsideCard(e.target)) { - setShowActions(false); - } - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('touchstart', handleTouchStart, { passive: true }); - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('touchstart', handleTouchStart); - }; - }, [showActions]); + // Build hover menu actions + const hoverActions = useMemo(() => [ + { + id: 'view', + icon: , + label: t('openInViewer', 'Open in Viewer'), + onClick: (e) => { + e.stopPropagation(); + onViewFile(file.id); + }, + }, + { + id: 'download', + icon: , + label: t('download', 'Download'), + onClick: (e) => { + e.stopPropagation(); + onDownloadFile(file.id); + alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 }); + }, + }, + { + id: 'unzip', + icon: , + label: t('fileManager.unzip', 'Unzip'), + onClick: (e) => { + e.stopPropagation(); + if (onUnzipFile) { + onUnzipFile(file.id); + alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 }); + } + }, + hidden: !isZipFile || !onUnzipFile, + }, + { + id: 'close', + icon: , + label: t('close', 'Close'), + onClick: (e) => { + e.stopPropagation(); + handleCloseWithConfirmation(); + }, + color: 'red', + } + ], [t, file.id, file.name, isZipFile, onViewFile, onDownloadFile, onUnzipFile, handleCloseWithConfirmation]); // ---- Card interactions ---- const handleCardClick = () => { @@ -205,6 +229,11 @@ const FileEditorThumbnail = ({ onToggleFile(file.id); }; + const handleCardDoubleClick = () => { + if (!isSupported) return; + onViewFile(file.id); + }; + // ---- Style helpers ---- const getHeaderClassName = () => { if (hasError) return styles.headerError; @@ -218,6 +247,7 @@ const FileEditorThumbnail = ({ ref={fileElementRef} data-file-id={file.id} data-testid="file-thumbnail" + data-tour="file-card-checkbox" data-selected={isSelected} data-supported={isSupported} className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`} @@ -226,6 +256,9 @@ const FileEditorThumbnail = ({ role="listitem" aria-selected={isSelected} onClick={handleCardClick} + onMouseEnter={() => setShowHoverMenu(true)} + onMouseLeave={() => setShowHoverMenu(false)} + onDoubleClick={handleCardDoubleClick} > {/* Header bar */}
{/* Pin/Unpin icon */} - + { e.stopPropagation(); if (actualFile) { @@ -282,98 +316,9 @@ const FileEditorThumbnail = ({ {isPinned ? : } - - {/* Download icon */} - - { - e.stopPropagation(); - onDownloadFile(file.id); - alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 }); - }} - > - - - - - {/* Kebab menu */} - { - e.stopPropagation(); - setShowActions((v) => !v); - }} - > - -
- {/* Actions overlay */} - {showActions && ( -
e.stopPropagation()} - > - - - - - {isZipFile && onUnzipFile && ( - - )} - -
- - -
- )} - {/* Title + meta line */}
)}
+ + {/* Hover Menu */} + + + {/* Close Confirmation Modal */} + + + {t('confirmCloseMessage', 'Are you sure you want to close this file?')} + + {file.name} + + + + + + +
); }; diff --git a/frontend/src/core/components/fileEditor/fileEditorRightRailButtons.tsx b/frontend/src/core/components/fileEditor/fileEditorRightRailButtons.tsx new file mode 100644 index 000000000..1895371da --- /dev/null +++ b/frontend/src/core/components/fileEditor/fileEditorRightRailButtons.tsx @@ -0,0 +1,60 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useRightRailButtons, RightRailButtonWithAction } from '@app/hooks/useRightRailButtons'; +import LocalIcon from '@app/components/shared/LocalIcon'; + +interface FileEditorRightRailButtonsParams { + totalItems: number; + selectedCount: number; + onSelectAll: () => void; + onDeselectAll: () => void; + onCloseSelected: () => void; +} + +export function useFileEditorRightRailButtons({ + totalItems, + selectedCount, + onSelectAll, + onDeselectAll, + onCloseSelected, +}: FileEditorRightRailButtonsParams) { + const { t } = useTranslation(); + + const buttons = useMemo(() => [ + { + id: 'file-select-all', + icon: , + tooltip: t('rightRail.selectAll', 'Select All'), + ariaLabel: typeof t === 'function' ? t('rightRail.selectAll', 'Select All') : 'Select All', + section: 'top' as const, + order: 10, + disabled: totalItems === 0 || selectedCount === totalItems, + visible: totalItems > 0, + onClick: onSelectAll, + }, + { + id: 'file-deselect-all', + icon: , + tooltip: t('rightRail.deselectAll', 'Deselect All'), + ariaLabel: typeof t === 'function' ? t('rightRail.deselectAll', 'Deselect All') : 'Deselect All', + section: 'top' as const, + order: 20, + disabled: selectedCount === 0, + visible: totalItems > 0, + onClick: onDeselectAll, + }, + { + id: 'file-close-selected', + icon: , + tooltip: t('rightRail.closeSelected', 'Close Selected Files'), + ariaLabel: typeof t === 'function' ? t('rightRail.closeSelected', 'Close Selected Files') : 'Close Selected Files', + section: 'top' as const, + order: 30, + disabled: selectedCount === 0, + visible: totalItems > 0, + onClick: onCloseSelected, + }, + ], [t, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected]); + + useRightRailButtons(buttons); +} diff --git a/frontend/src/components/fileManager/CompactFileDetails.tsx b/frontend/src/core/components/fileManager/CompactFileDetails.tsx similarity index 95% rename from frontend/src/components/fileManager/CompactFileDetails.tsx rename to frontend/src/core/components/fileManager/CompactFileDetails.tsx index 3656acee6..6da3b553e 100644 --- a/frontend/src/components/fileManager/CompactFileDetails.tsx +++ b/frontend/src/core/components/fileManager/CompactFileDetails.tsx @@ -4,8 +4,8 @@ import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import { useTranslation } from 'react-i18next'; -import { getFileSize } from '../../utils/fileUtils'; -import { StirlingFileStub } from '../../types/fileContext'; +import { getFileSize } from '@app/utils/fileUtils'; +import { StirlingFileStub } from '@app/types/fileContext'; interface CompactFileDetailsProps { currentFile: StirlingFileStub | null; @@ -42,6 +42,7 @@ const CompactFileDetails: React.FC = ({ {currentFile && thumbnail ? ( {currentFile.name} = ({ {/* File info */} - + {currentFile ? currentFile.name : 'No file selected'} diff --git a/frontend/src/components/fileManager/DesktopLayout.tsx b/frontend/src/core/components/fileManager/DesktopLayout.tsx similarity index 82% rename from frontend/src/components/fileManager/DesktopLayout.tsx rename to frontend/src/core/components/fileManager/DesktopLayout.tsx index 8d1e32ffc..c4bd72e16 100644 --- a/frontend/src/components/fileManager/DesktopLayout.tsx +++ b/frontend/src/core/components/fileManager/DesktopLayout.tsx @@ -1,12 +1,12 @@ import React from 'react'; import { Grid } from '@mantine/core'; -import FileSourceButtons from './FileSourceButtons'; -import FileDetails from './FileDetails'; -import SearchInput from './SearchInput'; -import FileListArea from './FileListArea'; -import FileActions from './FileActions'; -import HiddenFileInput from './HiddenFileInput'; -import { useFileManagerContext } from '../../contexts/FileManagerContext'; +import FileSourceButtons from '@app/components/fileManager/FileSourceButtons'; +import FileDetails from '@app/components/fileManager/FileDetails'; +import SearchInput from '@app/components/fileManager/SearchInput'; +import FileListArea from '@app/components/fileManager/FileListArea'; +import FileActions from '@app/components/fileManager/FileActions'; +import HiddenFileInput from '@app/components/fileManager/HiddenFileInput'; +import { useFileManagerContext } from '@app/contexts/FileManagerContext'; const DesktopLayout: React.FC = () => { const { @@ -23,7 +23,7 @@ const DesktopLayout: React.FC = () => { width: '13.625rem', flexShrink: 0, height: '100%', - }}> + }} data-tour="file-sources"> diff --git a/frontend/src/components/fileManager/DragOverlay.tsx b/frontend/src/core/components/fileManager/DragOverlay.tsx similarity index 100% rename from frontend/src/components/fileManager/DragOverlay.tsx rename to frontend/src/core/components/fileManager/DragOverlay.tsx diff --git a/frontend/src/components/fileManager/EmptyFilesState.tsx b/frontend/src/core/components/fileManager/EmptyFilesState.tsx similarity index 95% rename from frontend/src/components/fileManager/EmptyFilesState.tsx rename to frontend/src/core/components/fileManager/EmptyFilesState.tsx index 55a3766b9..c9f72a7ad 100644 --- a/frontend/src/components/fileManager/EmptyFilesState.tsx +++ b/frontend/src/core/components/fileManager/EmptyFilesState.tsx @@ -2,9 +2,9 @@ import React, { useState } from 'react'; import { Button, Group, Text, Stack, useMantineColorScheme } from '@mantine/core'; import HistoryIcon from '@mui/icons-material/History'; import { useTranslation } from 'react-i18next'; -import { useFileManagerContext } from '../../contexts/FileManagerContext'; -import LocalIcon from '../shared/LocalIcon'; -import { BASE_PATH } from '../../constants/app'; +import { useFileManagerContext } from '@app/contexts/FileManagerContext'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import { BASE_PATH } from '@app/constants/app'; const EmptyFilesState: React.FC = () => { const { t } = useTranslation(); diff --git a/frontend/src/components/fileManager/FileActions.tsx b/frontend/src/core/components/fileManager/FileActions.tsx similarity index 97% rename from frontend/src/components/fileManager/FileActions.tsx rename to frontend/src/core/components/fileManager/FileActions.tsx index 7bc8d27bc..d32ed0314 100644 --- a/frontend/src/components/fileManager/FileActions.tsx +++ b/frontend/src/core/components/fileManager/FileActions.tsx @@ -4,7 +4,7 @@ import SelectAllIcon from "@mui/icons-material/SelectAll"; import DeleteIcon from "@mui/icons-material/Delete"; import DownloadIcon from "@mui/icons-material/Download"; import { useTranslation } from "react-i18next"; -import { useFileManagerContext } from "../../contexts/FileManagerContext"; +import { useFileManagerContext } from "@app/contexts/FileManagerContext"; const FileActions: React.FC = () => { const { t } = useTranslation(); diff --git a/frontend/src/components/fileManager/FileDetails.tsx b/frontend/src/core/components/fileManager/FileDetails.tsx similarity index 90% rename from frontend/src/components/fileManager/FileDetails.tsx rename to frontend/src/core/components/fileManager/FileDetails.tsx index d4ef7bd07..428dbb53a 100644 --- a/frontend/src/components/fileManager/FileDetails.tsx +++ b/frontend/src/core/components/fileManager/FileDetails.tsx @@ -1,11 +1,11 @@ import React, { useEffect, useState } from 'react'; import { Stack, Button, Box } from '@mantine/core'; import { useTranslation } from 'react-i18next'; -import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail'; -import { useFileManagerContext } from '../../contexts/FileManagerContext'; -import FilePreview from '../shared/FilePreview'; -import FileInfoCard from './FileInfoCard'; -import CompactFileDetails from './CompactFileDetails'; +import { useIndexedDBThumbnail } from '@app/hooks/useIndexedDBThumbnail'; +import { useFileManagerContext } from '@app/contexts/FileManagerContext'; +import FilePreview from '@app/components/shared/FilePreview'; +import FileInfoCard from '@app/components/fileManager/FileInfoCard'; +import CompactFileDetails from '@app/components/fileManager/CompactFileDetails'; interface FileDetailsProps { compact?: boolean; diff --git a/frontend/src/components/fileManager/FileHistoryGroup.tsx b/frontend/src/core/components/fileManager/FileHistoryGroup.tsx similarity index 94% rename from frontend/src/components/fileManager/FileHistoryGroup.tsx rename to frontend/src/core/components/fileManager/FileHistoryGroup.tsx index 771499584..75d8f0e50 100644 --- a/frontend/src/components/fileManager/FileHistoryGroup.tsx +++ b/frontend/src/core/components/fileManager/FileHistoryGroup.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { Box, Text, Collapse, Group } from '@mantine/core'; import { useTranslation } from 'react-i18next'; -import { StirlingFileStub } from '../../types/fileContext'; -import FileListItem from './FileListItem'; +import { StirlingFileStub } from '@app/types/fileContext'; +import FileListItem from '@app/components/fileManager/FileListItem'; interface FileHistoryGroupProps { leafFile: StirlingFileStub; diff --git a/frontend/src/components/fileManager/FileInfoCard.tsx b/frontend/src/core/components/fileManager/FileInfoCard.tsx similarity index 94% rename from frontend/src/components/fileManager/FileInfoCard.tsx rename to frontend/src/core/components/fileManager/FileInfoCard.tsx index 160ddcc62..f898cbc67 100644 --- a/frontend/src/components/fileManager/FileInfoCard.tsx +++ b/frontend/src/core/components/fileManager/FileInfoCard.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core'; import { useTranslation } from 'react-i18next'; -import { detectFileExtension, getFileSize } from '../../utils/fileUtils'; -import { StirlingFileStub } from '../../types/fileContext'; -import ToolChain from '../shared/ToolChain'; +import { detectFileExtension, getFileSize } from '@app/utils/fileUtils'; +import { StirlingFileStub } from '@app/types/fileContext'; +import ToolChain from '@app/components/shared/ToolChain'; interface FileInfoCardProps { currentFile: StirlingFileStub | null; diff --git a/frontend/src/components/fileManager/FileListArea.tsx b/frontend/src/core/components/fileManager/FileListArea.tsx similarity index 91% rename from frontend/src/components/fileManager/FileListArea.tsx rename to frontend/src/core/components/fileManager/FileListArea.tsx index 556ecc4f1..439af6996 100644 --- a/frontend/src/components/fileManager/FileListArea.tsx +++ b/frontend/src/core/components/fileManager/FileListArea.tsx @@ -2,10 +2,10 @@ import React from 'react'; import { Center, ScrollArea, Text, Stack } from '@mantine/core'; import CloudIcon from '@mui/icons-material/Cloud'; import { useTranslation } from 'react-i18next'; -import FileListItem from './FileListItem'; -import FileHistoryGroup from './FileHistoryGroup'; -import EmptyFilesState from './EmptyFilesState'; -import { useFileManagerContext } from '../../contexts/FileManagerContext'; +import FileListItem from '@app/components/fileManager/FileListItem'; +import FileHistoryGroup from '@app/components/fileManager/FileHistoryGroup'; +import EmptyFilesState from '@app/components/fileManager/EmptyFilesState'; +import { useFileManagerContext } from '@app/contexts/FileManagerContext'; interface FileListAreaProps { scrollAreaHeight: string; diff --git a/frontend/src/components/fileManager/FileListItem.tsx b/frontend/src/core/components/fileManager/FileListItem.tsx similarity index 94% rename from frontend/src/components/fileManager/FileListItem.tsx rename to frontend/src/core/components/fileManager/FileListItem.tsx index 8436d2e29..d8c0aac70 100644 --- a/frontend/src/components/fileManager/FileListItem.tsx +++ b/frontend/src/core/components/fileManager/FileListItem.tsx @@ -7,11 +7,12 @@ import HistoryIcon from '@mui/icons-material/History'; import RestoreIcon from '@mui/icons-material/Restore'; import UnarchiveIcon from '@mui/icons-material/Unarchive'; import { useTranslation } from 'react-i18next'; -import { getFileSize, getFileDate } from '../../utils/fileUtils'; -import { FileId, StirlingFileStub } from '../../types/fileContext'; -import { useFileManagerContext } from '../../contexts/FileManagerContext'; -import { zipFileService } from '../../services/zipFileService'; -import ToolChain from '../shared/ToolChain'; +import { getFileSize, getFileDate } from '@app/utils/fileUtils'; +import { FileId, StirlingFileStub } from '@app/types/fileContext'; +import { useFileManagerContext } from '@app/contexts/FileManagerContext'; +import { zipFileService } from '@app/services/zipFileService'; +import ToolChain from '@app/components/shared/ToolChain'; +import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex'; interface FileListItemProps { file: StirlingFileStub; @@ -127,6 +128,7 @@ const FileListItem: React.FC = ({ withinPortal onOpen={() => setIsMenuOpen(true)} onClose={() => setIsMenuOpen(false)} + zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL} > { const { fileInputRef, onFileInputChange } = useFileManagerContext(); diff --git a/frontend/src/components/fileManager/MobileLayout.tsx b/frontend/src/core/components/fileManager/MobileLayout.tsx similarity index 85% rename from frontend/src/components/fileManager/MobileLayout.tsx rename to frontend/src/core/components/fileManager/MobileLayout.tsx index 3f101ab0b..070187485 100644 --- a/frontend/src/components/fileManager/MobileLayout.tsx +++ b/frontend/src/core/components/fileManager/MobileLayout.tsx @@ -1,12 +1,12 @@ import React from 'react'; import { Box } from '@mantine/core'; -import FileSourceButtons from './FileSourceButtons'; -import FileDetails from './FileDetails'; -import SearchInput from './SearchInput'; -import FileListArea from './FileListArea'; -import FileActions from './FileActions'; -import HiddenFileInput from './HiddenFileInput'; -import { useFileManagerContext } from '../../contexts/FileManagerContext'; +import FileSourceButtons from '@app/components/fileManager/FileSourceButtons'; +import FileDetails from '@app/components/fileManager/FileDetails'; +import SearchInput from '@app/components/fileManager/SearchInput'; +import FileListArea from '@app/components/fileManager/FileListArea'; +import FileActions from '@app/components/fileManager/FileActions'; +import HiddenFileInput from '@app/components/fileManager/HiddenFileInput'; +import { useFileManagerContext } from '@app/contexts/FileManagerContext'; const MobileLayout: React.FC = () => { const { diff --git a/frontend/src/components/fileManager/SearchInput.tsx b/frontend/src/core/components/fileManager/SearchInput.tsx similarity index 91% rename from frontend/src/components/fileManager/SearchInput.tsx rename to frontend/src/core/components/fileManager/SearchInput.tsx index f47da0dca..2b318604c 100644 --- a/frontend/src/components/fileManager/SearchInput.tsx +++ b/frontend/src/core/components/fileManager/SearchInput.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { TextInput } from '@mantine/core'; import SearchIcon from '@mui/icons-material/Search'; import { useTranslation } from 'react-i18next'; -import { useFileManagerContext } from '../../contexts/FileManagerContext'; +import { useFileManagerContext } from '@app/contexts/FileManagerContext'; interface SearchInputProps { style?: React.CSSProperties; diff --git a/frontend/src/components/hotkeys/HotkeyDisplay.tsx b/frontend/src/core/components/hotkeys/HotkeyDisplay.tsx similarity index 93% rename from frontend/src/components/hotkeys/HotkeyDisplay.tsx rename to frontend/src/core/components/hotkeys/HotkeyDisplay.tsx index 1b5953a63..7f6d9f26d 100644 --- a/frontend/src/components/hotkeys/HotkeyDisplay.tsx +++ b/frontend/src/core/components/hotkeys/HotkeyDisplay.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { HotkeyBinding } from '../../utils/hotkeys'; -import { useHotkeys } from '../../contexts/HotkeyContext'; +import { HotkeyBinding } from '@app/utils/hotkeys'; +import { useHotkeys } from '@app/contexts/HotkeyContext'; interface HotkeyDisplayProps { binding: HotkeyBinding | null | undefined; diff --git a/frontend/src/components/layout/Workbench.css b/frontend/src/core/components/layout/Workbench.css similarity index 100% rename from frontend/src/components/layout/Workbench.css rename to frontend/src/core/components/layout/Workbench.css diff --git a/frontend/src/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx similarity index 64% rename from frontend/src/components/layout/Workbench.tsx rename to frontend/src/core/components/layout/Workbench.tsx index f0f9a542e..861e6a365 100644 --- a/frontend/src/components/layout/Workbench.tsx +++ b/frontend/src/core/components/layout/Workbench.tsx @@ -1,37 +1,42 @@ import { Box } from '@mantine/core'; -import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; -import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; -import { useFileHandler } from '../../hooks/useFileHandler'; -import { useFileState } from '../../contexts/FileContext'; -import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext'; -import './Workbench.css'; +import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider'; +import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; +import { useFileHandler } from '@app/hooks/useFileHandler'; +import { useFileState } from '@app/contexts/FileContext'; +import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext'; +import { isBaseWorkbench } from '@app/types/workbench'; +import { useViewer } from '@app/contexts/ViewerContext'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; +import '@app/components/layout/Workbench.css'; -import TopControls from '../shared/TopControls'; -import FileEditor from '../fileEditor/FileEditor'; -import PageEditor from '../pageEditor/PageEditor'; -import PageEditorControls from '../pageEditor/PageEditorControls'; -import Viewer from '../viewer/Viewer'; -import LandingPage from '../shared/LandingPage'; -import Footer from '../shared/Footer'; -import DismissAllErrorsButton from '../shared/DismissAllErrorsButton'; +import TopControls from '@app/components/shared/TopControls'; +import FileEditor from '@app/components/fileEditor/FileEditor'; +import PageEditor from '@app/components/pageEditor/PageEditor'; +import PageEditorControls from '@app/components/pageEditor/PageEditorControls'; +import Viewer from '@app/components/viewer/Viewer'; +import LandingPage from '@app/components/shared/LandingPage'; +import Footer from '@app/components/shared/Footer'; +import DismissAllErrorsButton from '@app/components/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 { state } = useFileState(); + const { selectors } = useFileState(); const { workbench: currentView } = useNavigationState(); const { actions: navActions } = useNavigationActions(); const setCurrentView = navActions.setWorkbench; - const activeFiles = state.files.ids; + const activeFiles = selectors.getFiles(); const { previewFile, pageEditorFunctions, sidebarsVisible, setPreviewFile, setPageEditorFunctions, - setSidebarsVisible + setSidebarsVisible, + customWorkbenchViews, } = useToolWorkflow(); const { handleToolSelect } = useToolWorkflow(); @@ -44,6 +49,9 @@ export default function Workbench() { const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null; const { addFiles } = useFileHandler(); + // Get active file index from ViewerContext + const { activeFileIndex, setActiveFileIndex } = useViewer(); + const handlePreviewClose = () => { setPreviewFile(null); const previousMode = sessionStorage.getItem('previousMode'); @@ -95,6 +103,8 @@ export default function Workbench() { setSidebarsVisible={setSidebarsVisible} previewFile={previewFile} onClose={handlePreviewClose} + activeFileIndex={activeFileIndex} + setActiveFileIndex={setActiveFileIndex} /> ); @@ -130,15 +140,21 @@ export default function Workbench() { ); default: - return ( - - ); + if (!isBaseWorkbench(currentView)) { + const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null); + if (customView) { + const CustomComponent = customView.component; + return ; + } + } + return ; } }; return ( { + const stub = selectors.getStirlingFileStub(f.fileId); + return { fileId: f.fileId, name: f.name, versionNumber: stub?.versionNumber }; + })} + currentFileIndex={activeFileIndex} + onFileSelect={setActiveFileIndex} /> )} @@ -161,13 +184,20 @@ export default function Workbench() { className="flex-1 min-h-0 relative z-10 workbench-scrollable " style={{ transition: 'opacity 0.15s ease-in-out', - paddingTop: activeFiles.length > 0 ? '3.5rem' : '0', + paddingTop: currentView === 'viewer' ? '0' : (activeFiles.length > 0 ? '3.5rem' : '0'), }} > {renderMainContent()} -