From 74c92ef215b9af63e7c1a50c2699d23eaee67ee0 Mon Sep 17 00:00:00 2001 From: Ludy Date: Mon, 11 Aug 2025 11:26:57 +0200 Subject: [PATCH 01/41] chore(labeler): add new 'v2' label and expand matching rules (#4172) # Description of Changes - **Added** a new `v2` label with `base-branch` targeting `V2` - **Extended** the 'UI' label matching to include `frontend/**` files - **Extended** the 'Scripts' label matching to include `docker/**` files - **Removed** duplicate `devTools/.*` entry from 'Devtools' label configuration --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .github/labeler-config-srvaroa.yml | 6 +++++- .github/labels.yml | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/labeler-config-srvaroa.yml b/.github/labeler-config-srvaroa.yml index 3719c0ad8..6c9e029bd 100644 --- a/.github/labeler-config-srvaroa.yml +++ b/.github/labeler-config-srvaroa.yml @@ -46,6 +46,9 @@ labels: - label: 'API' title: '.*openapi.*|.*swagger.*|.*api.*' + - label: 'v2' + base-branch: 'V2' + - label: 'Translation' files: - 'app/core/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}.properties' @@ -62,6 +65,7 @@ labels: - 'app/core/src/main/java/stirling/software/SPDF/controller/web/.*' - 'app/core/src/main/java/stirling/software/SPDF/UI/.*' - 'app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/.*' + - 'frontend/**' - label: 'Java' files: @@ -120,6 +124,7 @@ labels: - 'scripts/installFonts.sh' - 'test.sh' - 'test2.sh' + - 'docker/**' - label: 'Devtools' files: @@ -131,7 +136,6 @@ labels: - '.github/workflows/pre_commit.yml' - 'devGuide/.*' - 'devTools/.*' - - 'devTools/.*' - label: 'Test' files: diff --git a/.github/labels.yml b/.github/labels.yml index a79fb8be5..842e3fb5c 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -83,6 +83,7 @@ color: "DEDEDE" - name: "v2" color: "FFFF00" + description: "Issues or pull requests related to the v2 branch" - name: "wontfix" description: "This will not be worked on" color: "FFFFFF" From 6699facc24a27db7e6b655835c13f195e59c64b0 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Mon, 11 Aug 2025 12:27:42 +0100 Subject: [PATCH 02/41] JWT Authentication (#3921) This PR introduces JWT (JSON Web Token) authentication for Stirling-PDF, allowing for stateless authentication capabilities alongside the existing session-based authentication system. ### Key Features & Changes JWT Authentication System - Core Service: JwtService.java - Token generation, validation, and cookie management - Authentication Filter: JwtAuthenticationFilter.java - Request interceptor for JWT validation - Key Management: KeyPersistenceService.java + KeyPairCleanupService.java - RSA key rotation and persistence - Frontend: jwt-init.js - Client-side JWT handling and URL cleanup Security Integration - SAML2: JwtSaml2AuthenticationRequestRepository.java - JWT-backed SAML request storage - OAuth2: Updated CustomAuthenticationSuccessHandler. java, CustomOAuth2AuthenticationSuccessHandler.java & CustomSaml2AuthenticationSuccessHandler.java for JWT integration - Configuration: Enhanced SecurityConfiguration.java with JWT filter chain Infrastructure - Caching: CacheConfig.java - Caffeine cache for JWT keys - Database: New JwtVerificationKey.java entity for key storage - Error Handling: JwtAuthenticationEntryPoint.java for unauthorized access ### Challenges Encountered - Configured SecurityConfiguration to use either `UsernamePasswordAuthenticationFilter` or `JWTAuthenticationFilter` based on whether JWTs are enabled to prevent the former intercepting requests while in stateless mode. - Removed the `.defaultSuccessUrl("/")` from login configuration as its inclusion was preventing overriding the use of the `CustomAuthenticationSuccessHandler` and preventing proper authentication flows. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [x] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [x] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [x] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [x] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) Screenshot 2025-07-10 at 13 35 56 Screenshot 2025-07-10 at 13 36 10 eb750e8c3954fc47b2dd2e6e76ddb7d5 Screenshot 2025-07-10 at 13 30 57 ### Testing (if applicable) - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Ludy Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Co-authored-by: Ethan Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .claude/settings.local.json | 8 +- .github/workflows/build.yml | 4 +- .../common/configuration/AppConfig.java | 15 +- .../configuration/InstallationPathConfig.java | 6 + .../common/model/ApplicationProperties.java | 10 + .../software/common/util/RequestUriUtils.java | 2 + .../src/main/resources/application.properties | 7 +- .../main/resources/messages_en_GB.properties | 3 +- .../src/main/resources/settings.yml.template | 7 +- .../main/resources/static/js/DecryptFiles.js | 3 +- .../main/resources/static/js/downloader.js | 2 +- .../main/resources/static/js/fetch-utils.js | 42 +- .../src/main/resources/static/js/jwt-init.js | 44 ++ .../src/main/resources/static/js/navbar.js | 14 + .../src/main/resources/static/js/usage.js | 2 +- .../src/main/resources/templates/account.html | 9 +- app/proprietary/build.gradle | 12 + .../CustomAuthenticationSuccessHandler.java | 51 ++- .../security/CustomLogoutSuccessHandler.java | 13 +- .../security/InitialSecuritySetup.java | 1 - .../security/JwtAuthenticationEntryPoint.java | 22 + .../security/config/AccountWebController.java | 9 +- .../security/configuration/CacheConfig.java | 31 ++ .../configuration/SecurityConfiguration.java | 101 +++-- .../filter/JwtAuthenticationFilter.java | 204 +++++++++ .../filter/UserAuthenticationFilter.java | 32 +- .../security/model/AuthenticationType.java | 5 +- .../proprietary/security/model/Authority.java | 4 +- .../security/model/JwtVerificationKey.java | 33 ++ .../proprietary/security/model/User.java | 4 +- .../AuthenticationFailureException.java | 13 + ...tomOAuth2AuthenticationSuccessHandler.java | 28 +- .../security/oauth2/OAuth2Configuration.java | 15 +- ...stomSaml2AuthenticationSuccessHandler.java | 44 +- ...tSaml2AuthenticationRequestRepository.java | 135 ++++++ ...iguration.java => Saml2Configuration.java} | 32 +- .../service/CustomOAuth2UserService.java | 8 +- .../service/CustomUserDetailsService.java | 27 +- .../security/service/JwtService.java | 330 +++++++++++++++ .../security/service/JwtServiceInterface.java | 90 ++++ .../service/KeyPairCleanupService.java | 88 ++++ .../service/KeyPersistenceService.java | 243 +++++++++++ .../KeyPersistenceServiceInterface.java | 29 ++ .../security/service/UserService.java | 22 +- .../resources/static/js/audit/dashboard.js | 6 +- .../CustomLogoutSuccessHandlerTest.java | 64 +-- .../JwtAuthenticationEntryPointTest.java | 38 ++ .../filter/JwtAuthenticationFilterTest.java | 242 +++++++++++ ...l2AuthenticationRequestRepositoryTest.java | 247 +++++++++++ .../security/service/JwtServiceTest.java | 389 ++++++++++++++++++ .../KeyPersistenceServiceInterfaceTest.java | 232 +++++++++++ exampleYmlFiles/test_cicd.yml | 1 + 52 files changed, 2827 insertions(+), 196 deletions(-) create mode 100644 app/core/src/main/resources/static/js/jwt-init.js create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/JwtAuthenticationEntryPoint.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/model/JwtVerificationKey.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/model/exception/AuthenticationFailureException.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepository.java rename app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/{SAML2Configuration.java => Saml2Configuration.java} (85%) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPairCleanupService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterface.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/JwtAuthenticationEntryPointTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilterTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterfaceTest.java diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6e006423a..bc5358b85 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,13 @@ "Bash(mkdir:*)", "Bash(./gradlew:*)", "Bash(grep:*)", - "Bash(cat:*)" + "Bash(cat:*)", + "Bash(find:*)", + "Bash(grep:*)", + "Bash(rg:*)", + "Bash(strings:*)", + "Bash(pkill:*)", + "Bash(true)" ], "deny": [] } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d87e478d3..c229ee40e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -147,7 +147,9 @@ jobs: - name: Generate OpenAPI documentation run: ./gradlew :stirling-pdf:generateOpenApiDocs - + env: + DISABLE_ADDITIONAL_FEATURES: true + - name: Upload OpenAPI Documentation uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: 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 f611f42ca..e24a92d6a 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 @@ -8,6 +8,7 @@ import java.util.List; import java.util.Locale; import java.util.Properties; import java.util.function.Predicate; +import java.util.stream.Stream; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -51,6 +52,14 @@ public class AppConfig { @Value("${server.port:8080}") private String serverPort; + @Value("${v2}") + public boolean v2Enabled; + + @Bean + public boolean v2Enabled() { + return v2Enabled; + } + @Bean @ConditionalOnProperty(name = "system.customHTMLFiles", havingValue = "true") public SpringTemplateEngine templateEngine(ResourceLoader resourceLoader) { @@ -120,7 +129,7 @@ public class AppConfig { public boolean rateLimit() { String rateLimit = System.getProperty("rateLimit"); if (rateLimit == null) rateLimit = System.getenv("rateLimit"); - return (rateLimit != null) ? Boolean.valueOf(rateLimit) : false; + return Boolean.parseBoolean(rateLimit); } @Bean(name = "RunningInDocker") @@ -140,8 +149,8 @@ public class AppConfig { if (!Files.exists(mountInfo)) { return true; } - try { - return Files.lines(mountInfo).anyMatch(line -> line.contains(" /configs ")); + try (Stream lines = Files.lines(mountInfo)) { + return lines.anyMatch(line -> line.contains(" /configs ")); } catch (IOException e) { return false; } diff --git a/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java b/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java index 247a012ad..64fbc41b7 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java @@ -25,6 +25,7 @@ public class InstallationPathConfig { private static final String STATIC_PATH; private static final String TEMPLATES_PATH; private static final String SIGNATURES_PATH; + private static final String PRIVATE_KEY_PATH; static { BASE_PATH = initializeBasePath(); @@ -45,6 +46,7 @@ public class InstallationPathConfig { STATIC_PATH = CUSTOM_FILES_PATH + "static" + File.separator; TEMPLATES_PATH = CUSTOM_FILES_PATH + "templates" + File.separator; SIGNATURES_PATH = CUSTOM_FILES_PATH + "signatures" + File.separator; + PRIVATE_KEY_PATH = CONFIG_PATH + "db" + File.separator + "keys" + File.separator; } private static String initializeBasePath() { @@ -120,4 +122,8 @@ public class InstallationPathConfig { public static String getSignaturesPath() { return SIGNATURES_PATH; } + + public static String getPrivateKeyPath() { + return PRIVATE_KEY_PATH; + } } 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 ee893c575..5845c6d16 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 @@ -119,6 +119,7 @@ public class ApplicationProperties { private long loginResetTimeMinutes; private String loginMethod = "all"; private String customGlobalAPIKey; + private Jwt jwt = new Jwt(); public Boolean isAltLogin() { return saml2.getEnabled() || oauth2.getEnabled(); @@ -298,6 +299,15 @@ public class ApplicationProperties { } } } + + @Data + public static class Jwt { + private boolean enableKeystore = true; + private boolean enableKeyRotation = false; + private boolean enableKeyCleanup = true; + private int keyRetentionDays = 7; + private boolean secureCookie; + } } @Data diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index 654c78fe9..239976b66 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -14,8 +14,10 @@ public class RequestUriUtils { || requestURI.startsWith(contextPath + "/images/") || requestURI.startsWith(contextPath + "/public/") || requestURI.startsWith(contextPath + "/pdfjs/") + || requestURI.startsWith(contextPath + "/pdfjs-legacy/") || requestURI.startsWith(contextPath + "/login") || requestURI.startsWith(contextPath + "/error") + || requestURI.startsWith(contextPath + "/favicon") || requestURI.endsWith(".svg") || requestURI.endsWith(".png") || requestURI.endsWith(".ico") diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index ea30bf78e..0ca864985 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -5,7 +5,7 @@ logging.level.org.eclipse.jetty=WARN #logging.level.org.springframework.security.saml2=TRACE #logging.level.org.springframework.security=DEBUG #logging.level.org.opensaml=DEBUG -#logging.level.stirling.software.SPDF.config.security: DEBUG +#logging.level.stirling.software.proprietary.security=DEBUG logging.level.com.zaxxer.hikari=WARN spring.jpa.open-in-view=false server.forward-headers-strategy=NATIVE @@ -47,4 +47,7 @@ posthog.host=https://eu.i.posthog.com spring.main.allow-bean-definition-overriding=true # Set up a consistent temporary directory location -java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf} \ No newline at end of file +java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf} + +# V2 features +v2=false diff --git a/app/core/src/main/resources/messages_en_GB.properties b/app/core/src/main/resources/messages_en_GB.properties index d6056e856..599dd0989 100644 --- a/app/core/src/main/resources/messages_en_GB.properties +++ b/app/core/src/main/resources/messages_en_GB.properties @@ -893,7 +893,7 @@ login.rememberme=Remember me login.invalid=Invalid username or password. login.locked=Your account has been locked. login.signinTitle=Please sign in -login.ssoSignIn=Login via Single Sign-on +login.ssoSignIn=Login via Single Sign-On login.oAuth2AutoCreateDisabled=OAUTH2 Auto-Create User Disabled login.oAuth2AdminBlockedUser=Registration or logging in of non-registered users is currently blocked. Please contact the administrator. login.oauth2RequestNotFound=Authorization request not found @@ -908,6 +908,7 @@ login.alreadyLoggedIn=You are already logged in to login.alreadyLoggedIn2=devices. Please log out of the devices and try again. login.toManySessions=You have too many active sessions login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Auto Redact diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 1af95f852..bbbac5fcd 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -59,12 +59,17 @@ security: idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair + jwt: # This feature is currently under development and not yet fully supported. Do not use in production. + persistence: true # Set to 'true' to enable JWT key store + 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 premium: key: 00000000-0000-0000-0000-000000000000 enabled: false # Enable license key checks for pro/enterprise features proFeatures: - database: true # Enable database features SSOAutoLogin: false CustomMetadata: autoUpdateMetadata: false diff --git a/app/core/src/main/resources/static/js/DecryptFiles.js b/app/core/src/main/resources/static/js/DecryptFiles.js index 67349a012..0e5b58a92 100644 --- a/app/core/src/main/resources/static/js/DecryptFiles.js +++ b/app/core/src/main/resources/static/js/DecryptFiles.js @@ -46,10 +46,9 @@ export class DecryptFile { formData.append('password', password); } // Send decryption request - const response = await fetch('/api/v1/security/remove-password', { + const response = await fetchWithCsrf('/api/v1/security/remove-password', { method: 'POST', body: formData, - headers: csrfToken ? {'X-XSRF-TOKEN': csrfToken} : undefined, }); if (response.ok) { diff --git a/app/core/src/main/resources/static/js/downloader.js b/app/core/src/main/resources/static/js/downloader.js index 42ba0c357..b5324dd82 100644 --- a/app/core/src/main/resources/static/js/downloader.js +++ b/app/core/src/main/resources/static/js/downloader.js @@ -218,7 +218,7 @@ formData.append('password', password); // Use handleSingleDownload to send the request - const decryptionResult = await fetch(removePasswordUrl, {method: 'POST', body: formData}); + const decryptionResult = await fetchWithCsrf(removePasswordUrl, {method: 'POST', body: formData}); if (decryptionResult && decryptionResult.blob) { const decryptedBlob = await decryptionResult.blob(); diff --git a/app/core/src/main/resources/static/js/fetch-utils.js b/app/core/src/main/resources/static/js/fetch-utils.js index dfe2604a8..2a2fe894c 100644 --- a/app/core/src/main/resources/static/js/fetch-utils.js +++ b/app/core/src/main/resources/static/js/fetch-utils.js @@ -1,3 +1,29 @@ +// Authentication utility for cookie-based JWT +window.JWTManager = { + + // Logout - clear cookies and redirect to login + logout: function() { + + // Clear JWT cookie manually (fallback) + document.cookie = 'stirling_jwt=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; SameSite=None; Secure'; + + // Perform logout request to clear server-side session + fetch('/logout', { + method: 'POST', + credentials: 'include' + }).then(response => { + if (response.redirected) { + window.location.href = response.url; + } else { + window.location.href = '/login?logout=true'; + } + }).catch(() => { + // If logout fails, let server handle it + window.location.href = '/logout'; + }); + } +}; + window.fetchWithCsrf = async function(url, options = {}) { function getCsrfToken() { const cookieValue = document.cookie @@ -24,5 +50,19 @@ window.fetchWithCsrf = async function(url, options = {}) { fetchOptions.headers['X-XSRF-TOKEN'] = csrfToken; } - return fetch(url, fetchOptions); + // Always include credentials to send JWT cookies + fetchOptions.credentials = 'include'; + + // Make the request + const response = await fetch(url, fetchOptions); + + // Handle 401 responses (unauthorized) + if (response.status === 401) { + console.warn('Authentication failed, redirecting to login'); + window.JWTManager.logout(); + return response; + } + + return response; } + diff --git a/app/core/src/main/resources/static/js/jwt-init.js b/app/core/src/main/resources/static/js/jwt-init.js new file mode 100644 index 000000000..8cd63e189 --- /dev/null +++ b/app/core/src/main/resources/static/js/jwt-init.js @@ -0,0 +1,44 @@ +// JWT Authentication Management Script +// This script handles cookie-based JWT authentication and page access control + +(function() { + // Clean up JWT token from URL parameters after OAuth/Login flows + function cleanupTokenFromUrl() { + const urlParams = new URLSearchParams(window.location.search); + const hasToken = urlParams.get('jwt') || urlParams.get('token'); + if (hasToken) { + // Clean up URL by removing token parameter + // Token should now be set as cookie by server + urlParams.delete('jwt'); + urlParams.delete('token'); + const newUrl = window.location.pathname + (urlParams.toString() ? '?' + urlParams.toString() : ''); + window.history.replaceState({}, '', newUrl); + } + } + + // Initialize JWT handling when page loads + function initializeJWT() { + // Clean up any JWT tokens from URL (OAuth flow) + cleanupTokenFromUrl(); + + // Authentication is handled server-side + // If user is not authenticated, server will redirect to login + console.log('JWT initialization complete - authentication handled server-side'); + } + + // No form enhancement needed for cookie-based JWT + // Cookies are automatically sent with form submissions + function enhanceFormSubmissions() { + // Cookie-based JWT is automatically included in form submissions + // No additional processing needed + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function() { + initializeJWT(); + }); + } else { + initializeJWT(); + } +})(); \ No newline at end of file diff --git a/app/core/src/main/resources/static/js/navbar.js b/app/core/src/main/resources/static/js/navbar.js index a95ff1639..1fd46ed70 100644 --- a/app/core/src/main/resources/static/js/navbar.js +++ b/app/core/src/main/resources/static/js/navbar.js @@ -138,5 +138,19 @@ document.addEventListener('DOMContentLoaded', () => { tooltipSetup(); setupDropdowns(); fixNavbarDropdownStyles(); + // Setup logout button functionality + const logoutButton = document.querySelector('a[href="/logout"]'); + if (logoutButton) { + logoutButton.addEventListener('click', function(event) { + event.preventDefault(); + if (window.JWTManager) { + window.JWTManager.logout(); + } else { + // Fallback if JWTManager is not available + window.location.href = '/logout'; + } + }); + } + }); window.addEventListener('resize', fixNavbarDropdownStyles); diff --git a/app/core/src/main/resources/static/js/usage.js b/app/core/src/main/resources/static/js/usage.js index 624e4ec78..443a27ce1 100644 --- a/app/core/src/main/resources/static/js/usage.js +++ b/app/core/src/main/resources/static/js/usage.js @@ -102,7 +102,7 @@ async function fetchEndpointData() { refreshBtn.classList.add('refreshing'); refreshBtn.disabled = true; - const response = await fetch('/api/v1/info/load/all'); + const response = await fetchWithCsrf('/api/v1/info/load/all'); if (!response.ok) { throw new Error('Network response was not ok'); } diff --git a/app/core/src/main/resources/templates/account.html b/app/core/src/main/resources/templates/account.html index 33a0d9f47..db48bb3a5 100644 --- a/app/core/src/main/resources/templates/account.html +++ b/app/core/src/main/resources/templates/account.html @@ -390,8 +390,13 @@ key.includes('clientSubmissionOrder') || key.includes('lastSubmitTime') || key.includes('lastClientId') || - - + key.includes('stirling_jwt') || + key.includes('JSESSIONID') || + key.includes('XSRF-TOKEN') || + key.includes('remember-me') || + key.includes('auth') || + key.includes('token') || + key.includes('session') || key.includes('posthog') || key.includes('ssoRedirectAttempts') || key.includes('lastRedirectAttempt') || key.includes('surveyVersion') || key.includes('pageViews'); } diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index 719f74127..b8862bdd8 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -1,9 +1,15 @@ repositories { maven { url = "https://build.shibboleth.net/maven/releases" } } + +ext { + jwtVersion = '0.12.6' +} + bootRun { enabled = false } + spotless { java { target 'src/**/java/**/*.java' @@ -41,6 +47,8 @@ dependencies { api 'org.springframework.boot:spring-boot-starter-data-jpa' api 'org.springframework.boot:spring-boot-starter-oauth2-client' api 'org.springframework.boot:spring-boot-starter-mail' + api 'org.springframework.boot:spring-boot-starter-cache' + api 'com.github.ben-manes.caffeine:caffeine' api 'io.swagger.core.v3:swagger-core-jakarta:2.2.35' implementation 'com.bucket4j:bucket4j_jdk17-core:8.14.0' @@ -50,6 +58,10 @@ dependencies { implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.3.RELEASE' api 'io.micrometer:micrometer-registry-prometheus' implementation 'com.unboundid.product.scim2:scim2-sdk-client:4.0.0' + + api "io.jsonwebtoken:jjwt-api:$jwtVersion" + runtimeOnly "io.jsonwebtoken:jjwt-impl:$jwtVersion" + runtimeOnly "io.jsonwebtoken:jjwt-jackson:$jwtVersion" runtimeOnly 'com.h2database:h2:2.3.232' // Don't upgrade h2database runtimeOnly 'org.postgresql:postgresql:42.7.7' constraints { 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 d5180c321..51908ef03 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 @@ -1,6 +1,7 @@ package stirling.software.proprietary.security; import java.io.IOException; +import java.util.Map; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; @@ -17,6 +18,8 @@ import stirling.software.common.util.RequestUriUtils; import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.audit.Audited; +import stirling.software.proprietary.security.model.AuthenticationType; +import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; @@ -24,13 +27,17 @@ import stirling.software.proprietary.security.service.UserService; public class CustomAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { - private LoginAttemptService loginAttemptService; - private UserService userService; + private final LoginAttemptService loginAttemptService; + private final UserService userService; + private final JwtServiceInterface jwtService; public CustomAuthenticationSuccessHandler( - LoginAttemptService loginAttemptService, UserService userService) { + LoginAttemptService loginAttemptService, + UserService userService, + JwtServiceInterface jwtService) { this.loginAttemptService = loginAttemptService; this.userService = userService; + this.jwtService = jwtService; } @Override @@ -46,23 +53,31 @@ public class CustomAuthenticationSuccessHandler } loginAttemptService.loginSucceeded(userName); - // Get the saved request - HttpSession session = request.getSession(false); - SavedRequest savedRequest = - (session != null) - ? (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST") - : null; + if (jwtService.isJwtEnabled()) { + String jwt = + jwtService.generateToken( + authentication, Map.of("authType", AuthenticationType.WEB)); + jwtService.addToken(response, jwt); + log.debug("JWT generated for user: {}", userName); - if (savedRequest != null - && !RequestUriUtils.isStaticResource( - request.getContextPath(), savedRequest.getRedirectUrl())) { - // Redirect to the original destination - super.onAuthenticationSuccess(request, response, authentication); - } else { - // Redirect to the root URL (considering context path) getRedirectStrategy().sendRedirect(request, response, "/"); - } + } else { + // Get the saved request + HttpSession session = request.getSession(false); + SavedRequest savedRequest = + (session != null) + ? (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST") + : null; - // super.onAuthenticationSuccess(request, response, authentication); + if (savedRequest != null + && !RequestUriUtils.isStaticResource( + request.getContextPath(), savedRequest.getRedirectUrl())) { + // Redirect to the original destination + super.onAuthenticationSuccess(request, response, authentication); + } else { + // No saved request or it's a static resource, redirect to home page + 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 033ea913c..136120528 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 @@ -33,6 +33,7 @@ import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.audit.Audited; import stirling.software.proprietary.security.saml2.CertificateUtils; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; +import stirling.software.proprietary.security.service.JwtServiceInterface; @Slf4j @RequiredArgsConstructor @@ -40,15 +41,18 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { public static final String LOGOUT_PATH = "/login?logout=true"; - private final ApplicationProperties applicationProperties; + private final ApplicationProperties.Security securityProperties; private final AppConfig appConfig; + private final JwtServiceInterface jwtService; + @Override @Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC) public void onLogoutSuccess( HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + if (!response.isCommitted()) { if (authentication != null) { if (authentication instanceof Saml2Authentication samlAuthentication) { @@ -67,6 +71,9 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { authentication.getClass().getSimpleName()); getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); } + } else if (!jwtService.extractToken(request).isBlank()) { + jwtService.clearToken(response); + getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); } else { // Redirect to login page after logout String path = checkForErrors(request); @@ -82,7 +89,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { Saml2Authentication samlAuthentication) throws IOException { - SAML2 samlConf = applicationProperties.getSecurity().getSaml2(); + SAML2 samlConf = securityProperties.getSaml2(); String registrationId = samlConf.getRegistrationId(); CustomSaml2AuthenticatedPrincipal principal = @@ -127,7 +134,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { OAuth2AuthenticationToken oAuthToken) throws IOException { String registrationId; - OAUTH2 oauth = applicationProperties.getSecurity().getOauth2(); + OAUTH2 oauth = securityProperties.getOauth2(); String path = checkForErrors(request); String redirectUrl = UrlUtils.getOrigin(request) + "/login?" + path; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java index 4b09fe0e9..e145e2754 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java @@ -43,7 +43,6 @@ public class InitialSecuritySetup { } } - userService.migrateOauth2ToSSO(); assignUsersToDefaultTeamIfMissing(); initializeInternalApiUser(); } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/JwtAuthenticationEntryPoint.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/JwtAuthenticationEntryPoint.java new file mode 100644 index 000000000..6805bcb54 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/JwtAuthenticationEntryPoint.java @@ -0,0 +1,22 @@ +package stirling.software.proprietary.security; + +import java.io.IOException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) + throws IOException { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java index 0d846fc3d..46d0e7d3d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java @@ -77,8 +77,11 @@ public class AccountWebController { @GetMapping("/login") public String login(HttpServletRequest request, Model model, Authentication authentication) { - // If the user is already authenticated, redirect them to the home page. - if (authentication != null && authentication.isAuthenticated()) { + // If the user is already authenticated and it's not a logout scenario, redirect them to the + // home page. + if (authentication != null + && authentication.isAuthenticated() + && request.getParameter("logout") == null) { return "redirect:/"; } @@ -184,7 +187,7 @@ public class AccountWebController { errorOAuth = "login.relyingPartyRegistrationNotFound"; // Valid InResponseTo was not available from the validation context, unable to // evaluate - case "invalid_in_response_to" -> errorOAuth = "login.invalid_in_response_to"; + case "invalid_in_response_to" -> errorOAuth = "login.invalidInResponseTo"; case "not_authentication_provider_found" -> errorOAuth = "login.not_authentication_provider_found"; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java new file mode 100644 index 000000000..ba074a5da --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java @@ -0,0 +1,31 @@ +package stirling.software.proprietary.security.configuration; + +import java.time.Duration; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.github.benmanes.caffeine.cache.Caffeine; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Value("${security.jwt.keyRetentionDays}") + private int keyRetentionDays; + + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager cacheManager = new CaffeineCacheManager(); + cacheManager.setCaffeine( + Caffeine.newBuilder() + .maximumSize(1000) // Make configurable? + .expireAfterWrite(Duration.ofDays(keyRetentionDays)) + .recordStats()); + return cacheManager; + } +} 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 ab809a037..aceb3b712 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 @@ -13,6 +13,7 @@ import org.springframework.security.authentication.dao.DaoAuthenticationProvider import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -35,10 +36,12 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.security.CustomAuthenticationFailureHandler; import stirling.software.proprietary.security.CustomAuthenticationSuccessHandler; 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; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationFailureHandler; @@ -48,6 +51,7 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationSuc import stirling.software.proprietary.security.saml2.CustomSaml2ResponseAuthenticationConverter; import stirling.software.proprietary.security.service.CustomOAuth2UserService; import stirling.software.proprietary.security.service.CustomUserDetailsService; +import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.session.SessionPersistentRegistry; @@ -64,9 +68,11 @@ public class SecurityConfiguration { private final boolean loginEnabledValue; private final boolean runningProOrHigher; - private final ApplicationProperties applicationProperties; + private final ApplicationProperties.Security securityProperties; private final AppConfig appConfig; private final UserAuthenticationFilter userAuthenticationFilter; + private final JwtServiceInterface jwtService; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final LoginAttemptService loginAttemptService; private final FirstLoginFilter firstLoginFilter; private final SessionPersistentRegistry sessionRegistry; @@ -82,8 +88,10 @@ public class SecurityConfiguration { @Qualifier("loginEnabled") boolean loginEnabledValue, @Qualifier("runningProOrHigher") boolean runningProOrHigher, AppConfig appConfig, - ApplicationProperties applicationProperties, + ApplicationProperties.Security securityProperties, UserAuthenticationFilter userAuthenticationFilter, + JwtServiceInterface jwtService, + JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, LoginAttemptService loginAttemptService, FirstLoginFilter firstLoginFilter, SessionPersistentRegistry sessionRegistry, @@ -97,8 +105,10 @@ public class SecurityConfiguration { this.loginEnabledValue = loginEnabledValue; this.runningProOrHigher = runningProOrHigher; this.appConfig = appConfig; - this.applicationProperties = applicationProperties; + this.securityProperties = securityProperties; this.userAuthenticationFilter = userAuthenticationFilter; + this.jwtService = jwtService; + this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint; this.loginAttemptService = loginAttemptService; this.firstLoginFilter = firstLoginFilter; this.sessionRegistry = sessionRegistry; @@ -115,14 +125,28 @@ public class SecurityConfiguration { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - if (applicationProperties.getSecurity().getCsrfDisabled() || !loginEnabledValue) { - http.csrf(csrf -> csrf.disable()); + if (securityProperties.getCsrfDisabled() || !loginEnabledValue) { + http.csrf(CsrfConfigurer::disable); } if (loginEnabledValue) { + boolean v2Enabled = appConfig.v2Enabled(); + + if (v2Enabled) { + http.addFilterBefore( + jwtAuthenticationFilter(), + UsernamePasswordAuthenticationFilter.class) + .exceptionHandling( + exceptionHandling -> + exceptionHandling.authenticationEntryPoint( + jwtAuthenticationEntryPoint)); + } http.addFilterBefore( - userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); - if (!applicationProperties.getSecurity().getCsrfDisabled()) { + userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterAfter(rateLimitingFilter(), UserAuthenticationFilter.class) + .addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class); + + if (!securityProperties.getCsrfDisabled()) { CookieCsrfTokenRepository cookieRepo = CookieCsrfTokenRepository.withHttpOnlyFalse(); CsrfTokenRequestAttributeHandler requestHandler = @@ -156,16 +180,21 @@ public class SecurityConfiguration { .csrfTokenRepository(cookieRepo) .csrfTokenRequestHandler(requestHandler)); } - http.addFilterBefore(rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class); - http.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class); + http.sessionManagement( - sessionManagement -> + sessionManagement -> { + if (v2Enabled) { + sessionManagement.sessionCreationPolicy( + SessionCreationPolicy.STATELESS); + } else { sessionManagement .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .maximumSessions(10) .maxSessionsPreventsLogin(false) .sessionRegistry(sessionRegistry) - .expiredUrl("/login?logout=true")); + .expiredUrl("/login?logout=true"); + } + }); http.authenticationProvider(daoAuthenticationProvider()); http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache())); http.logout( @@ -175,10 +204,10 @@ public class SecurityConfiguration { .matcher("/logout")) .logoutSuccessHandler( new CustomLogoutSuccessHandler( - applicationProperties, appConfig)) + securityProperties, appConfig, jwtService)) .clearAuthentication(true) .invalidateHttpSession(true) - .deleteCookies("JSESSIONID", "remember-me")); + .deleteCookies("JSESSIONID", "remember-me", "stirling_jwt")); http.rememberMe( rememberMeConfigurer -> // Use the configurator directly rememberMeConfigurer @@ -200,6 +229,7 @@ public class SecurityConfiguration { req -> { String uri = req.getRequestURI(); String contextPath = req.getContextPath(); + // Remove the context path from the URI String trimmedUri = uri.startsWith(contextPath) @@ -217,29 +247,35 @@ public class SecurityConfiguration { || trimmedUri.startsWith("/css/") || trimmedUri.startsWith("/fonts/") || trimmedUri.startsWith("/js/") + || trimmedUri.startsWith("/pdfjs/") + || trimmedUri.startsWith("/pdfjs-legacy/") + || trimmedUri.startsWith("/favicon") || trimmedUri.startsWith( - "/api/v1/info/status"); + "/api/v1/info/status") + || trimmedUri.startsWith("/v1/api-docs") + || uri.contains("/v1/api-docs"); }) .permitAll() .anyRequest() .authenticated()); // Handle User/Password Logins - if (applicationProperties.getSecurity().isUserPass()) { + if (securityProperties.isUserPass()) { http.formLogin( formLogin -> formLogin .loginPage("/login") .successHandler( new CustomAuthenticationSuccessHandler( - loginAttemptService, userService)) + loginAttemptService, + userService, + jwtService)) .failureHandler( new CustomAuthenticationFailureHandler( loginAttemptService, userService)) - .defaultSuccessUrl("/") .permitAll()); } // Handle OAUTH2 Logins - if (applicationProperties.getSecurity().isOauth2Active()) { + if (securityProperties.isOauth2Active()) { http.oauth2Login( oauth2 -> oauth2.loginPage("/oauth2") @@ -251,17 +287,18 @@ public class SecurityConfiguration { .successHandler( new CustomOAuth2AuthenticationSuccessHandler( loginAttemptService, - applicationProperties, - userService)) + securityProperties.getOauth2(), + userService, + jwtService)) .failureHandler( new CustomOAuth2AuthenticationFailureHandler()) - . // Add existing Authorities from the database - userInfoEndpoint( + // Add existing Authorities from the database + .userInfoEndpoint( userInfoEndpoint -> userInfoEndpoint .oidcUserService( new CustomOAuth2UserService( - applicationProperties, + securityProperties, userService, loginAttemptService)) .userAuthoritiesMapper( @@ -269,8 +306,7 @@ public class SecurityConfiguration { .permitAll()); } // Handle SAML - if (applicationProperties.getSecurity().isSaml2Active() && runningProOrHigher) { - // Configure the authentication provider + if (securityProperties.isSaml2Active() && runningProOrHigher) { OpenSaml4AuthenticationProvider authenticationProvider = new OpenSaml4AuthenticationProvider(); authenticationProvider.setResponseAuthenticationConverter( @@ -287,8 +323,9 @@ public class SecurityConfiguration { .successHandler( new CustomSaml2AuthenticationSuccessHandler( loginAttemptService, - applicationProperties, - userService)) + securityProperties.getSaml2(), + userService, + jwtService)) .failureHandler( new CustomSaml2AuthenticationFailureHandler()) .authenticationRequestResolver( @@ -323,4 +360,14 @@ public class SecurityConfiguration { public PersistentTokenRepository persistentTokenRepository() { return new JPATokenRepositoryImpl(persistentLoginRepository); } + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter() { + return new JwtAuthenticationFilter( + jwtService, + userService, + userDetailsService, + jwtAuthenticationEntryPoint, + securityProperties); + } } 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 new file mode 100644 index 000000000..faf50832f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,204 @@ +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.SAML2; + +import java.io.IOException; +import java.sql.SQLException; +import java.util.Map; +import java.util.Optional; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +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.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +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 lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.exception.UnsupportedProviderException; +import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; +import stirling.software.proprietary.security.model.AuthenticationType; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.model.exception.AuthenticationFailureException; +import stirling.software.proprietary.security.service.CustomUserDetailsService; +import stirling.software.proprietary.security.service.JwtServiceInterface; +import stirling.software.proprietary.security.service.UserService; + +@Slf4j +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtServiceInterface jwtService; + private final UserService userService; + private final CustomUserDetailsService userDetailsService; + private final AuthenticationEntryPoint authenticationEntryPoint; + private final ApplicationProperties.Security securityProperties; + + public JwtAuthenticationFilter( + JwtServiceInterface jwtService, + UserService userService, + CustomUserDetailsService userDetailsService, + AuthenticationEntryPoint authenticationEntryPoint, + ApplicationProperties.Security securityProperties) { + this.jwtService = jwtService; + this.userService = userService; + this.userDetailsService = userDetailsService; + this.authenticationEntryPoint = authenticationEntryPoint; + this.securityProperties = securityProperties; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + if (!jwtService.isJwtEnabled()) { + filterChain.doFilter(request, response); + return; + } + if (isStaticResource(request.getContextPath(), request.getRequestURI())) { + filterChain.doFilter(request, response); + return; + } + + if (!apiKeyExists(request, response)) { + String jwtToken = jwtService.extractToken(request); + + if (jwtToken == null) { + // Any unauthenticated requests should redirect to /login + String requestURI = request.getRequestURI(); + String contextPath = request.getContextPath(); + + if (!requestURI.startsWith(contextPath + "/login")) { + response.sendRedirect("/login"); + return; + } + } + + try { + jwtService.validateToken(jwtToken); + } catch (AuthenticationFailureException e) { + jwtService.clearToken(response); + handleAuthenticationFailure(request, response, e); + return; + } + + Map claims = jwtService.extractClaims(jwtToken); + String tokenUsername = claims.get("sub").toString(); + + try { + authenticate(request, claims); + } catch (SQLException | UnsupportedProviderException e) { + log.error("Error processing user authentication for user: {}", tokenUsername, e); + handleAuthenticationFailure( + request, + response, + new AuthenticationFailureException( + "Error processing user authentication", e)); + return; + } + } + + filterChain.doFilter(request, response); + } + + private boolean apiKeyExists(HttpServletRequest request, HttpServletResponse response) + throws IOException, ServletException { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated()) { + String apiKey = request.getHeader("X-API-KEY"); + + if (apiKey != null && !apiKey.isBlank()) { + try { + Optional user = userService.getUserByApiKey(apiKey); + + if (user.isEmpty()) { + handleAuthenticationFailure( + request, + response, + new AuthenticationFailureException("Invalid API Key")); + return false; + } + + authentication = + new ApiKeyAuthenticationToken( + user.get(), apiKey, user.get().getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + return true; + } catch (AuthenticationException e) { + handleAuthenticationFailure( + request, + response, + new AuthenticationFailureException("Invalid API Key", e)); + return false; + } + } + + return false; + } + + return true; + } + + private void authenticate(HttpServletRequest request, Map claims) + throws SQLException, UnsupportedProviderException { + String username = claims.get("sub").toString(); + + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + processUserAuthenticationType(claims, username); + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + if (userDetails != null) { + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } else { + throw new UsernameNotFoundException("User not found: " + username); + } + } + } + + private void processUserAuthenticationType(Map claims, String username) + throws SQLException, UnsupportedProviderException { + AuthenticationType authenticationType = + AuthenticationType.valueOf(claims.getOrDefault("authType", WEB).toString()); + log.debug("Processing {} login for {} user", authenticationType, username); + + switch (authenticationType) { + case OAUTH2 -> { + ApplicationProperties.Security.OAUTH2 oauth2Properties = + securityProperties.getOauth2(); + userService.processSSOPostLogin( + username, oauth2Properties.getAutoCreateUser(), OAUTH2); + } + case SAML2 -> { + ApplicationProperties.Security.SAML2 saml2Properties = + securityProperties.getSaml2(); + userService.processSSOPostLogin( + username, saml2Properties.getAutoCreateUser(), SAML2); + } + } + } + + private void handleAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) + throws IOException, ServletException { + authenticationEntryPoint.commence(request, response, authException); + } +} 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 e9addd239..f51a9d543 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 @@ -9,7 +9,6 @@ import org.springframework.context.annotation.Lazy; import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.userdetails.UserDetails; @@ -64,6 +63,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { return; } String requestURI = request.getRequestURI(); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); // Check for session expiration (unsure if needed) @@ -92,14 +92,9 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { response.getWriter().write("Invalid API Key."); return; } - List authorities = - user.get().getAuthorities().stream() - .map( - authority -> - new SimpleGrantedAuthority( - authority.getAuthority())) - .toList(); - authentication = new ApiKeyAuthenticationToken(user.get(), apiKey, authorities); + authentication = + new ApiKeyAuthenticationToken( + user.get(), apiKey, user.get().getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (AuthenticationException e) { // If API key authentication fails, deny the request @@ -115,20 +110,19 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { String method = request.getMethod(); String contextPath = request.getContextPath(); - if ("GET".equalsIgnoreCase(method) && !(contextPath + "/login").equals(requestURI)) { + if ("GET".equalsIgnoreCase(method) && !requestURI.startsWith(contextPath + "/login")) { response.sendRedirect(contextPath + "/login"); // redirect to the login page - return; } else { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter() .write( - "Authentication required. Please provide a X-API-KEY in request" - + " header.\n" - + "This is found in Settings -> Account Settings -> API Key\n" - + "Alternatively you can disable authentication if this is" - + " unexpected"); - return; + """ + Authentication required. Please provide a X-API-KEY in request header. + This is found in Settings -> Account Settings -> API Key + Alternatively you can disable authentication if this is unexpected. + """); } + return; } // Check if the authenticated user is disabled and invalidate their session if so @@ -226,11 +220,12 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { } @Override - protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + protected boolean shouldNotFilter(HttpServletRequest request) { String uri = request.getRequestURI(); String contextPath = request.getContextPath(); String[] permitAllPatterns = { contextPath + "/login", + contextPath + "/signup", contextPath + "/register", contextPath + "/error", contextPath + "/images/", @@ -247,6 +242,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { for (String pattern : permitAllPatterns) { if (uri.startsWith(pattern) || uri.endsWith(".svg") + || uri.endsWith(".mjs") || uri.endsWith(".png") || uri.endsWith(".ico")) { return true; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java index ca8140bca..c92c1655e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java @@ -2,5 +2,8 @@ package stirling.software.proprietary.security.model; public enum AuthenticationType { WEB, - SSO + @Deprecated(since = "1.0.2") + SSO, + OAUTH2, + SAML2 } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java index 382d3a71e..a32e7d7ca 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java @@ -2,6 +2,8 @@ package stirling.software.proprietary.security.model; import java.io.Serializable; +import org.springframework.security.core.GrantedAuthority; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -18,7 +20,7 @@ import lombok.Setter; @Table(name = "authorities") @Getter @Setter -public class Authority implements Serializable { +public class Authority implements GrantedAuthority, Serializable { private static final long serialVersionUID = 1L; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/JwtVerificationKey.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/JwtVerificationKey.java new file mode 100644 index 000000000..632c5f13a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/JwtVerificationKey.java @@ -0,0 +1,33 @@ +package stirling.software.proprietary.security.model; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@NoArgsConstructor +@ToString(onlyExplicitlyIncluded = true) +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class JwtVerificationKey implements Serializable { + + @Serial private static final long serialVersionUID = 1L; + + @ToString.Include private String keyId; + + private String verifyingKey; + + @ToString.Include private LocalDateTime createdAt; + + public JwtVerificationKey(String keyId, String verifyingKey) { + this.keyId = keyId; + this.verifyingKey = verifyingKey; + this.createdAt = LocalDateTime.now(); + } +} 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 d3e232f61..7d1b235cd 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 @@ -7,6 +7,8 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import org.springframework.security.core.userdetails.UserDetails; + import jakarta.persistence.*; import lombok.EqualsAndHashCode; @@ -25,7 +27,7 @@ import stirling.software.proprietary.model.Team; @Setter @EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString(onlyExplicitlyIncluded = true) -public class User implements Serializable { +public class User implements UserDetails, Serializable { private static final long serialVersionUID = 1L; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/exception/AuthenticationFailureException.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/exception/AuthenticationFailureException.java new file mode 100644 index 000000000..f2cd5e242 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/exception/AuthenticationFailureException.java @@ -0,0 +1,13 @@ +package stirling.software.proprietary.security.model.exception; + +import org.springframework.security.core.AuthenticationException; + +public class AuthenticationFailureException extends AuthenticationException { + public AuthenticationFailureException(String message) { + super(message); + } + + public AuthenticationFailureException(String message, Throwable cause) { + super(message, cause); + } +} 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 71bd42a85..4e7ed9d9e 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 @@ -1,7 +1,11 @@ package stirling.software.proprietary.security.oauth2; +import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2; +import static stirling.software.proprietary.security.model.AuthenticationType.SSO; + import java.io.IOException; import java.sql.SQLException; +import java.util.Map; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.Authentication; @@ -18,10 +22,10 @@ import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; import stirling.software.common.model.ApplicationProperties; -import stirling.software.common.model.ApplicationProperties.Security.OAUTH2; import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.common.util.RequestUriUtils; import stirling.software.proprietary.security.model.AuthenticationType; +import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; @@ -30,8 +34,9 @@ public class CustomOAuth2AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { private final LoginAttemptService loginAttemptService; - private final ApplicationProperties applicationProperties; + private final ApplicationProperties.Security.OAUTH2 oauth2Properties; private final UserService userService; + private final JwtServiceInterface jwtService; @Override public void onAuthenticationSuccess( @@ -60,8 +65,6 @@ public class CustomOAuth2AuthenticationSuccessHandler // Redirect to the original destination super.onAuthenticationSuccess(request, response, authentication); } else { - OAUTH2 oAuth = applicationProperties.getSecurity().getOauth2(); - if (loginAttemptService.isBlocked(username)) { if (session != null) { session.removeAttribute("SPRING_SECURITY_SAVED_REQUEST"); @@ -69,7 +72,12 @@ 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"); @@ -77,20 +85,22 @@ public class CustomOAuth2AuthenticationSuccessHandler } if (userService.usernameExistsIgnoreCase(username) && userService.hasPassword(username) - && !userService.isAuthenticationTypeByUsername(username, AuthenticationType.SSO) - && oAuth.getAutoCreateUser()) { + && (!userService.isAuthenticationTypeByUsername(username, SSO) + || !userService.isAuthenticationTypeByUsername(username, OAUTH2)) + && oauth2Properties.getAutoCreateUser()) { response.sendRedirect(contextPath + "/logout?oAuth2AuthenticationErrorWeb=true"); return; } try { - if (oAuth.getBlockRegistration() + if (oauth2Properties.getBlockRegistration() && !userService.usernameExistsIgnoreCase(username)) { response.sendRedirect(contextPath + "/logout?oAuth2AdminBlockedUser=true"); return; } if (principal instanceof OAuth2User) { - userService.processSSOPostLogin(username, oAuth.getAutoCreateUser()); + userService.processSSOPostLogin( + username, oauth2Properties.getAutoCreateUser(), OAUTH2); } response.sendRedirect(contextPath + "/"); } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { 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 6516cc7d7..913dc458a 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 @@ -34,6 +34,7 @@ import stirling.software.common.model.oauth2.GitHubProvider; import stirling.software.common.model.oauth2.GoogleProvider; import stirling.software.common.model.oauth2.KeycloakProvider; import stirling.software.common.model.oauth2.Provider; +import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.exception.NoProviderFoundException; import stirling.software.proprietary.security.service.UserService; @@ -239,12 +240,14 @@ public class OAuth2Configuration { Optional userOpt = userService.findByUsernameIgnoreCase( (String) oAuth2Auth.getAttributes().get(useAsUsername)); - if (userOpt.isPresent()) { - User user = userOpt.get(); - mappedAuthorities.add( - new SimpleGrantedAuthority( - userService.findRole(user).getAuthority())); - } + userOpt.ifPresent( + user -> + mappedAuthorities.add( + new Authority( + userService + .findRole(user) + .getAuthority(), + user))); } }); return mappedAuthorities; 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 2170a9632..3255cbc15 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 @@ -1,7 +1,11 @@ package stirling.software.proprietary.security.saml2; +import static stirling.software.proprietary.security.model.AuthenticationType.SAML2; +import static stirling.software.proprietary.security.model.AuthenticationType.SSO; + import java.io.IOException; import java.sql.SQLException; +import java.util.Map; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.Authentication; @@ -17,10 +21,10 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; -import stirling.software.common.model.ApplicationProperties.Security.SAML2; import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.common.util.RequestUriUtils; import stirling.software.proprietary.security.model.AuthenticationType; +import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; @@ -30,8 +34,9 @@ public class CustomSaml2AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { private LoginAttemptService loginAttemptService; - private ApplicationProperties applicationProperties; + private ApplicationProperties.Security.SAML2 saml2Properties; private UserService userService; + private final JwtServiceInterface jwtService; @Override public void onAuthenticationSuccess( @@ -65,10 +70,9 @@ public class CustomSaml2AuthenticationSuccessHandler savedRequest.getRedirectUrl()); super.onAuthenticationSuccess(request, response, authentication); } else { - SAML2 saml2 = applicationProperties.getSecurity().getSaml2(); log.debug( "Processing SAML2 authentication with autoCreateUser: {}", - saml2.getAutoCreateUser()); + saml2Properties.getAutoCreateUser()); if (loginAttemptService.isBlocked(username)) { log.debug("User {} is blocked due to too many login attempts", username); @@ -82,17 +86,21 @@ public class CustomSaml2AuthenticationSuccessHandler boolean userExists = userService.usernameExistsIgnoreCase(username); boolean hasPassword = userExists && userService.hasPassword(username); boolean isSSOUser = - userExists - && userService.isAuthenticationTypeByUsername( - username, AuthenticationType.SSO); + userExists && userService.isAuthenticationTypeByUsername(username, SSO); + boolean isSAML2User = + userExists && userService.isAuthenticationTypeByUsername(username, SAML2); log.debug( - "User status - Exists: {}, Has password: {}, Is SSO user: {}", + "User status - Exists: {}, Has password: {}, Is SSO user: {}, Is SAML2 user: {}", userExists, hasPassword, - isSSOUser); + isSSOUser, + isSAML2User); - if (userExists && hasPassword && !isSSOUser && saml2.getAutoCreateUser()) { + if (userExists + && hasPassword + && (!isSSOUser || !isSAML2User) + && saml2Properties.getAutoCreateUser()) { log.debug( "User {} exists with password but is not SSO user, redirecting to logout", username); @@ -102,15 +110,18 @@ public class CustomSaml2AuthenticationSuccessHandler } try { - if (saml2.getBlockRegistration() && !userExists) { + if (!userExists || saml2Properties.getBlockRegistration()) { log.debug("Registration blocked for new user: {}", username); response.sendRedirect( contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser"); return; } log.debug("Processing SSO post-login for user: {}", username); - userService.processSSOPostLogin(username, saml2.getAutoCreateUser()); + userService.processSSOPostLogin( + username, saml2Properties.getAutoCreateUser(), SAML2); log.debug("Successfully processed authentication for user: {}", username); + + generateJwt(response, authentication); response.sendRedirect(contextPath + "/"); } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { log.debug( @@ -124,4 +135,13 @@ public class CustomSaml2AuthenticationSuccessHandler super.onAuthenticationSuccess(request, response, authentication); } } + + private void generateJwt(HttpServletResponse response, Authentication authentication) { + if (jwtService.isJwtEnabled()) { + String jwt = + jwtService.generateToken( + authentication, Map.of("authType", AuthenticationType.SAML2)); + jwtService.addToken(response, jwt); + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepository.java new file mode 100644 index 000000000..d0508151c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepository.java @@ -0,0 +1,135 @@ +package stirling.software.proprietary.security.saml2; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.security.saml2.provider.service.authentication.Saml2PostAuthenticationRequest; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; +import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestRepository; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.security.service.JwtServiceInterface; + +@Slf4j +public class JwtSaml2AuthenticationRequestRepository + implements Saml2AuthenticationRequestRepository { + private final Map tokenStore; + private final JwtServiceInterface jwtService; + private final RelyingPartyRegistrationRepository relyingPartyRegistrationRepository; + + private static final String SAML_REQUEST_TOKEN = "stirling_saml_request_token"; + + public JwtSaml2AuthenticationRequestRepository( + Map tokenStore, + JwtServiceInterface jwtService, + RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) { + this.tokenStore = tokenStore; + this.jwtService = jwtService; + this.relyingPartyRegistrationRepository = relyingPartyRegistrationRepository; + } + + @Override + public void saveAuthenticationRequest( + Saml2PostAuthenticationRequest authRequest, + HttpServletRequest request, + HttpServletResponse response) { + if (!jwtService.isJwtEnabled()) { + log.debug("V2 is not enabled, skipping SAMLRequest token storage"); + return; + } + + if (authRequest == null) { + removeAuthenticationRequest(request, response); + return; + } + + Map claims = serializeSamlRequest(authRequest); + String token = jwtService.generateToken("", claims); + String relayState = authRequest.getRelayState(); + + tokenStore.put(relayState, token); + request.setAttribute(SAML_REQUEST_TOKEN, relayState); + response.addHeader(SAML_REQUEST_TOKEN, relayState); + + log.debug("Saved SAMLRequest token with RelayState: {}", relayState); + } + + @Override + public Saml2PostAuthenticationRequest loadAuthenticationRequest(HttpServletRequest request) { + String token = extractTokenFromStore(request); + + if (token == null) { + log.debug("No SAMLResponse token found in RelayState"); + return null; + } + + Map claims = jwtService.extractClaims(token); + return deserializeSamlRequest(claims); + } + + @Override + public Saml2PostAuthenticationRequest removeAuthenticationRequest( + HttpServletRequest request, HttpServletResponse response) { + Saml2PostAuthenticationRequest authRequest = loadAuthenticationRequest(request); + + String relayStateId = request.getParameter("RelayState"); + if (relayStateId != null) { + tokenStore.remove(relayStateId); + log.debug("Removed SAMLRequest token for RelayState ID: {}", relayStateId); + } + + return authRequest; + } + + private String extractTokenFromStore(HttpServletRequest request) { + String authnRequestId = request.getParameter("RelayState"); + + if (authnRequestId != null && !authnRequestId.isEmpty()) { + String token = tokenStore.get(authnRequestId); + + if (token != null) { + tokenStore.remove(authnRequestId); + log.debug("Retrieved SAMLRequest token for RelayState ID: {}", authnRequestId); + return token; + } else { + log.warn("No SAMLRequest token found for RelayState ID: {}", authnRequestId); + } + } + + return null; + } + + private Map serializeSamlRequest(Saml2PostAuthenticationRequest authRequest) { + Map claims = new HashMap<>(); + + claims.put("id", authRequest.getId()); + claims.put("relyingPartyRegistrationId", authRequest.getRelyingPartyRegistrationId()); + claims.put("authenticationRequestUri", authRequest.getAuthenticationRequestUri()); + claims.put("samlRequest", authRequest.getSamlRequest()); + claims.put("relayState", authRequest.getRelayState()); + + return claims; + } + + private Saml2PostAuthenticationRequest deserializeSamlRequest(Map claims) { + String relyingPartyRegistrationId = (String) claims.get("relyingPartyRegistrationId"); + RelyingPartyRegistration relyingPartyRegistration = + relyingPartyRegistrationRepository.findByRegistrationId(relyingPartyRegistrationId); + + if (relyingPartyRegistration == null) { + return null; + } + + return Saml2PostAuthenticationRequest.withRelyingPartyRegistration(relyingPartyRegistration) + .id((String) claims.get("id")) + .authenticationRequestUri((String) claims.get("authenticationRequestUri")) + .samlRequest((String) claims.get("samlRequest")) + .relayState((String) claims.get("relayState")) + .build(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/SAML2Configuration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java similarity index 85% rename from app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/SAML2Configuration.java rename to app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java index 7fd4768b3..9d21f88a3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/SAML2Configuration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.security.saml2; import java.security.cert.X509Certificate; import java.util.Collections; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import org.opensaml.saml.saml2.core.AuthnRequest; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -11,12 +12,12 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.io.Resource; import org.springframework.security.saml2.core.Saml2X509Credential; import org.springframework.security.saml2.core.Saml2X509Credential.Saml2X509CredentialType; -import org.springframework.security.saml2.provider.service.authentication.AbstractSaml2AuthenticationRequest; +import org.springframework.security.saml2.provider.service.authentication.Saml2PostAuthenticationRequest; import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; -import org.springframework.security.saml2.provider.service.web.HttpSessionSaml2AuthenticationRequestRepository; +import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestRepository; import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver; import jakarta.servlet.http.HttpServletRequest; @@ -26,12 +27,13 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties.Security.SAML2; +import stirling.software.proprietary.security.service.JwtServiceInterface; @Configuration @Slf4j @ConditionalOnProperty(value = "security.saml2.enabled", havingValue = "true") @RequiredArgsConstructor -public class SAML2Configuration { +public class Saml2Configuration { private final ApplicationProperties applicationProperties; @@ -58,6 +60,7 @@ public class SAML2Configuration { .assertionConsumerServiceBinding(Saml2MessageBinding.POST) .assertionConsumerServiceLocation( "{baseUrl}/login/saml2/sso/{registrationId}") + .authnRequestsSigned(true) .assertingPartyMetadata( metadata -> metadata.entityId(samlConf.getIdpIssuer()) @@ -71,15 +74,29 @@ public class SAML2Configuration { Saml2MessageBinding.POST) .singleLogoutServiceLocation( samlConf.getIdpSingleLogoutUrl()) + .singleLogoutServiceResponseLocation( + "http://localhost:8080/login") .wantAuthnRequestsSigned(true)) .build(); return new InMemoryRelyingPartyRegistrationRepository(rp); } + @Bean + @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") + public Saml2AuthenticationRequestRepository + saml2AuthenticationRequestRepository( + JwtServiceInterface jwtService, + RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) { + return new JwtSaml2AuthenticationRequestRepository( + new ConcurrentHashMap<>(), jwtService, relyingPartyRegistrationRepository); + } + @Bean @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") public OpenSaml4AuthenticationRequestResolver authenticationRequestResolver( - RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) { + RelyingPartyRegistrationRepository relyingPartyRegistrationRepository, + Saml2AuthenticationRequestRepository + saml2AuthenticationRequestRepository) { OpenSaml4AuthenticationRequestResolver resolver = new OpenSaml4AuthenticationRequestResolver(relyingPartyRegistrationRepository); @@ -87,10 +104,8 @@ public class SAML2Configuration { customizer -> { HttpServletRequest request = customizer.getRequest(); AuthnRequest authnRequest = customizer.getAuthnRequest(); - HttpSessionSaml2AuthenticationRequestRepository requestRepository = - new HttpSessionSaml2AuthenticationRequestRepository(); - AbstractSaml2AuthenticationRequest saml2AuthenticationRequest = - requestRepository.loadAuthenticationRequest(request); + Saml2PostAuthenticationRequest saml2AuthenticationRequest = + saml2AuthenticationRequestRepository.loadAuthenticationRequest(request); if (saml2AuthenticationRequest != null) { String sessionId = request.getSession(false).getId(); @@ -113,7 +128,6 @@ public class SAML2Configuration { log.debug("Generating new authentication request ID"); authnRequest.setID("ARQ" + UUID.randomUUID().toString().substring(1)); } - logAuthnRequestDetails(authnRequest); logHttpRequestDetails(request); }); 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 0b286e894..8f9afbe3d 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 @@ -27,13 +27,13 @@ public class CustomOAuth2UserService implements OAuth2UserService new UsernameNotFoundException( "No user found with username: " + username)); + if (loginAttemptService.isBlocked(username)) { throw new LockedException( "Your account has been locked due to too many failed login attempts."); } - if (!user.hasPassword()) { + + AuthenticationType userAuthenticationType = + AuthenticationType.valueOf(user.getAuthenticationType().toUpperCase()); + if (!user.hasPassword() && userAuthenticationType == AuthenticationType.WEB) { throw new IllegalArgumentException("Password must not be null"); } - return new org.springframework.security.core.userdetails.User( - user.getUsername(), - user.getPassword(), - user.isEnabled(), - true, - true, - true, - getAuthorities(user.getAuthorities())); - } - private Collection getAuthorities(Set authorities) { - return authorities.stream() - .map(authority -> new SimpleGrantedAuthority(authority.getAuthority())) - .toList(); + return user; } } 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 new file mode 100644 index 000000000..8724da9a8 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java @@ -0,0 +1,330 @@ +package stirling.software.proprietary.security.service; + +import java.security.KeyPair; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.time.LocalDateTime; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +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; +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; + +import stirling.software.proprietary.security.model.JwtVerificationKey; +import stirling.software.proprietary.security.model.exception.AuthenticationFailureException; +import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; + +@Slf4j +@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 long EXPIRATION = 3600000; + + @Value("${stirling.security.jwt.secureCookie:true}") + private boolean secureCookie; + + private final KeyPersistenceServiceInterface keyPersistenceService; + private final boolean v2Enabled; + + @Autowired + public JwtService( + @Qualifier("v2Enabled") boolean v2Enabled, + KeyPersistenceServiceInterface keyPersistenceService) { + this.v2Enabled = v2Enabled; + this.keyPersistenceService = keyPersistenceService; + } + + @Override + public String generateToken(Authentication authentication, Map claims) { + Object principal = authentication.getPrincipal(); + String username = ""; + + if (principal instanceof UserDetails) { + username = ((UserDetails) principal).getUsername(); + } else if (principal instanceof OAuth2User) { + username = ((OAuth2User) principal).getName(); + } else if (principal instanceof CustomSaml2AuthenticatedPrincipal) { + username = ((CustomSaml2AuthenticatedPrincipal) principal).getName(); + } + + return generateToken(username, claims); + } + + @Override + public String generateToken(String username, Map claims) { + try { + JwtVerificationKey activeKey = keyPersistenceService.getActiveKey(); + Optional keyPairOpt = keyPersistenceService.getKeyPair(activeKey.getKeyId()); + + if (keyPairOpt.isEmpty()) { + throw new RuntimeException("Unable to retrieve key pair for active key"); + } + + KeyPair keyPair = keyPairOpt.get(); + + var builder = + Jwts.builder() + .claims(claims) + .subject(username) + .issuer(ISSUER) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + EXPIRATION)) + .signWith(keyPair.getPrivate(), Jwts.SIG.RS256); + + String keyId = activeKey.getKeyId(); + if (keyId != null) { + builder.header().keyId(keyId); + } + + return builder.compact(); + } catch (Exception e) { + throw new RuntimeException("Failed to generate token", e); + } + } + + @Override + public void validateToken(String token) throws AuthenticationFailureException { + extractAllClaims(token); + + if (isTokenExpired(token)) { + throw new AuthenticationFailureException("The token has expired"); + } + } + + @Override + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + @Override + public Map extractClaims(String token) { + Claims claims = extractAllClaims(token); + return new HashMap<>(claims); + } + + @Override + public boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + private Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } + + private T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + private Claims extractAllClaims(String token) { + try { + String keyId = extractKeyId(token); + KeyPair keyPair; + + if (keyId != null) { + Optional specificKeyPair = keyPersistenceService.getKeyPair(keyId); + + if (specificKeyPair.isPresent()) { + keyPair = specificKeyPair.get(); + } else { + log.warn( + "Key ID {} not found in keystore, token may have been signed with an expired key", + keyId); + + if (keyId.equals(keyPersistenceService.getActiveKey().getKeyId())) { + JwtVerificationKey verificationKey = + keyPersistenceService.refreshActiveKeyPair(); + Optional refreshedKeyPair = + keyPersistenceService.getKeyPair(verificationKey.getKeyId()); + if (refreshedKeyPair.isPresent()) { + keyPair = refreshedKeyPair.get(); + } else { + throw new AuthenticationFailureException( + "Failed to retrieve refreshed key pair"); + } + } else { + // Try to use active key as fallback + JwtVerificationKey activeKey = keyPersistenceService.getActiveKey(); + Optional activeKeyPair = + keyPersistenceService.getKeyPair(activeKey.getKeyId()); + if (activeKeyPair.isPresent()) { + keyPair = activeKeyPair.get(); + } else { + throw new AuthenticationFailureException( + "Failed to retrieve active key pair"); + } + } + } + } else { + log.debug("No key ID in token header, trying all available keys"); + // Try all available keys when no keyId is present + return tryAllKeys(token); + } + + return Jwts.parser() + .verifyWith(keyPair.getPublic()) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (SignatureException e) { + log.warn("Invalid signature: {}", e.getMessage()); + throw new AuthenticationFailureException("Invalid signature", e); + } catch (MalformedJwtException e) { + log.warn("Invalid token: {}", e.getMessage()); + throw new AuthenticationFailureException("Invalid token", e); + } catch (ExpiredJwtException e) { + log.warn("The token has expired: {}", e.getMessage()); + throw new AuthenticationFailureException("The token has expired", e); + } catch (UnsupportedJwtException e) { + log.warn("The token is unsupported: {}", e.getMessage()); + throw new AuthenticationFailureException("The token is unsupported", e); + } catch (IllegalArgumentException e) { + log.warn("Claims are empty: {}", e.getMessage()); + throw new AuthenticationFailureException("Claims are empty", e); + } + } + + private Claims tryAllKeys(String token) throws AuthenticationFailureException { + // First try the active key + try { + JwtVerificationKey activeKey = keyPersistenceService.getActiveKey(); + PublicKey publicKey = + keyPersistenceService.decodePublicKey(activeKey.getVerifyingKey()); + return Jwts.parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (SignatureException + | NoSuchAlgorithmException + | InvalidKeySpecException activeKeyException) { + log.debug("Active key failed, trying all available keys from cache"); + + // If active key fails, try all available keys from cache + List allKeys = + keyPersistenceService.getKeysEligibleForCleanup( + LocalDateTime.now().plusDays(1)); + + for (JwtVerificationKey verificationKey : allKeys) { + try { + PublicKey publicKey = + keyPersistenceService.decodePublicKey( + verificationKey.getVerifyingKey()); + return Jwts.parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (SignatureException + | NoSuchAlgorithmException + | InvalidKeySpecException e) { + log.debug( + "Key {} failed to verify token, trying next key", + verificationKey.getKeyId()); + // Continue to next key + } + } + + throw new AuthenticationFailureException( + "Token signature could not be verified with any available key", + activeKeyException); + } + } + + @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(); + } + } + } + + 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; + } + + private String extractKeyId(String token) { + try { + PublicKey signingKey = + keyPersistenceService.decodePublicKey( + keyPersistenceService.getActiveKey().getVerifyingKey()); + + String keyId = + (String) + Jwts.parser() + .verifyWith(signingKey) + .build() + .parse(token) + .getHeader() + .get("kid"); + log.debug("Extracted key ID from token: {}", keyId); + return keyId; + } catch (Exception e) { + log.warn("Failed to extract key ID from token header: {}", e.getMessage()); + return null; + } + } +} 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 new file mode 100644 index 000000000..7cdca8209 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java @@ -0,0 +1,90 @@ +package stirling.software.proprietary.security.service; + +import java.util.Map; + +import org.springframework.security.core.Authentication; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public interface JwtServiceInterface { + + /** + * Generate a JWT token for the authenticated user + * + * @param authentication Spring Security authentication object + * @return JWT token as a string + */ + String generateToken(Authentication authentication, Map claims); + + /** + * Generate a JWT token for a specific username + * + * @param username the username for which to generate the token + * @param claims additional claims to include in the token + * @return JWT token as a string + */ + String generateToken(String username, Map claims); + + /** + * Validate a JWT token + * + * @param token the JWT token to validate + * @return true if token is valid, false otherwise + */ + void validateToken(String token); + + /** + * Extract username from JWT token + * + * @param token the JWT token + * @return username extracted from token + */ + String extractUsername(String token); + + /** + * Extract all claims from JWT token + * + * @param token the JWT token + * @return map of claims + */ + Map extractClaims(String token); + + /** + * Check if token is expired + * + * @param token the JWT token + * @return true if token is expired, false otherwise + */ + boolean isTokenExpired(String token); + + /** + * Extract JWT token from HTTP request (header or cookie) + * + * @param request HTTP servlet request + * @return JWT token if found, null otherwise + */ + 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 + * + * @return true if JWT is enabled, false otherwise + */ + boolean isJwtEnabled(); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPairCleanupService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPairCleanupService.java new file mode 100644 index 000000000..b419f78fe --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPairCleanupService.java @@ -0,0 +1,88 @@ +package stirling.software.proprietary.security.service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.annotation.PostConstruct; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.model.JwtVerificationKey; + +@Slf4j +@Service +@ConditionalOnBooleanProperty("v2") +public class KeyPairCleanupService { + + private final KeyPersistenceService keyPersistenceService; + private final ApplicationProperties.Security.Jwt jwtProperties; + + @Autowired + public KeyPairCleanupService( + KeyPersistenceService keyPersistenceService, + ApplicationProperties applicationProperties) { + this.keyPersistenceService = keyPersistenceService; + this.jwtProperties = applicationProperties.getSecurity().getJwt(); + } + + @Transactional + @PostConstruct + @Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS) + public void cleanup() { + if (!jwtProperties.isEnableKeyCleanup() || !keyPersistenceService.isKeystoreEnabled()) { + return; + } + + LocalDateTime cutoffDate = + LocalDateTime.now().minusDays(jwtProperties.getKeyRetentionDays()); + + List eligibleKeys = + keyPersistenceService.getKeysEligibleForCleanup(cutoffDate); + if (eligibleKeys.isEmpty()) { + return; + } + + log.info("Removing keys older than retention period"); + removeKeys(eligibleKeys); + keyPersistenceService.refreshActiveKeyPair(); + } + + private void removeKeys(List keys) { + keys.forEach( + key -> { + try { + keyPersistenceService.removeKey(key.getKeyId()); + removePrivateKey(key.getKeyId()); + } catch (IOException e) { + log.warn("Failed to remove key: {}", key.getKeyId(), e); + } + }); + } + + private void removePrivateKey(String keyId) throws IOException { + if (!keyPersistenceService.isKeystoreEnabled()) { + return; + } + + Path privateKeyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath()); + Path keyFile = privateKeyDirectory.resolve(keyId + KeyPersistenceService.KEY_SUFFIX); + + if (Files.exists(keyFile)) { + Files.delete(keyFile); + log.debug("Deleted private key: {}", keyFile); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java new file mode 100644 index 000000000..48bcddac0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java @@ -0,0 +1,243 @@ +package stirling.software.proprietary.security.service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.caffeine.CaffeineCache; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.annotation.PostConstruct; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.model.JwtVerificationKey; + +@Slf4j +@Service +public class KeyPersistenceService implements KeyPersistenceServiceInterface { + + public static final String KEY_SUFFIX = ".key"; + + private final ApplicationProperties.Security.Jwt jwtProperties; + private final CacheManager cacheManager; + private final Cache verifyingKeyCache; + + private volatile JwtVerificationKey activeKey; + + @Autowired + public KeyPersistenceService( + ApplicationProperties applicationProperties, CacheManager cacheManager) { + this.jwtProperties = applicationProperties.getSecurity().getJwt(); + this.cacheManager = cacheManager; + this.verifyingKeyCache = cacheManager.getCache("verifyingKeys"); + } + + @PostConstruct + public void initializeKeystore() { + if (!isKeystoreEnabled()) { + return; + } + + try { + ensurePrivateKeyDirectoryExists(); + loadKeyPair(); + } catch (Exception e) { + log.error("Failed to initialize keystore, using in-memory generation", e); + } + } + + private void loadKeyPair() { + if (activeKey == null) { + generateAndStoreKeypair(); + } + } + + @Transactional + private JwtVerificationKey generateAndStoreKeypair() { + JwtVerificationKey verifyingKey = null; + + try { + KeyPair keyPair = generateRSAKeypair(); + String keyId = generateKeyId(); + + storePrivateKey(keyId, keyPair.getPrivate()); + verifyingKey = new JwtVerificationKey(keyId, encodePublicKey(keyPair.getPublic())); + verifyingKeyCache.put(keyId, verifyingKey); + activeKey = verifyingKey; + } catch (IOException e) { + log.error("Failed to generate and store keypair", e); + } + + return verifyingKey; + } + + @Override + public JwtVerificationKey getActiveKey() { + if (activeKey == null) { + return generateAndStoreKeypair(); + } + return activeKey; + } + + @Override + public Optional getKeyPair(String keyId) { + if (!isKeystoreEnabled()) { + return Optional.empty(); + } + + try { + JwtVerificationKey verifyingKey = + verifyingKeyCache.get(keyId, JwtVerificationKey.class); + + if (verifyingKey == null) { + log.warn("No signing key found in database for keyId: {}", keyId); + return Optional.empty(); + } + + PrivateKey privateKey = loadPrivateKey(keyId); + PublicKey publicKey = decodePublicKey(verifyingKey.getVerifyingKey()); + + return Optional.of(new KeyPair(publicKey, privateKey)); + } catch (Exception e) { + log.error("Failed to load keypair for keyId: {}", keyId, e); + return Optional.empty(); + } + } + + @Override + public boolean isKeystoreEnabled() { + return jwtProperties.isEnableKeystore(); + } + + @Override + public JwtVerificationKey refreshActiveKeyPair() { + return generateAndStoreKeypair(); + } + + @Override + @CacheEvict( + value = {"verifyingKeys"}, + key = "#keyId", + condition = "#root.target.isKeystoreEnabled()") + public void removeKey(String keyId) { + verifyingKeyCache.evict(keyId); + } + + @Override + public List getKeysEligibleForCleanup(LocalDateTime cutoffDate) { + CaffeineCache caffeineCache = (CaffeineCache) verifyingKeyCache; + com.github.benmanes.caffeine.cache.Cache nativeCache = + caffeineCache.getNativeCache(); + + log.debug( + "Cache size: {}, Checking {} keys for cleanup", + nativeCache.estimatedSize(), + nativeCache.asMap().size()); + + return nativeCache.asMap().values().stream() + .filter(value -> value instanceof JwtVerificationKey) + .map(value -> (JwtVerificationKey) value) + .filter( + key -> { + boolean eligible = key.getCreatedAt().isBefore(cutoffDate); + log.debug( + "Key {} created at {}, eligible for cleanup: {}", + key.getKeyId(), + key.getCreatedAt(), + eligible); + return eligible; + }) + .collect(Collectors.toList()); + } + + private String generateKeyId() { + return "jwt-key-" + + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HHmmss")); + } + + private KeyPair generateRSAKeypair() { + KeyPairGenerator keyPairGenerator = null; + + try { + keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + } catch (NoSuchAlgorithmException e) { + log.error("Failed to initialize RSA key pair generator", e); + } + + return keyPairGenerator.generateKeyPair(); + } + + private void ensurePrivateKeyDirectoryExists() throws IOException { + Path keyPath = Paths.get(InstallationPathConfig.getPrivateKeyPath()); + + if (!Files.exists(keyPath)) { + Files.createDirectories(keyPath); + } + } + + private void storePrivateKey(String keyId, PrivateKey privateKey) throws IOException { + Path keyFile = + Paths.get(InstallationPathConfig.getPrivateKeyPath()).resolve(keyId + KEY_SUFFIX); + String encodedKey = Base64.getEncoder().encodeToString(privateKey.getEncoded()); + Files.writeString(keyFile, encodedKey); + + // Set read/write to only the owner + keyFile.toFile().setReadable(true, true); + keyFile.toFile().setWritable(true, true); + keyFile.toFile().setExecutable(false, false); + } + + private PrivateKey loadPrivateKey(String keyId) + throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { + Path keyFile = + Paths.get(InstallationPathConfig.getPrivateKeyPath()).resolve(keyId + KEY_SUFFIX); + + if (!Files.exists(keyFile)) { + throw new IOException("Private key not found: " + keyFile); + } + + String encodedKey = Files.readString(keyFile); + byte[] keyBytes = Base64.getDecoder().decode(encodedKey); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + + return keyFactory.generatePrivate(keySpec); + } + + private String encodePublicKey(PublicKey publicKey) { + return Base64.getEncoder().encodeToString(publicKey.getEncoded()); + } + + public PublicKey decodePublicKey(String encodedKey) + throws NoSuchAlgorithmException, InvalidKeySpecException { + byte[] keyBytes = Base64.getDecoder().decode(encodedKey); + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePublic(keySpec); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterface.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterface.java new file mode 100644 index 000000000..f3050472e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterface.java @@ -0,0 +1,29 @@ +package stirling.software.proprietary.security.service; + +import java.security.KeyPair; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import stirling.software.proprietary.security.model.JwtVerificationKey; + +public interface KeyPersistenceServiceInterface { + + JwtVerificationKey getActiveKey(); + + Optional getKeyPair(String keyId); + + boolean isKeystoreEnabled(); + + JwtVerificationKey refreshActiveKeyPair(); + + List getKeysEligibleForCleanup(LocalDateTime cutoffDate); + + void removeKey(String keyId); + + PublicKey decodePublicKey(String encodedKey) + throws NoSuchAlgorithmException, InvalidKeySpecException; +} 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 50c8027f6..6f213b25e 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 @@ -15,7 +15,6 @@ import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.userdetails.UserDetails; @@ -61,19 +60,9 @@ public class UserService implements UserServiceInterface { private final ApplicationProperties.Security.OAUTH2 oAuth2; - @Transactional - public void migrateOauth2ToSSO() { - userRepository - .findByAuthenticationTypeIgnoreCase("OAUTH2") - .forEach( - user -> { - user.setAuthenticationType(AuthenticationType.SSO); - userRepository.save(user); - }); - } - // Handle OAUTH2 login and user auto creation. - public void processSSOPostLogin(String username, boolean autoCreateUser) + public void processSSOPostLogin( + String username, boolean autoCreateUser, AuthenticationType type) throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!isUsernameValid(username)) { return; @@ -83,7 +72,7 @@ public class UserService implements UserServiceInterface { return; } if (autoCreateUser) { - saveUser(username, AuthenticationType.SSO); + saveUser(username, type); } } @@ -100,10 +89,7 @@ public class UserService implements UserServiceInterface { } private Collection getAuthorities(User user) { - // Convert each Authority object into a SimpleGrantedAuthority object. - return user.getAuthorities().stream() - .map((Authority authority) -> new SimpleGrantedAuthority(authority.getAuthority())) - .toList(); + return user.getAuthorities(); } private String generateApiKey() { diff --git a/app/proprietary/src/main/resources/static/js/audit/dashboard.js b/app/proprietary/src/main/resources/static/js/audit/dashboard.js index 5cc670908..c0b93bd8e 100644 --- a/app/proprietary/src/main/resources/static/js/audit/dashboard.js +++ b/app/proprietary/src/main/resources/static/js/audit/dashboard.js @@ -230,7 +230,7 @@ function loadAuditData(targetPage, realPageSize) { document.getElementById('page-indicator').textContent = `Page ${requestedPage + 1} of ?`; } - fetch(url) + fetchWithCsrf(url) .then(response => { return response.json(); }) @@ -302,7 +302,7 @@ function loadStats(days) { showLoading('user-chart-loading'); showLoading('time-chart-loading'); - fetch(`/audit/stats?days=${days}`) + fetchWithCsrf(`/audit/stats?days=${days}`) .then(response => response.json()) .then(data => { document.getElementById('total-events').textContent = data.totalEvents; @@ -835,7 +835,7 @@ function hideLoading(id) { // Load event types from the server for filter dropdowns function loadEventTypes() { - fetch('/audit/types') + fetchWithCsrf('/audit/types') .then(response => response.json()) .then(types => { if (!types || types.length === 0) { 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 04ca4c35f..7a4076260 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 @@ -14,12 +14,18 @@ import org.springframework.security.oauth2.client.authentication.OAuth2Authentic import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import stirling.software.common.configuration.AppConfig; import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.service.JwtServiceInterface; @ExtendWith(MockitoExtension.class) class CustomLogoutSuccessHandlerTest { - @Mock private ApplicationProperties applicationProperties; + @Mock private ApplicationProperties.Security securityProperties; + + @Mock private AppConfig appConfig; + + @Mock private JwtServiceInterface jwtService; @InjectMocks private CustomLogoutSuccessHandler customLogoutSuccessHandler; @@ -27,9 +33,12 @@ class CustomLogoutSuccessHandlerTest { void testSuccessfulLogout() throws IOException { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); - String logoutPath = "logout=true"; + String token = "token"; + String logoutPath = "/login?logout=true"; 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); @@ -38,12 +47,30 @@ class CustomLogoutSuccessHandlerTest { verify(response).sendRedirect(logoutPath); } + @Test + void testSuccessfulLogoutViaJWT() throws IOException { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + String logoutPath = "/login?logout=true"; + String token = "token"; + + 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 void testSuccessfulLogoutViaOAuth2() throws IOException { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); OAuth2AuthenticationToken oAuth2AuthenticationToken = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security security = mock(ApplicationProperties.Security.class); ApplicationProperties.Security.OAUTH2 oauth = mock(ApplicationProperties.Security.OAUTH2.class); @@ -54,8 +81,7 @@ class CustomLogoutSuccessHandlerTest { when(request.getServerName()).thenReturn("localhost"); when(request.getServerPort()).thenReturn(8080); when(request.getContextPath()).thenReturn(""); - when(applicationProperties.getSecurity()).thenReturn(security); - when(security.getOauth2()).thenReturn(oauth); + when(securityProperties.getOauth2()).thenReturn(oauth); when(oAuth2AuthenticationToken.getAuthorizedClientRegistrationId()).thenReturn("test"); customLogoutSuccessHandler.onLogoutSuccess(request, response, oAuth2AuthenticationToken); @@ -70,7 +96,6 @@ class CustomLogoutSuccessHandlerTest { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security security = mock(ApplicationProperties.Security.class); ApplicationProperties.Security.OAUTH2 oauth = mock(ApplicationProperties.Security.OAUTH2.class); @@ -84,8 +109,7 @@ class CustomLogoutSuccessHandlerTest { when(request.getServerName()).thenReturn("localhost"); when(request.getServerPort()).thenReturn(8080); when(request.getContextPath()).thenReturn(""); - when(applicationProperties.getSecurity()).thenReturn(security); - when(security.getOauth2()).thenReturn(oauth); + when(securityProperties.getOauth2()).thenReturn(oauth); when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test"); customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); @@ -101,7 +125,6 @@ class CustomLogoutSuccessHandlerTest { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security security = mock(ApplicationProperties.Security.class); ApplicationProperties.Security.OAUTH2 oauth = mock(ApplicationProperties.Security.OAUTH2.class); @@ -111,8 +134,7 @@ class CustomLogoutSuccessHandlerTest { when(request.getServerName()).thenReturn("localhost"); when(request.getServerPort()).thenReturn(8080); when(request.getContextPath()).thenReturn(""); - when(applicationProperties.getSecurity()).thenReturn(security); - when(security.getOauth2()).thenReturn(oauth); + when(securityProperties.getOauth2()).thenReturn(oauth); when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test"); customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); @@ -127,7 +149,6 @@ class CustomLogoutSuccessHandlerTest { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security security = mock(ApplicationProperties.Security.class); ApplicationProperties.Security.OAUTH2 oauth = mock(ApplicationProperties.Security.OAUTH2.class); @@ -138,8 +159,7 @@ class CustomLogoutSuccessHandlerTest { when(request.getServerName()).thenReturn("localhost"); when(request.getServerPort()).thenReturn(8080); when(request.getContextPath()).thenReturn(""); - when(applicationProperties.getSecurity()).thenReturn(security); - when(security.getOauth2()).thenReturn(oauth); + when(securityProperties.getOauth2()).thenReturn(oauth); when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test"); customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); @@ -154,7 +174,6 @@ class CustomLogoutSuccessHandlerTest { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security security = mock(ApplicationProperties.Security.class); ApplicationProperties.Security.OAUTH2 oauth = mock(ApplicationProperties.Security.OAUTH2.class); @@ -167,8 +186,7 @@ class CustomLogoutSuccessHandlerTest { when(request.getServerName()).thenReturn("localhost"); when(request.getServerPort()).thenReturn(8080); when(request.getContextPath()).thenReturn(""); - when(applicationProperties.getSecurity()).thenReturn(security); - when(security.getOauth2()).thenReturn(oauth); + when(securityProperties.getOauth2()).thenReturn(oauth); when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test"); customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); @@ -183,7 +201,6 @@ class CustomLogoutSuccessHandlerTest { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security security = mock(ApplicationProperties.Security.class); ApplicationProperties.Security.OAUTH2 oauth = mock(ApplicationProperties.Security.OAUTH2.class); @@ -198,8 +215,7 @@ class CustomLogoutSuccessHandlerTest { when(request.getServerName()).thenReturn("localhost"); when(request.getServerPort()).thenReturn(8080); when(request.getContextPath()).thenReturn(""); - when(applicationProperties.getSecurity()).thenReturn(security); - when(security.getOauth2()).thenReturn(oauth); + when(securityProperties.getOauth2()).thenReturn(oauth); when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test"); customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); @@ -214,7 +230,6 @@ class CustomLogoutSuccessHandlerTest { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security security = mock(ApplicationProperties.Security.class); ApplicationProperties.Security.OAUTH2 oauth = mock(ApplicationProperties.Security.OAUTH2.class); @@ -230,8 +245,7 @@ class CustomLogoutSuccessHandlerTest { when(request.getServerName()).thenReturn("localhost"); when(request.getServerPort()).thenReturn(8080); when(request.getContextPath()).thenReturn(""); - when(applicationProperties.getSecurity()).thenReturn(security); - when(security.getOauth2()).thenReturn(oauth); + when(securityProperties.getOauth2()).thenReturn(oauth); when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test"); customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); @@ -246,7 +260,6 @@ class CustomLogoutSuccessHandlerTest { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security security = mock(ApplicationProperties.Security.class); ApplicationProperties.Security.OAUTH2 oauth = mock(ApplicationProperties.Security.OAUTH2.class); @@ -259,8 +272,7 @@ class CustomLogoutSuccessHandlerTest { when(request.getServerName()).thenReturn("localhost"); when(request.getServerPort()).thenReturn(8080); when(request.getContextPath()).thenReturn(""); - when(applicationProperties.getSecurity()).thenReturn(security); - when(security.getOauth2()).thenReturn(oauth); + when(securityProperties.getOauth2()).thenReturn(oauth); when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test"); customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/JwtAuthenticationEntryPointTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/JwtAuthenticationEntryPointTest.java new file mode 100644 index 000000000..a47f45318 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/JwtAuthenticationEntryPointTest.java @@ -0,0 +1,38 @@ +package stirling.software.proprietary.security; + +import static org.mockito.Mockito.*; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import stirling.software.proprietary.security.model.exception.AuthenticationFailureException; + +@ExtendWith(MockitoExtension.class) +class JwtAuthenticationEntryPointTest { + + @Mock private HttpServletRequest request; + + @Mock private HttpServletResponse response; + + @Mock private AuthenticationFailureException authException; + + @InjectMocks private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + + @Test + void testCommence() throws IOException { + String errorMessage = "Authentication failed"; + when(authException.getMessage()).thenReturn(errorMessage); + + jwtAuthenticationEntryPoint.commence(request, response, authException); + + verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED, errorMessage); + } +} 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 new file mode 100644 index 000000000..d3f484486 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilterTest.java @@ -0,0 +1,242 @@ +package stirling.software.proprietary.security.filter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.util.Collections; +import java.util.Map; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.model.exception.AuthenticationFailureException; +import stirling.software.proprietary.security.service.CustomUserDetailsService; +import stirling.software.proprietary.security.service.JwtServiceInterface; +import stirling.software.proprietary.security.service.UserService; + +@Disabled +@ExtendWith(MockitoExtension.class) +class JwtAuthenticationFilterTest { + + @Mock private JwtServiceInterface jwtService; + + @Mock private CustomUserDetailsService userDetailsService; + + @Mock private UserService userService; + + @Mock private ApplicationProperties.Security securityProperties; + + @Mock private HttpServletRequest request; + + @Mock private HttpServletResponse response; + + @Mock private FilterChain filterChain; + + @Mock private UserDetails userDetails; + + @Mock private SecurityContext securityContext; + + @Mock private AuthenticationEntryPoint authenticationEntryPoint; + + @InjectMocks private JwtAuthenticationFilter jwtAuthenticationFilter; + + @Test + void shouldNotAuthenticateWhenJwtDisabled() throws ServletException, IOException { + when(jwtService.isJwtEnabled()).thenReturn(false); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(jwtService, never()).extractToken(any()); + } + + @Test + void shouldNotFilterWhenPageIsLogin() throws ServletException, IOException { + when(jwtService.isJwtEnabled()).thenReturn(true); + when(request.getRequestURI()).thenReturn("/login"); + when(request.getContextPath()).thenReturn("/login"); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + verify(filterChain, never()).doFilter(request, response); + } + + @Test + void testDoFilterInternal() throws ServletException, IOException { + String token = "valid-jwt-token"; + String newToken = "new-jwt-token"; + String username = "testuser"; + Map claims = Map.of("sub", username, "authType", "WEB"); + + when(jwtService.isJwtEnabled()).thenReturn(true); + when(request.getContextPath()).thenReturn("/"); + when(request.getRequestURI()).thenReturn("/protected"); + when(jwtService.extractToken(request)).thenReturn(token); + doNothing().when(jwtService).validateToken(token); + when(jwtService.extractClaims(token)).thenReturn(claims); + when(userDetails.getAuthorities()).thenReturn(Collections.emptyList()); + when(userDetailsService.loadUserByUsername(username)).thenReturn(userDetails); + + try (MockedStatic mockedSecurityContextHolder = + mockStatic(SecurityContextHolder.class)) { + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + + when(securityContext.getAuthentication()).thenReturn(null).thenReturn(authToken); + mockedSecurityContextHolder + .when(SecurityContextHolder::getContext) + .thenReturn(securityContext); + when(jwtService.generateToken( + any(UsernamePasswordAuthenticationToken.class), eq(claims))) + .thenReturn(newToken); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + verify(jwtService).validateToken(token); + verify(jwtService).extractClaims(token); + verify(userDetailsService).loadUserByUsername(username); + verify(securityContext) + .setAuthentication(any(UsernamePasswordAuthenticationToken.class)); + verify(jwtService) + .generateToken(any(UsernamePasswordAuthenticationToken.class), eq(claims)); + verify(jwtService).addToken(response, newToken); + verify(filterChain).doFilter(request, response); + } + } + + @Test + void testDoFilterInternalWithMissingTokenForRootPath() throws ServletException, IOException { + when(jwtService.isJwtEnabled()).thenReturn(true); + when(request.getRequestURI()).thenReturn("/"); + when(request.getMethod()).thenReturn("GET"); + when(jwtService.extractToken(request)).thenReturn(null); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + verify(response).sendRedirect("/login"); + verify(filterChain, never()).doFilter(request, response); + } + + @Test + void validationFailsWithInvalidToken() throws ServletException, IOException { + String token = "invalid-jwt-token"; + + when(jwtService.isJwtEnabled()).thenReturn(true); + when(request.getRequestURI()).thenReturn("/protected"); + when(request.getContextPath()).thenReturn("/"); + when(jwtService.extractToken(request)).thenReturn(token); + doThrow(new AuthenticationFailureException("Invalid token")) + .when(jwtService) + .validateToken(token); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + verify(jwtService).validateToken(token); + verify(authenticationEntryPoint) + .commence(eq(request), eq(response), any(AuthenticationFailureException.class)); + verify(filterChain, never()).doFilter(request, response); + } + + @Test + void validationFailsWithExpiredToken() throws ServletException, IOException { + String token = "expired-jwt-token"; + + when(jwtService.isJwtEnabled()).thenReturn(true); + when(request.getRequestURI()).thenReturn("/protected"); + when(request.getContextPath()).thenReturn("/"); + when(jwtService.extractToken(request)).thenReturn(token); + doThrow(new AuthenticationFailureException("The token has expired")) + .when(jwtService) + .validateToken(token); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + verify(jwtService).validateToken(token); + verify(authenticationEntryPoint).commence(eq(request), eq(response), any()); + verify(filterChain, never()).doFilter(request, response); + } + + @Test + void exceptionThrown_WhenUserNotFound() throws ServletException, IOException { + String token = "valid-jwt-token"; + String username = "nonexistentuser"; + Map claims = Map.of("sub", username, "authType", "WEB"); + + when(jwtService.isJwtEnabled()).thenReturn(true); + when(request.getRequestURI()).thenReturn("/protected"); + when(request.getContextPath()).thenReturn("/"); + when(jwtService.extractToken(request)).thenReturn(token); + doNothing().when(jwtService).validateToken(token); + when(jwtService.extractClaims(token)).thenReturn(claims); + when(userDetailsService.loadUserByUsername(username)).thenReturn(null); + + try (MockedStatic mockedSecurityContextHolder = + mockStatic(SecurityContextHolder.class)) { + when(securityContext.getAuthentication()).thenReturn(null); + mockedSecurityContextHolder + .when(SecurityContextHolder::getContext) + .thenReturn(securityContext); + + UsernameNotFoundException result = + assertThrows( + UsernameNotFoundException.class, + () -> + jwtAuthenticationFilter.doFilterInternal( + request, response, filterChain)); + + assertEquals("User not found: " + username, result.getMessage()); + verify(userDetailsService).loadUserByUsername(username); + verify(filterChain, never()).doFilter(request, response); + } + } + + @Test + void testAuthenticationEntryPointCalledWithCorrectException() + throws ServletException, IOException { + when(jwtService.isJwtEnabled()).thenReturn(true); + when(request.getRequestURI()).thenReturn("/protected"); + when(request.getContextPath()).thenReturn("/"); + when(jwtService.extractToken(request)).thenReturn(null); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + verify(authenticationEntryPoint) + .commence( + eq(request), + eq(response), + argThat( + exception -> + exception + .getMessage() + .equals("JWT is missing from the request"))); + verify(filterChain, never()).doFilter(request, response); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java new file mode 100644 index 000000000..1aa083cc0 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java @@ -0,0 +1,247 @@ +package stirling.software.proprietary.security.saml2; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +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.NullAndEmptySource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.saml2.provider.service.authentication.Saml2PostAuthenticationRequest; +import org.springframework.security.saml2.provider.service.registration.AssertingPartyMetadata; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import stirling.software.proprietary.security.service.JwtServiceInterface; + +@ExtendWith(MockitoExtension.class) +class JwtSaml2AuthenticationRequestRepositoryTest { + + private static final String SAML_REQUEST_TOKEN = "stirling_saml_request_token"; + + private Map tokenStore; + + @Mock private JwtServiceInterface jwtService; + + @Mock private RelyingPartyRegistrationRepository relyingPartyRegistrationRepository; + + private JwtSaml2AuthenticationRequestRepository jwtSaml2AuthenticationRequestRepository; + + @BeforeEach + void setUp() { + tokenStore = new ConcurrentHashMap<>(); + jwtSaml2AuthenticationRequestRepository = + new JwtSaml2AuthenticationRequestRepository( + tokenStore, jwtService, relyingPartyRegistrationRepository); + } + + @Test + void saveAuthenticationRequest() { + var authRequest = mock(Saml2PostAuthenticationRequest.class); + var request = mock(MockHttpServletRequest.class); + var response = mock(MockHttpServletResponse.class); + String token = "testToken"; + String id = "testId"; + String relayState = "testRelayState"; + String authnRequestUri = "example.com/authnRequest"; + Map claims = Map.of(); + String samlRequest = "testSamlRequest"; + String relyingPartyRegistrationId = "stirling-pdf"; + + when(jwtService.isJwtEnabled()).thenReturn(true); + when(authRequest.getRelayState()).thenReturn(relayState); + when(authRequest.getId()).thenReturn(id); + when(authRequest.getAuthenticationRequestUri()).thenReturn(authnRequestUri); + when(authRequest.getSamlRequest()).thenReturn(samlRequest); + when(authRequest.getRelyingPartyRegistrationId()).thenReturn(relyingPartyRegistrationId); + when(jwtService.generateToken(eq(""), anyMap())).thenReturn(token); + + jwtSaml2AuthenticationRequestRepository.saveAuthenticationRequest( + authRequest, request, response); + + verify(request).setAttribute(SAML_REQUEST_TOKEN, relayState); + verify(response).addHeader(SAML_REQUEST_TOKEN, relayState); + } + + @Test + void saveAuthenticationRequestWithNullRequest() { + var request = mock(MockHttpServletRequest.class); + var response = mock(MockHttpServletResponse.class); + + jwtSaml2AuthenticationRequestRepository.saveAuthenticationRequest(null, request, response); + + assertTrue(tokenStore.isEmpty()); + } + + @Test + void loadAuthenticationRequest() { + var request = mock(MockHttpServletRequest.class); + var relyingPartyRegistration = mock(RelyingPartyRegistration.class); + var assertingPartyMetadata = mock(AssertingPartyMetadata.class); + String relayState = "testRelayState"; + String token = "testToken"; + Map claims = + Map.of( + "id", "testId", + "relyingPartyRegistrationId", "stirling-pdf", + "authenticationRequestUri", "example.com/authnRequest", + "samlRequest", "testSamlRequest", + "relayState", relayState); + + when(request.getParameter("RelayState")).thenReturn(relayState); + when(jwtService.extractClaims(token)).thenReturn(claims); + when(relyingPartyRegistrationRepository.findByRegistrationId("stirling-pdf")) + .thenReturn(relyingPartyRegistration); + when(relyingPartyRegistration.getRegistrationId()).thenReturn("stirling-pdf"); + when(relyingPartyRegistration.getAssertingPartyMetadata()) + .thenReturn(assertingPartyMetadata); + when(assertingPartyMetadata.getSingleSignOnServiceLocation()) + .thenReturn("https://example.com/sso"); + tokenStore.put(relayState, token); + + var result = jwtSaml2AuthenticationRequestRepository.loadAuthenticationRequest(request); + + assertNotNull(result); + assertFalse(tokenStore.containsKey(relayState)); + } + + @ParameterizedTest + @NullAndEmptySource + void loadAuthenticationRequestWithInvalidRelayState(String relayState) { + var request = mock(MockHttpServletRequest.class); + when(request.getParameter("RelayState")).thenReturn(relayState); + + var result = jwtSaml2AuthenticationRequestRepository.loadAuthenticationRequest(request); + + assertNull(result); + } + + @Test + void loadAuthenticationRequestWithNonExistentToken() { + var request = mock(MockHttpServletRequest.class); + when(request.getParameter("RelayState")).thenReturn("nonExistentRelayState"); + + var result = jwtSaml2AuthenticationRequestRepository.loadAuthenticationRequest(request); + + assertNull(result); + } + + @Test + void loadAuthenticationRequestWithNullRelyingPartyRegistration() { + var request = mock(MockHttpServletRequest.class); + String relayState = "testRelayState"; + String token = "testToken"; + Map claims = + Map.of( + "id", "testId", + "relyingPartyRegistrationId", "stirling-pdf", + "authenticationRequestUri", "example.com/authnRequest", + "samlRequest", "testSamlRequest", + "relayState", relayState); + + when(request.getParameter("RelayState")).thenReturn(relayState); + when(jwtService.extractClaims(token)).thenReturn(claims); + when(relyingPartyRegistrationRepository.findByRegistrationId("stirling-pdf")) + .thenReturn(null); + tokenStore.put(relayState, token); + + var result = jwtSaml2AuthenticationRequestRepository.loadAuthenticationRequest(request); + + assertNull(result); + } + + @Test + void removeAuthenticationRequest() { + var request = mock(HttpServletRequest.class); + var response = mock(HttpServletResponse.class); + var relyingPartyRegistration = mock(RelyingPartyRegistration.class); + var assertingPartyMetadata = mock(AssertingPartyMetadata.class); + String relayState = "testRelayState"; + String token = "testToken"; + Map claims = + Map.of( + "id", "testId", + "relyingPartyRegistrationId", "stirling-pdf", + "authenticationRequestUri", "example.com/authnRequest", + "samlRequest", "testSamlRequest", + "relayState", relayState); + + when(request.getParameter("RelayState")).thenReturn(relayState); + when(jwtService.extractClaims(token)).thenReturn(claims); + when(relyingPartyRegistrationRepository.findByRegistrationId("stirling-pdf")) + .thenReturn(relyingPartyRegistration); + when(relyingPartyRegistration.getRegistrationId()).thenReturn("stirling-pdf"); + when(relyingPartyRegistration.getAssertingPartyMetadata()) + .thenReturn(assertingPartyMetadata); + when(assertingPartyMetadata.getSingleSignOnServiceLocation()) + .thenReturn("https://example.com/sso"); + tokenStore.put(relayState, token); + + var result = + jwtSaml2AuthenticationRequestRepository.removeAuthenticationRequest( + request, response); + + assertNotNull(result); + assertFalse(tokenStore.containsKey(relayState)); + } + + @Test + void removeAuthenticationRequestWithNullRelayState() { + var request = mock(HttpServletRequest.class); + var response = mock(HttpServletResponse.class); + when(request.getParameter("RelayState")).thenReturn(null); + + var result = + jwtSaml2AuthenticationRequestRepository.removeAuthenticationRequest( + request, response); + + assertNull(result); + } + + @Test + void removeAuthenticationRequestWithNonExistentToken() { + var request = mock(HttpServletRequest.class); + var response = mock(HttpServletResponse.class); + when(request.getParameter("RelayState")).thenReturn("nonExistentRelayState"); + + var result = + jwtSaml2AuthenticationRequestRepository.removeAuthenticationRequest( + request, response); + + assertNull(result); + } + + @Test + void removeAuthenticationRequestWithOnlyRelayState() { + var request = mock(HttpServletRequest.class); + var response = mock(HttpServletResponse.class); + String relayState = "testRelayState"; + + when(request.getParameter("RelayState")).thenReturn(relayState); + + var result = + jwtSaml2AuthenticationRequestRepository.removeAuthenticationRequest( + request, response); + + assertNull(result); + assertFalse(tokenStore.containsKey(relayState)); + } +} 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 new file mode 100644 index 000000000..6f9af4c54 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java @@ -0,0 +1,389 @@ +package stirling.software.proprietary.security.service; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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; +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; +import java.util.Map; +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; + +import stirling.software.proprietary.security.model.JwtVerificationKey; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.model.exception.AuthenticationFailureException; + +@ExtendWith(MockitoExtension.class) +class JwtServiceTest { + + @Mock private Authentication authentication; + + @Mock private User userDetails; + + @Mock private HttpServletRequest request; + + @Mock private HttpServletResponse response; + + @Mock private KeyPersistenceServiceInterface keystoreService; + + private JwtService jwtService; + private KeyPair testKeyPair; + private JwtVerificationKey testVerificationKey; + + @BeforeEach + void setUp() throws NoSuchAlgorithmException { + // Generate a test keypair + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + testKeyPair = keyPairGenerator.generateKeyPair(); + + // Create test verification key + String encodedPublicKey = + Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()); + testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey); + + jwtService = new JwtService(true, keystoreService); + } + + @Test + void testGenerateTokenWithAuthentication() throws Exception { + String username = "testuser"; + + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + when(authentication.getPrincipal()).thenReturn(userDetails); + when(userDetails.getUsername()).thenReturn(username); + + String token = jwtService.generateToken(authentication, Collections.emptyMap()); + + assertNotNull(token); + assertFalse(token.isEmpty()); + assertEquals(username, jwtService.extractUsername(token)); + } + + @Test + void testGenerateTokenWithUsernameAndClaims() throws Exception { + String username = "testuser"; + Map claims = new HashMap<>(); + claims.put("role", "admin"); + claims.put("department", "IT"); + + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + when(authentication.getPrincipal()).thenReturn(userDetails); + when(userDetails.getUsername()).thenReturn(username); + + String token = jwtService.generateToken(authentication, claims); + + assertNotNull(token); + assertFalse(token.isEmpty()); + assertEquals(username, jwtService.extractUsername(token)); + + Map extractedClaims = jwtService.extractClaims(token); + assertEquals("admin", extractedClaims.get("role")); + assertEquals("IT", extractedClaims.get("department")); + } + + @Test + void testValidateTokenSuccess() throws Exception { + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + when(authentication.getPrincipal()).thenReturn(userDetails); + when(userDetails.getUsername()).thenReturn("testuser"); + + String token = jwtService.generateToken(authentication, new HashMap<>()); + + assertDoesNotThrow(() -> jwtService.validateToken(token)); + } + + @Test + void testValidateTokenWithInvalidToken() throws Exception { + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + + assertThrows( + AuthenticationFailureException.class, + () -> { + jwtService.validateToken("invalid-token"); + }); + } + + @Test + void testValidateTokenWithMalformedToken() throws Exception { + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + + AuthenticationFailureException exception = + assertThrows( + AuthenticationFailureException.class, + () -> { + jwtService.validateToken("malformed.token"); + }); + + assertTrue(exception.getMessage().contains("Invalid")); + } + + @Test + void testValidateTokenWithEmptyToken() throws Exception { + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + + AuthenticationFailureException exception = + assertThrows( + AuthenticationFailureException.class, + () -> { + jwtService.validateToken(""); + }); + + assertTrue( + exception.getMessage().contains("Claims are empty") + || exception.getMessage().contains("Invalid")); + } + + @Test + void testExtractUsername() throws Exception { + String username = "testuser"; + User user = mock(User.class); + Map claims = Map.of("sub", "testuser", "authType", "WEB"); + + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + when(authentication.getPrincipal()).thenReturn(user); + when(user.getUsername()).thenReturn(username); + + String token = jwtService.generateToken(authentication, claims); + + assertEquals(username, jwtService.extractUsername(token)); + } + + @Test + void testExtractUsernameWithInvalidToken() throws Exception { + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + + assertThrows( + AuthenticationFailureException.class, + () -> jwtService.extractUsername("invalid-token")); + } + + @Test + void testExtractClaims() throws Exception { + String username = "testuser"; + Map claims = Map.of("role", "admin", "department", "IT"); + + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + when(authentication.getPrincipal()).thenReturn(userDetails); + when(userDetails.getUsername()).thenReturn(username); + + String token = jwtService.generateToken(authentication, claims); + Map extractedClaims = jwtService.extractClaims(token); + + assertEquals("admin", extractedClaims.get("role")); + assertEquals("IT", extractedClaims.get("department")); + assertEquals(username, extractedClaims.get("sub")); + assertEquals("Stirling PDF", extractedClaims.get("iss")); + } + + @Test + void testExtractClaimsWithInvalidToken() throws Exception { + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + + assertThrows( + AuthenticationFailureException.class, + () -> jwtService.extractClaims("invalid-token")); + } + + @Test + void testExtractTokenWithCookie() { + String token = "test-token"; + Cookie[] cookies = {new Cookie("stirling_jwt", token)}; + when(request.getCookies()).thenReturn(cookies); + + assertEquals(token, jwtService.extractToken(request)); + } + + @Test + void testExtractTokenWithNoCookies() { + when(request.getCookies()).thenReturn(null); + + assertNull(jwtService.extractToken(request)); + } + + @Test + void testExtractTokenWithWrongCookie() { + Cookie[] cookies = {new Cookie("OTHER_COOKIE", "value")}; + when(request.getCookies()).thenReturn(cookies); + + 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"; + Map claims = new HashMap<>(); + + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); + when(authentication.getPrincipal()).thenReturn(userDetails); + when(userDetails.getUsername()).thenReturn(username); + + String token = jwtService.generateToken(authentication, claims); + + assertNotNull(token); + assertFalse(token.isEmpty()); + // Verify that the keystore service was called + verify(keystoreService).getActiveKey(); + verify(keystoreService).getKeyPair("test-key-id"); + } + + @Test + void testTokenVerificationWithSpecificKeyId() throws Exception { + String username = "testuser"; + Map claims = new HashMap<>(); + + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + when(authentication.getPrincipal()).thenReturn(userDetails); + when(userDetails.getUsername()).thenReturn(username); + + // Generate token with key ID + String token = jwtService.generateToken(authentication, claims); + + // Mock extraction of key ID and verification (lenient to avoid unused stubbing) + lenient() + .when(keystoreService.getKeyPair("test-key-id")) + .thenReturn(Optional.of(testKeyPair)); + + // Verify token can be validated + assertDoesNotThrow(() -> jwtService.validateToken(token)); + assertEquals(username, jwtService.extractUsername(token)); + } + + @Test + void testTokenVerificationFallsBackToActiveKeyWhenKeyIdNotFound() throws Exception { + String username = "testuser"; + Map claims = new HashMap<>(); + + // First, generate a token successfully + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + when(authentication.getPrincipal()).thenReturn(userDetails); + when(userDetails.getUsername()).thenReturn(username); + + String token = jwtService.generateToken(authentication, claims); + + // Now mock the scenario for validation - key not found, but fallback works + // Create a fallback key pair that can be used + JwtVerificationKey fallbackKey = + new JwtVerificationKey( + "fallback-key", + Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded())); + + // Mock the specific key lookup to fail, but the active key should work + when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.empty()); + when(keystoreService.refreshActiveKeyPair()).thenReturn(fallbackKey); + when(keystoreService.getKeyPair("fallback-key")).thenReturn(Optional.of(testKeyPair)); + + // Should still work by falling back to the active keypair + assertDoesNotThrow(() -> jwtService.validateToken(token)); + assertEquals(username, jwtService.extractUsername(token)); + + // 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/app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterfaceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterfaceTest.java new file mode 100644 index 000000000..33b971e5a --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterfaceTest.java @@ -0,0 +1,232 @@ +package stirling.software.proprietary.security.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +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.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cache.CacheManager; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; + +import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.model.JwtVerificationKey; + +@ExtendWith(MockitoExtension.class) +class KeyPersistenceServiceInterfaceTest { + + @Mock private ApplicationProperties applicationProperties; + + @Mock private ApplicationProperties.Security security; + + @Mock private ApplicationProperties.Security.Jwt jwtConfig; + + @TempDir Path tempDir; + + private KeyPersistenceService keyPersistenceService; + private KeyPair testKeyPair; + private CacheManager cacheManager; + + @BeforeEach + void setUp() throws NoSuchAlgorithmException { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + testKeyPair = keyPairGenerator.generateKeyPair(); + + cacheManager = new ConcurrentMapCacheManager("verifyingKeys"); + + lenient().when(applicationProperties.getSecurity()).thenReturn(security); + lenient().when(security.getJwt()).thenReturn(jwtConfig); + lenient().when(jwtConfig.isEnableKeystore()).thenReturn(true); // Default value + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testKeystoreEnabled(boolean keystoreEnabled) { + when(jwtConfig.isEnableKeystore()).thenReturn(keystoreEnabled); + + try (MockedStatic mockedStatic = + mockStatic(InstallationPathConfig.class)) { + mockedStatic + .when(InstallationPathConfig::getPrivateKeyPath) + .thenReturn(tempDir.toString()); + keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); + + assertEquals(keystoreEnabled, keyPersistenceService.isKeystoreEnabled()); + } + } + + @Test + void testGetActiveKeypairWhenNoActiveKeyExists() { + try (MockedStatic mockedStatic = + mockStatic(InstallationPathConfig.class)) { + mockedStatic + .when(InstallationPathConfig::getPrivateKeyPath) + .thenReturn(tempDir.toString()); + keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); + keyPersistenceService.initializeKeystore(); + + JwtVerificationKey result = keyPersistenceService.getActiveKey(); + + assertNotNull(result); + assertNotNull(result.getKeyId()); + assertNotNull(result.getVerifyingKey()); + } + } + + @Test + void testGetActiveKeyPairWithExistingKey() throws Exception { + String keyId = "test-key-2024-01-01-120000"; + String publicKeyBase64 = + Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()); + String privateKeyBase64 = + Base64.getEncoder().encodeToString(testKeyPair.getPrivate().getEncoded()); + + JwtVerificationKey existingKey = new JwtVerificationKey(keyId, publicKeyBase64); + + Path keyFile = tempDir.resolve(keyId + ".key"); + Files.writeString(keyFile, privateKeyBase64); + + try (MockedStatic mockedStatic = + mockStatic(InstallationPathConfig.class)) { + mockedStatic + .when(InstallationPathConfig::getPrivateKeyPath) + .thenReturn(tempDir.toString()); + keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); + keyPersistenceService.initializeKeystore(); + + JwtVerificationKey result = keyPersistenceService.getActiveKey(); + + assertNotNull(result); + assertNotNull(result.getKeyId()); + } + } + + @Test + void testGetKeyPair() throws Exception { + String keyId = "test-key-123"; + String publicKeyBase64 = + Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()); + String privateKeyBase64 = + Base64.getEncoder().encodeToString(testKeyPair.getPrivate().getEncoded()); + + JwtVerificationKey signingKey = new JwtVerificationKey(keyId, publicKeyBase64); + + Path keyFile = tempDir.resolve(keyId + ".key"); + Files.writeString(keyFile, privateKeyBase64); + + try (MockedStatic mockedStatic = + mockStatic(InstallationPathConfig.class)) { + mockedStatic + .when(InstallationPathConfig::getPrivateKeyPath) + .thenReturn(tempDir.toString()); + keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); + + keyPersistenceService + .getClass() + .getDeclaredField("verifyingKeyCache") + .setAccessible(true); + var cache = cacheManager.getCache("verifyingKeys"); + cache.put(keyId, signingKey); + + Optional result = keyPersistenceService.getKeyPair(keyId); + + assertTrue(result.isPresent()); + assertNotNull(result.get().getPublic()); + assertNotNull(result.get().getPrivate()); + } + } + + @Test + void testGetKeyPairNotFound() { + String keyId = "non-existent-key"; + + try (MockedStatic mockedStatic = + mockStatic(InstallationPathConfig.class)) { + mockedStatic + .when(InstallationPathConfig::getPrivateKeyPath) + .thenReturn(tempDir.toString()); + keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); + + Optional result = keyPersistenceService.getKeyPair(keyId); + + assertFalse(result.isPresent()); + } + } + + @Test + void testGetKeyPairWhenKeystoreDisabled() { + when(jwtConfig.isEnableKeystore()).thenReturn(false); + + try (MockedStatic mockedStatic = + mockStatic(InstallationPathConfig.class)) { + mockedStatic + .when(InstallationPathConfig::getPrivateKeyPath) + .thenReturn(tempDir.toString()); + keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); + + Optional result = keyPersistenceService.getKeyPair("any-key"); + + assertFalse(result.isPresent()); + } + } + + @Test + void testInitializeKeystoreCreatesDirectory() throws IOException { + try (MockedStatic mockedStatic = + mockStatic(InstallationPathConfig.class)) { + mockedStatic + .when(InstallationPathConfig::getPrivateKeyPath) + .thenReturn(tempDir.toString()); + keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); + keyPersistenceService.initializeKeystore(); + + assertTrue(Files.exists(tempDir)); + assertTrue(Files.isDirectory(tempDir)); + } + } + + @Test + void testLoadExistingKeypairWithMissingPrivateKeyFile() throws Exception { + String keyId = "test-key-missing-file"; + String publicKeyBase64 = + Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()); + + JwtVerificationKey existingKey = new JwtVerificationKey(keyId, publicKeyBase64); + + try (MockedStatic mockedStatic = + mockStatic(InstallationPathConfig.class)) { + mockedStatic + .when(InstallationPathConfig::getPrivateKeyPath) + .thenReturn(tempDir.toString()); + keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); + keyPersistenceService.initializeKeystore(); + + JwtVerificationKey result = keyPersistenceService.getActiveKey(); + assertNotNull(result); + assertNotNull(result.getKeyId()); + assertNotNull(result.getVerifyingKey()); + } + } +} diff --git a/exampleYmlFiles/test_cicd.yml b/exampleYmlFiles/test_cicd.yml index 31e24da48..086f862d5 100644 --- a/exampleYmlFiles/test_cicd.yml +++ b/exampleYmlFiles/test_cicd.yml @@ -20,6 +20,7 @@ services: environment: DISABLE_ADDITIONAL_FEATURES: "false" SECURITY_ENABLELOGIN: "true" + V2: "false" PUID: 1002 PGID: 1002 UMASK: "022" From 901218cdb2c6eadafa8426b526a5e1c68c37611e Mon Sep 17 00:00:00 2001 From: "stirlingbot[bot]" <195170888+stirlingbot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 12:29:51 +0100 Subject: [PATCH 03/41] :globe_with_meridians: Sync Translations + Update README Progress Table (#4174) ### Description of Changes This Pull Request was automatically generated to synchronize updates to translation files and documentation. Below are the details of the changes made: #### **1. Synchronization of Translation Files** - Updated translation files (`messages_*.properties`) to reflect changes in the reference file `messages_en_GB.properties`. - Ensured consistency and synchronization across all supported language files. - Highlighted any missing or incomplete translations. #### **2. Update README.md** - Generated the translation progress table in `README.md`. - Added a summary of the current translation status for all supported languages. - Included up-to-date statistics on translation coverage. #### **Why these changes are necessary** - Keeps translation files aligned with the latest reference updates. - Ensures the documentation reflects the current translation progress. --- Auto-generated by [create-pull-request][1]. [1]: https://github.com/peter-evans/create-pull-request Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> --- app/core/src/main/resources/messages_ar_AR.properties | 1 + app/core/src/main/resources/messages_az_AZ.properties | 1 + app/core/src/main/resources/messages_bg_BG.properties | 1 + app/core/src/main/resources/messages_bo_CN.properties | 1 + app/core/src/main/resources/messages_ca_CA.properties | 1 + app/core/src/main/resources/messages_cs_CZ.properties | 1 + app/core/src/main/resources/messages_da_DK.properties | 1 + app/core/src/main/resources/messages_de_DE.properties | 1 + app/core/src/main/resources/messages_el_GR.properties | 1 + app/core/src/main/resources/messages_en_US.properties | 1 + app/core/src/main/resources/messages_es_ES.properties | 1 + app/core/src/main/resources/messages_eu_ES.properties | 1 + app/core/src/main/resources/messages_fa_IR.properties | 1 + app/core/src/main/resources/messages_fr_FR.properties | 1 + app/core/src/main/resources/messages_ga_IE.properties | 1 + app/core/src/main/resources/messages_hi_IN.properties | 1 + app/core/src/main/resources/messages_hr_HR.properties | 1 + app/core/src/main/resources/messages_hu_HU.properties | 1 + app/core/src/main/resources/messages_id_ID.properties | 1 + app/core/src/main/resources/messages_it_IT.properties | 1 + app/core/src/main/resources/messages_ja_JP.properties | 1 + app/core/src/main/resources/messages_ko_KR.properties | 1 + app/core/src/main/resources/messages_ml_IN.properties | 1 + app/core/src/main/resources/messages_nl_NL.properties | 1 + app/core/src/main/resources/messages_no_NB.properties | 1 + app/core/src/main/resources/messages_pl_PL.properties | 1 + app/core/src/main/resources/messages_pt_BR.properties | 1 + app/core/src/main/resources/messages_pt_PT.properties | 1 + app/core/src/main/resources/messages_ro_RO.properties | 1 + app/core/src/main/resources/messages_ru_RU.properties | 1 + app/core/src/main/resources/messages_sk_SK.properties | 1 + app/core/src/main/resources/messages_sl_SI.properties | 1 + app/core/src/main/resources/messages_sr_LATN_RS.properties | 1 + app/core/src/main/resources/messages_sv_SE.properties | 1 + app/core/src/main/resources/messages_th_TH.properties | 1 + app/core/src/main/resources/messages_tr_TR.properties | 1 + app/core/src/main/resources/messages_uk_UA.properties | 1 + app/core/src/main/resources/messages_vi_VN.properties | 1 + app/core/src/main/resources/messages_zh_CN.properties | 1 + app/core/src/main/resources/messages_zh_TW.properties | 1 + 40 files changed, 40 insertions(+) diff --git a/app/core/src/main/resources/messages_ar_AR.properties b/app/core/src/main/resources/messages_ar_AR.properties index 1cd554cd1..ed0bc1228 100644 --- a/app/core/src/main/resources/messages_ar_AR.properties +++ b/app/core/src/main/resources/messages_ar_AR.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=لقد تسجل دخولًا إلى login.alreadyLoggedIn2=أجهزة أخرى. يرجى تسجيل الخروج من الأجهزة وحاول مرة أخرى. login.toManySessions=لديك عدة جلسات نشطة login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=حجب تلقائي diff --git a/app/core/src/main/resources/messages_az_AZ.properties b/app/core/src/main/resources/messages_az_AZ.properties index 2304a13d1..f0e3f5ea9 100644 --- a/app/core/src/main/resources/messages_az_AZ.properties +++ b/app/core/src/main/resources/messages_az_AZ.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Siz artıq daxil olmusunuz login.alreadyLoggedIn2=cihazlar. Zəhmət olmasa, cihazlardan çıxış edin və yenidən cəhd edin. login.toManySessions=Həddindən artıq aktiv sessiyanız var login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Avtomatik Gizlətmə diff --git a/app/core/src/main/resources/messages_bg_BG.properties b/app/core/src/main/resources/messages_bg_BG.properties index a99e9447e..d7964e792 100644 --- a/app/core/src/main/resources/messages_bg_BG.properties +++ b/app/core/src/main/resources/messages_bg_BG.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Вече сте влезли в login.alreadyLoggedIn2=устройства. Моля, излезте от устройствата и опитайте отново. login.toManySessions=Имате твърде много активни сесии login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Автоматично редактиране diff --git a/app/core/src/main/resources/messages_bo_CN.properties b/app/core/src/main/resources/messages_bo_CN.properties index aef66f128..32df39257 100644 --- a/app/core/src/main/resources/messages_bo_CN.properties +++ b/app/core/src/main/resources/messages_bo_CN.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=ཁྱེད་རང་ login.alreadyLoggedIn2=སྒྲིག་ཆས་ནང་ནང་འཛུལ་བྱས་ཟིན། སྒྲིག་ཆས་ནས་ཕྱིར་འཐེན་བྱས་ནས་ཡང་བསྐྱར་ཚོད་ལྟ་བྱེད་རོགས། login.toManySessions=ཁྱེད་ལ་འཛུལ་ཞུགས་བྱས་པའི་གནས་སྐབས་མང་དྲགས་འདུག login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=རང་འགུལ་སྒྲིབ་སྲུང་། diff --git a/app/core/src/main/resources/messages_ca_CA.properties b/app/core/src/main/resources/messages_ca_CA.properties index ff7f2b64b..dda522bdd 100644 --- a/app/core/src/main/resources/messages_ca_CA.properties +++ b/app/core/src/main/resources/messages_ca_CA.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Ja has iniciat sessió a login.alreadyLoggedIn2=dispositius. Si us plau, tanca la sessió en els dispositius i torna-ho a intentar. login.toManySessions=Tens massa sessions actives login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Redacció Automàtica diff --git a/app/core/src/main/resources/messages_cs_CZ.properties b/app/core/src/main/resources/messages_cs_CZ.properties index a68fbcb78..7ce4b77a2 100644 --- a/app/core/src/main/resources/messages_cs_CZ.properties +++ b/app/core/src/main/resources/messages_cs_CZ.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Již jste přihlášeni na login.alreadyLoggedIn2=zařízeních. Odhlaste se prosím z těchto zařízení a zkuste to znovu. login.toManySessions=Máte příliš mnoho aktivních relací login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Automatické začernění diff --git a/app/core/src/main/resources/messages_da_DK.properties b/app/core/src/main/resources/messages_da_DK.properties index 8d55cc8d1..b82f1d761 100644 --- a/app/core/src/main/resources/messages_da_DK.properties +++ b/app/core/src/main/resources/messages_da_DK.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Du er allerede logget ind på login.alreadyLoggedIn2=enheder. Log ud af disse enheder og prøv igen. login.toManySessions=Du har for mange aktive sessoner login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Auto Rediger diff --git a/app/core/src/main/resources/messages_de_DE.properties b/app/core/src/main/resources/messages_de_DE.properties index 63b54fa74..db91b8dc7 100644 --- a/app/core/src/main/resources/messages_de_DE.properties +++ b/app/core/src/main/resources/messages_de_DE.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Sie sind bereits an login.alreadyLoggedIn2=Geräten angemeldet. Bitte melden Sie sich dort ab und versuchen es dann erneut. login.toManySessions=Sie haben zu viele aktive Sitzungen login.logoutMessage=Sie wurden erfolgreich abgemeldet. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Automatisch zensieren/schwärzen diff --git a/app/core/src/main/resources/messages_el_GR.properties b/app/core/src/main/resources/messages_el_GR.properties index a9fbee538..7f59f217e 100644 --- a/app/core/src/main/resources/messages_el_GR.properties +++ b/app/core/src/main/resources/messages_el_GR.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Είστε ήδη συνδεδεμένοι σε login.alreadyLoggedIn2=συσκευές. Παρακαλώ αποσυνδεθείτε από τις συσκευές και προσπαθήστε ξανά. login.toManySessions=Έχετε πάρα πολλές ενεργές συνεδρίες login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Αυτόματη απόκρυψη diff --git a/app/core/src/main/resources/messages_en_US.properties b/app/core/src/main/resources/messages_en_US.properties index 250dd51c5..8ccbd7c99 100644 --- a/app/core/src/main/resources/messages_en_US.properties +++ b/app/core/src/main/resources/messages_en_US.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=You are already logged in to login.alreadyLoggedIn2=devices. Please log out of the devices and try again. login.toManySessions=You have too many active sessions login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Auto Redact diff --git a/app/core/src/main/resources/messages_es_ES.properties b/app/core/src/main/resources/messages_es_ES.properties index 4ccb6d758..ae63d5107 100644 --- a/app/core/src/main/resources/messages_es_ES.properties +++ b/app/core/src/main/resources/messages_es_ES.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Ya ha iniciado sesión en login.alreadyLoggedIn2=dispositivos. Cierre sesión en los dispositivos y vuelva a intentarlo. login.toManySessions=Tiene demasiadas sesiones activas login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Auto Censurar Texto diff --git a/app/core/src/main/resources/messages_eu_ES.properties b/app/core/src/main/resources/messages_eu_ES.properties index 27dbfdb08..17bd70a93 100644 --- a/app/core/src/main/resources/messages_eu_ES.properties +++ b/app/core/src/main/resources/messages_eu_ES.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=You are already logged in to login.alreadyLoggedIn2=devices. Please log out of the devices and try again. login.toManySessions=You have too many active sessions login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Auto Idatzi diff --git a/app/core/src/main/resources/messages_fa_IR.properties b/app/core/src/main/resources/messages_fa_IR.properties index dccb7fc0b..a3b7cfec3 100644 --- a/app/core/src/main/resources/messages_fa_IR.properties +++ b/app/core/src/main/resources/messages_fa_IR.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=شما قبلاً وارد شده‌اید در login.alreadyLoggedIn2=دستگاه‌ها. لطفاً از دستگاه‌ها خارج شده و دوباره تلاش کنید. login.toManySessions=شما تعداد زیادی نشست فعال دارید. login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=سانسور خودکار diff --git a/app/core/src/main/resources/messages_fr_FR.properties b/app/core/src/main/resources/messages_fr_FR.properties index 86e6c0d95..b9db7ff5c 100644 --- a/app/core/src/main/resources/messages_fr_FR.properties +++ b/app/core/src/main/resources/messages_fr_FR.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Vous êtes déjà connecté sur login.alreadyLoggedIn2=appareils. Veuillez vous déconnecter des appareils et réessayer. login.toManySessions=Vous avez trop de sessions actives. login.logoutMessage=Vous avez été déconnecté. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Caviarder automatiquement diff --git a/app/core/src/main/resources/messages_ga_IE.properties b/app/core/src/main/resources/messages_ga_IE.properties index 816932ff1..b0363acb4 100644 --- a/app/core/src/main/resources/messages_ga_IE.properties +++ b/app/core/src/main/resources/messages_ga_IE.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Tá tú logáilte isteach cheana login.alreadyLoggedIn2=gléasanna. Logáil amach as na gléasanna agus bain triail eile as. login.toManySessions=Tá an iomarca seisiún gníomhach agat login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Auto Redact diff --git a/app/core/src/main/resources/messages_hi_IN.properties b/app/core/src/main/resources/messages_hi_IN.properties index e2f9b2c19..32885740c 100644 --- a/app/core/src/main/resources/messages_hi_IN.properties +++ b/app/core/src/main/resources/messages_hi_IN.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=आप पहले से ही login.alreadyLoggedIn2=उपकरणों में लॉग इन हैं। कृपया उपकरणों से लॉग आउट करें और पुनः प्रयास करें। login.toManySessions=आपके बहुत सारे सक्रिय सत्र हैं login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=स्वतः गोपनीयकरण diff --git a/app/core/src/main/resources/messages_hr_HR.properties b/app/core/src/main/resources/messages_hr_HR.properties index 7ea02b909..cb06aba43 100644 --- a/app/core/src/main/resources/messages_hr_HR.properties +++ b/app/core/src/main/resources/messages_hr_HR.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Već ste se prijavili na login.alreadyLoggedIn2=ure. Odjavite se s ure i pokušajte ponovo. login.toManySessions=Imate preko mrežne sesije aktivnih login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Automatsko uređivanje diff --git a/app/core/src/main/resources/messages_hu_HU.properties b/app/core/src/main/resources/messages_hu_HU.properties index c5488bc2b..7845c3fce 100644 --- a/app/core/src/main/resources/messages_hu_HU.properties +++ b/app/core/src/main/resources/messages_hu_HU.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Már be van jelentkezve login.alreadyLoggedIn2=eszközön. Kérjük, jelentkezzen ki az eszközökről és próbálja újra. login.toManySessions=Túl sok aktív munkamenet login.logoutMessage=Sikeresen kijelentkezett. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Automatikus kitakarás diff --git a/app/core/src/main/resources/messages_id_ID.properties b/app/core/src/main/resources/messages_id_ID.properties index 541226f69..d06da87ab 100644 --- a/app/core/src/main/resources/messages_id_ID.properties +++ b/app/core/src/main/resources/messages_id_ID.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Anda sudah login ke login.alreadyLoggedIn2=perangkat. Silakan keluar dari perangkat dan coba lagi. login.toManySessions=Anda memiliki terlalu banyak sesi aktif login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Redaksional Otomatis diff --git a/app/core/src/main/resources/messages_it_IT.properties b/app/core/src/main/resources/messages_it_IT.properties index 74952b670..26d492be3 100644 --- a/app/core/src/main/resources/messages_it_IT.properties +++ b/app/core/src/main/resources/messages_it_IT.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Hai già effettuato l'accesso a login.alreadyLoggedIn2=dispositivi. Esci dai dispositivi e riprova. login.toManySessions=Hai troppe sessioni attive login.logoutMessage=Sei stato disconnesso. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Redazione automatica diff --git a/app/core/src/main/resources/messages_ja_JP.properties b/app/core/src/main/resources/messages_ja_JP.properties index a5af895fd..f0c987c9d 100644 --- a/app/core/src/main/resources/messages_ja_JP.properties +++ b/app/core/src/main/resources/messages_ja_JP.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=すでにログインしています login.alreadyLoggedIn2=デバイスからログアウトしてもう一度お試しください。 login.toManySessions=アクティブなセッションが多すぎます login.logoutMessage=ログアウトしました +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=自動墨消し diff --git a/app/core/src/main/resources/messages_ko_KR.properties b/app/core/src/main/resources/messages_ko_KR.properties index 7de79d52c..77517a000 100644 --- a/app/core/src/main/resources/messages_ko_KR.properties +++ b/app/core/src/main/resources/messages_ko_KR.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=이미 다음에 로그인되어 있습니다 login.alreadyLoggedIn2=개의 기기. 해당 기기에서 로그아웃한 후 다시 시도하세요. login.toManySessions=활성 세션이 너무 많습니다 login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=자동 검열 diff --git a/app/core/src/main/resources/messages_ml_IN.properties b/app/core/src/main/resources/messages_ml_IN.properties index 123f5a53f..356e5f99b 100644 --- a/app/core/src/main/resources/messages_ml_IN.properties +++ b/app/core/src/main/resources/messages_ml_IN.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=നിങ്ങൾ ഇതിനകം ലോഗിൻ ച login.alreadyLoggedIn2=ഉപകരണങ്ങളിൽ. ദയവായി ഉപകരണങ്ങളിൽ നിന്ന് ലോഗ് ഔട്ട് ചെയ്ത് വീണ്ടും ശ്രമിക്കുക. login.toManySessions=നിങ്ങൾക്ക് വളരെയധികം സജീവ സെഷനുകൾ ഉണ്ട് login.logoutMessage=നിങ്ങൾ ലോഗ് ഔട്ട് ചെയ്തു. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=സ്വയം റെഡാക്റ്റ് ചെയ്യുക diff --git a/app/core/src/main/resources/messages_nl_NL.properties b/app/core/src/main/resources/messages_nl_NL.properties index 44418eb0f..f7aa1e805 100644 --- a/app/core/src/main/resources/messages_nl_NL.properties +++ b/app/core/src/main/resources/messages_nl_NL.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=U zit reeds ingelogd bij login.alreadyLoggedIn2=apparaten. U moet u a.u.b. uitloggen van de apparaten en opnieuw proberen. login.toManySessions=U heeft te veel actieve sessies login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Automatisch censureren diff --git a/app/core/src/main/resources/messages_no_NB.properties b/app/core/src/main/resources/messages_no_NB.properties index ed830ec3f..ae9091cf5 100644 --- a/app/core/src/main/resources/messages_no_NB.properties +++ b/app/core/src/main/resources/messages_no_NB.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Du er allerede innlogget på login.alreadyLoggedIn2=enheter. Logg ut og forsøk igjen login.toManySessions=Du har for mange aktive økter login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Automatisk Sensurering diff --git a/app/core/src/main/resources/messages_pl_PL.properties b/app/core/src/main/resources/messages_pl_PL.properties index 0eefb4ccc..9c5dc670e 100644 --- a/app/core/src/main/resources/messages_pl_PL.properties +++ b/app/core/src/main/resources/messages_pl_PL.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Jesteś już zalogowany na login.alreadyLoggedIn2=urządzeniach. Wyloguj się z tych urządzeń i spróbuj ponownie. login.toManySessions=Masz zbyt wiele aktywnych sesji login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Automatyczne zaciemnienie diff --git a/app/core/src/main/resources/messages_pt_BR.properties b/app/core/src/main/resources/messages_pt_BR.properties index 57e8dd93e..bf2cb6a17 100644 --- a/app/core/src/main/resources/messages_pt_BR.properties +++ b/app/core/src/main/resources/messages_pt_BR.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Você já está conectado em login.alreadyLoggedIn2=aparelhos. Por favor saia dos aparelhos e tente novamente. login.toManySessions=Você tem muitas sessões ativas login.logoutMessage=Você foi desconectado. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Ocultação de Texto Automática diff --git a/app/core/src/main/resources/messages_pt_PT.properties b/app/core/src/main/resources/messages_pt_PT.properties index 2c78fa93b..7b73092f1 100644 --- a/app/core/src/main/resources/messages_pt_PT.properties +++ b/app/core/src/main/resources/messages_pt_PT.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Já tem sessão iniciada em login.alreadyLoggedIn2=dispositivos. Por favor termine sessão nesses dispositivos e tente novamente. login.toManySessions=Tem demasiadas sessões ativas login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Redação Automática diff --git a/app/core/src/main/resources/messages_ro_RO.properties b/app/core/src/main/resources/messages_ro_RO.properties index 5a904a9c8..07fee9b86 100644 --- a/app/core/src/main/resources/messages_ro_RO.properties +++ b/app/core/src/main/resources/messages_ro_RO.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=You are already logged in to login.alreadyLoggedIn2=devices. Please log out of the devices and try again. login.toManySessions=You have too many active sessions login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Redactare Automată diff --git a/app/core/src/main/resources/messages_ru_RU.properties b/app/core/src/main/resources/messages_ru_RU.properties index 4580f3933..14dd4121a 100644 --- a/app/core/src/main/resources/messages_ru_RU.properties +++ b/app/core/src/main/resources/messages_ru_RU.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Вы уже вошли в login.alreadyLoggedIn2=устройств(а). Пожалуйста, выйдите из этих устройств и попробуйте снова. login.toManySessions=У вас слишком много активных сессий login.logoutMessage=Вы вышли из системы. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Автоматическое редактирование diff --git a/app/core/src/main/resources/messages_sk_SK.properties b/app/core/src/main/resources/messages_sk_SK.properties index 68faeab85..4b84511f5 100644 --- a/app/core/src/main/resources/messages_sk_SK.properties +++ b/app/core/src/main/resources/messages_sk_SK.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=You are already logged in to login.alreadyLoggedIn2=devices. Please log out of the devices and try again. login.toManySessions=You have too many active sessions login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Automatické redigovanie diff --git a/app/core/src/main/resources/messages_sl_SI.properties b/app/core/src/main/resources/messages_sl_SI.properties index fe95a4165..72987dfcd 100644 --- a/app/core/src/main/resources/messages_sl_SI.properties +++ b/app/core/src/main/resources/messages_sl_SI.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Prijavljeni ste že v login.alreadyLoggedIn2=naprave. Odjavite se iz naprav in poskusite znova. login.toManySessions=Imate preveč aktivnih sej login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Samodejno redigiraj diff --git a/app/core/src/main/resources/messages_sr_LATN_RS.properties b/app/core/src/main/resources/messages_sr_LATN_RS.properties index f15d8397a..4a6e987ca 100644 --- a/app/core/src/main/resources/messages_sr_LATN_RS.properties +++ b/app/core/src/main/resources/messages_sr_LATN_RS.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Već si prijavljen na login.alreadyLoggedIn2=uređaja. Odjavi se sa uređaja i pokušaj ponovo. login.toManySessions=Imaš previše aktivnih sesija login.logoutMessage=Odjavljen si. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Automatsko cenzurisanje diff --git a/app/core/src/main/resources/messages_sv_SE.properties b/app/core/src/main/resources/messages_sv_SE.properties index 7a786add6..0182c8f98 100644 --- a/app/core/src/main/resources/messages_sv_SE.properties +++ b/app/core/src/main/resources/messages_sv_SE.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Du är redan inloggad på login.alreadyLoggedIn2=enheter. Logga ut från enheterna och försök igen. login.toManySessions=Du har för många aktiva sessioner login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Auto-redigera diff --git a/app/core/src/main/resources/messages_th_TH.properties b/app/core/src/main/resources/messages_th_TH.properties index 9b332982c..a0473bdef 100644 --- a/app/core/src/main/resources/messages_th_TH.properties +++ b/app/core/src/main/resources/messages_th_TH.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=คุณได้เข้าสู่ระบบใน login.alreadyLoggedIn2=อุปกรณ์แล้ว กรุณาออกจากระบบจากอุปกรณ์ที่ใช้งานอยู่แล้ว จากนั้นลองใหม่อีกครั้ง login.toManySessions=คุณมีการเข้าสู่ระบบพร้อมกันเกินกว่ากำหนด login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=ซ่อนข้อมูลอัตโนมัติ diff --git a/app/core/src/main/resources/messages_tr_TR.properties b/app/core/src/main/resources/messages_tr_TR.properties index 72e78f1b3..155b4365d 100644 --- a/app/core/src/main/resources/messages_tr_TR.properties +++ b/app/core/src/main/resources/messages_tr_TR.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Zaten şu cihazlarda oturum açılmış: login.alreadyLoggedIn2=Lütfen bu cihazlardan çıkış yaparak tekrar deneyin. login.toManySessions=Çok fazla aktif oturumunuz var login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Otomatik Karartma diff --git a/app/core/src/main/resources/messages_uk_UA.properties b/app/core/src/main/resources/messages_uk_UA.properties index db5739fe3..cf0cc7115 100644 --- a/app/core/src/main/resources/messages_uk_UA.properties +++ b/app/core/src/main/resources/messages_uk_UA.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Ви вже увійшли до login.alreadyLoggedIn2=пристроїв (а). Будь ласка, вийдіть із цих пристроїв і спробуйте знову. login.toManySessions=У вас дуже багато активних сесій login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Автоматичне редагування diff --git a/app/core/src/main/resources/messages_vi_VN.properties b/app/core/src/main/resources/messages_vi_VN.properties index 0a1e9b392..ba7ba416b 100644 --- a/app/core/src/main/resources/messages_vi_VN.properties +++ b/app/core/src/main/resources/messages_vi_VN.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=You are already logged in to login.alreadyLoggedIn2=devices. Please log out of the devices and try again. login.toManySessions=You have too many active sessions login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Tự động biên tập diff --git a/app/core/src/main/resources/messages_zh_CN.properties b/app/core/src/main/resources/messages_zh_CN.properties index 4eeac6483..80abcef7a 100644 --- a/app/core/src/main/resources/messages_zh_CN.properties +++ b/app/core/src/main/resources/messages_zh_CN.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=您已经登录到了 login.alreadyLoggedIn2=设备,请注销设备后重试。 login.toManySessions=你已经有太多的会话了。请注销一些设备后重试。 login.logoutMessage=您已退出登录。 +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=自动删除 diff --git a/app/core/src/main/resources/messages_zh_TW.properties b/app/core/src/main/resources/messages_zh_TW.properties index cee6b9c7d..c2cf4518c 100644 --- a/app/core/src/main/resources/messages_zh_TW.properties +++ b/app/core/src/main/resources/messages_zh_TW.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=您已經登入了 login.alreadyLoggedIn2=部裝置。請先從這些裝置登出後再試一次。 login.toManySessions=您有太多使用中的工作階段 login.logoutMessage=您已登出。 +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=自動塗黑 From bb07eced6ebaff67c6206d2afdf29018023f2c4f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:08:52 +0100 Subject: [PATCH 04/41] build(deps): bump gradle/actions from 4.4.1 to 4.4.2 (#4177) Bumps [gradle/actions](https://github.com/gradle/actions) from 4.4.1 to 4.4.2.
Release notes

Sourced from gradle/actions's releases.

v4.4.2

This patch release updates a bunch of dependency versions

What's Changed

  • Bump github/codeql-action from 3.29.4 to 3.29.5 in the github-actions group across 1 directory (gradle/actions#703)
  • Bumps the npm-dependencies group in /sources with 4 updates (gradle/actions#702)
  • Upgrade to gradle 9 in workflows and tests (gradle/actions#704)
  • Update known wrapper checksums (gradle/actions#701)
  • Bump Gradle Wrapper from 8.14.3 to 9.0.0 in /.github/workflow-samples/gradle-plugin (gradle/actions#695)
  • Bump Gradle Wrapper from 8.14.3 to 9.0.0 in /.github/workflow-samples/groovy-dsl (gradle/actions#696)
  • Bump Gradle Wrapper from 8.14.3 to 9.0.0 in /.github/workflow-samples/java-toolchain (gradle/actions#697)
  • Bump com.fasterxml.jackson.dataformat:jackson-dataformat-smile from 2.19.1 to 2.19.2 in /sources/test/init-scripts in the gradle group across 1 directory (gradle/actions#693)
  • Bump github/codeql-action from 3.29.0 to 3.29.4 in the github-actions group across 1 directory (gradle/actions#691)
  • Bump the npm-dependencies group in /sources with 5 updates (gradle/actions#692)
  • Bump references to Develocity Gradle plugin from 4.0.2 to 4.1 (gradle/actions#685)
  • Bump the npm-dependencies group across 1 directory with 8 updates (gradle/actions#684)
  • Run Gradle release candidate tests with JDK 17 (gradle/actions#690)
  • Update Develocity npm agent to version 1.0.1 (gradle/actions#687)
  • Update known wrapper checksums (gradle/actions#688)
  • Bump Gradle Wrapper from 8.14.2 to 8.14.3 in /.github/workflow-samples/kotlin-dsl (gradle/actions#683
  • Bump the github-actions group across 1 directory with 3 updates (gradle/actions#675)
  • Bump the gradle group across 3 directories with 2 updates (gradle/actions#674)
  • Bump Gradle Wrapper from 8.14.2 to 8.14.3 in /sources/test/init-scripts (gradle/actions#679)
  • Bump Gradle Wrapper from 8.14.2 to 8.14.3 in /.github/workflow-samples/java-toolchain (gradle/actions#682)
  • Bump Gradle Wrapper from 8.14.2 to 8.14.3 in /.github/workflow-samples/groovy-dsl (gradle/actions#681)
  • Bump Gradle Wrapper from 8.14.2 to 8.14.3 in /.github/workflow-samples/gradle-plugin (gradle/actions#680)
  • Update known wrapper checksums (gradle/actions#676)

Full Changelog: https://github.com/gradle/actions/compare/v4.4.1...v4.4.2

Commits
  • 017a9ef Bump github/codeql-action from 3.29.4 to 3.29.5 in the github-actions group a...
  • d5397cf Merge branch 'main' into dependabot/github_actions/github-actions-12d2e1d0cf
  • 559dfbd Bump the npm-dependencies group in /sources with 4 updates (#702)
  • 075ee28 Merge branch 'main' into dependabot/npm_and_yarn/sources/npm-dependencies-fda...
  • c3e68c5 Upgrade to gradle 9 in workflows and tests (#704)
  • d7e674f Fix init script tests dependencies
  • 3e65128 Upgrade init script tests to Gradle 9
  • 896b9fa Run tests on Gradle release candidate and current with JDK 17 as required sin...
  • 431b3e3 Bump github/codeql-action in the github-actions group across 1 directory
  • 44c3664 Bump the npm-dependencies group in /sources with 4 updates
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=gradle/actions&package-manager=github_actions&previous-version=4.4.1&new-version=4.4.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 6 +++--- .github/workflows/licenses-update.yml | 2 +- .github/workflows/multiOSReleases.yml | 4 ++-- .github/workflows/push-docker.yml | 2 +- .github/workflows/releaseArtifacts.yml | 2 +- .github/workflows/sonarqube.yml | 2 +- .github/workflows/swagger.yml | 2 +- .github/workflows/testdriver.yml | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c229ee40e..b6c5237c2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -70,7 +70,7 @@ jobs: distribution: "temurin" - name: Setup Gradle - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 with: gradle-version: 8.14 @@ -143,7 +143,7 @@ jobs: distribution: "temurin" - name: Setup Gradle - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 - name: Generate OpenAPI documentation run: ./gradlew :stirling-pdf:generateOpenApiDocs @@ -271,7 +271,7 @@ jobs: distribution: "temurin" - name: Set up Gradle - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 with: gradle-version: 8.14 diff --git a/.github/workflows/licenses-update.yml b/.github/workflows/licenses-update.yml index 49486c4d5..4db087539 100644 --- a/.github/workflows/licenses-update.yml +++ b/.github/workflows/licenses-update.yml @@ -54,7 +54,7 @@ jobs: distribution: "temurin" - name: Setup Gradle - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 - name: Check licenses for compatibility run: ./gradlew clean checkLicense diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index b55c7d402..d705ced09 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -72,7 +72,7 @@ jobs: java-version: "21" distribution: "temurin" - - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 with: gradle-version: 8.14 @@ -160,7 +160,7 @@ jobs: java-version: "21" distribution: "temurin" - - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 with: gradle-version: 8.14 diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index 2a04ba33e..a766f5d5b 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -42,7 +42,7 @@ jobs: java-version: "17" distribution: "temurin" - - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 with: gradle-version: 8.14 diff --git a/.github/workflows/releaseArtifacts.yml b/.github/workflows/releaseArtifacts.yml index ba970e885..3dd8bb5ea 100644 --- a/.github/workflows/releaseArtifacts.yml +++ b/.github/workflows/releaseArtifacts.yml @@ -35,7 +35,7 @@ jobs: java-version: "17" distribution: "temurin" - - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 with: gradle-version: 8.14 diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 71f01438c..0ea32af59 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -39,7 +39,7 @@ jobs: fetch-depth: 0 - name: Setup Gradle - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 - name: Build and analyze with Gradle env: diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 85a7f10f1..74d3ec471 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -38,7 +38,7 @@ jobs: java-version: "17" distribution: "temurin" - - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 - name: Generate Swagger documentation run: ./gradlew :stirling-pdf:generateOpenApiDocs diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml index b5759ed54..5841879b4 100644 --- a/.github/workflows/testdriver.yml +++ b/.github/workflows/testdriver.yml @@ -38,7 +38,7 @@ jobs: distribution: 'temurin' - name: Setup Gradle - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 with: gradle-version: 8.14 From 84142bb42ad1165ca8ab2f414d29cb74343437b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:09:09 +0100 Subject: [PATCH 05/41] build(deps): bump github/codeql-action from 3.29.7 to 3.29.8 (#4178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [//]: # (dependabot-start) ⚠️ **Dependabot is rebasing this PR** ⚠️ Rebasing might not happen immediately, so don't worry if this takes some time. Note: if you make any changes to this PR yourself, they will take precedence over the rebase. --- [//]: # (dependabot-end) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.29.7 to 3.29.8.
Release notes

Sourced from github/codeql-action's releases.

v3.29.8

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

3.29.8 - 08 Aug 2025

  • Fix an issue where the Action would autodetect unsupported languages such as HTML. #3015

See the full CHANGELOG.md for more information.

Changelog

Sourced from github/codeql-action's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

3.29.8 - 08 Aug 2025

  • Fix an issue where the Action would autodetect unsupported languages such as HTML. #3015

3.29.7 - 07 Aug 2025

This release rolls back 3.29.6 to address issues with language autodetection. It is identical to 3.29.5.

3.29.6 - 07 Aug 2025

  • The cleanup-level input to the analyze Action is now deprecated. The CodeQL Action has written a limited amount of intermediate results to the database since version 2.2.5, and now automatically manages cleanup. #2999
  • Update default CodeQL bundle version to 2.22.3. #3000

3.29.5 - 29 Jul 2025

  • Update default CodeQL bundle version to 2.22.2. #2986

3.29.4 - 23 Jul 2025

No user facing changes.

3.29.3 - 21 Jul 2025

No user facing changes.

3.29.2 - 30 Jun 2025

  • Experimental: When the quality-queries input for the init action is provided with an argument, separate .quality.sarif files are produced and uploaded for each language with the results of the specified queries. Do not use this in production as it is part of an internal experiment and subject to change at any time. #2935

3.29.1 - 27 Jun 2025

  • Fix bug in PR analysis where user-provided include query filter fails to exclude non-included queries. #2938
  • Update default CodeQL bundle version to 2.22.1. #2950

3.29.0 - 11 Jun 2025

  • Update default CodeQL bundle version to 2.22.0. #2925
  • Bump minimum CodeQL bundle version to 2.16.6. #2912

3.28.21 - 28 July 2025

No user facing changes.

... (truncated)

Commits
  • 76621b6 Merge pull request #3019 from github/update-v3.29.8-679a40d33
  • 29ac3ce Add release notes for 3.29.7
  • 737cfde Update changelog for v3.29.8
  • 679a40d Merge pull request #3014 from github/henrymercer/rebuild-dispatch
  • 6fe50b2 Merge pull request #3015 from github/henrymercer/language-autodetection-worka...
  • 6bc91d6 Add changelog note
  • 6b4fedc Bump Action patch version
  • 5794ffc Fix auto-detection of extractors that aren't languages
  • bd62bf4 Finish in-progress merges
  • 2afb4e6 Avoid specifying branch unnecessarily
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action&package-manager=github_actions&previous-version=3.29.7&new-version=3.29.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/scorecards.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 47fae4f83..4922cac2a 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -74,6 +74,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5 + uses: github/codeql-action/upload-sarif@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.5 with: sarif_file: results.sarif From 2c293d2231ae0fdf9e895376857a737170327a2c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:09:19 +0100 Subject: [PATCH 06/41] build(deps): bump actions/download-artifact from 4.3.0 to 5.0.0 (#4179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.3.0 to 5.0.0.
Release notes

Sourced from actions/download-artifact's releases.

v5.0.0

What's Changed

v5.0.0

🚨 Breaking Change

This release fixes an inconsistency in path behavior for single artifact downloads by ID. If you're downloading single artifacts by ID, the output path may change.

What Changed

Previously, single artifact downloads behaved differently depending on how you specified the artifact:

  • By name: name: my-artifact → extracted to path/ (direct)
  • By ID: artifact-ids: 12345 → extracted to path/my-artifact/ (nested)

Now both methods are consistent:

  • By name: name: my-artifact → extracted to path/ (unchanged)
  • By ID: artifact-ids: 12345 → extracted to path/ (fixed - now direct)

Migration Guide

✅ No Action Needed If:
  • You download artifacts by name
  • You download multiple artifacts by ID
  • You already use merge-multiple: true as a workaround
⚠️ Action Required If:

You download single artifacts by ID and your workflows expect the nested directory structure.

Before v5 (nested structure):

- uses: actions/download-artifact@v4
  with:
    artifact-ids: 12345
    path: dist
# Files were in: dist/my-artifact/

Where my-artifact is the name of the artifact you previously uploaded

To maintain old behavior (if needed):

</tr></table>

... (truncated)

Commits
  • 634f93c Merge pull request #416 from actions/single-artifact-id-download-path
  • b19ff43 refactor: resolve download path correctly in artifact download tests (mainly ...
  • e262cbe bundle dist
  • bff23f9 update docs
  • fff8c14 fix download path logic when downloading a single artifact by id
  • 448e3f8 Merge pull request #407 from actions/nebuk89-patch-1
  • 47225c4 Update README.md
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/download-artifact&package-manager=github_actions&previous-version=4.3.0&new-version=5.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/multiOSReleases.yml | 6 +++--- .github/workflows/releaseArtifacts.yml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index d705ced09..f13bb5c16 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -115,7 +115,7 @@ jobs: egress-policy: audit - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: stirling-${{ matrix.file_suffix }}binaries @@ -243,7 +243,7 @@ jobs: egress-policy: audit - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: ${{ matrix.platform }}binaries @@ -306,7 +306,7 @@ jobs: egress-policy: audit - name: Download signed artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 - name: Display structure of downloaded files run: ls -R - name: Upload binaries, attestations and signatures to Release and create GitHub Release diff --git a/.github/workflows/releaseArtifacts.yml b/.github/workflows/releaseArtifacts.yml index 3dd8bb5ea..7ae70e1ec 100644 --- a/.github/workflows/releaseArtifacts.yml +++ b/.github/workflows/releaseArtifacts.yml @@ -88,7 +88,7 @@ jobs: egress-policy: audit - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: binaries${{ matrix.file_suffix }} - name: Display structure of downloaded files @@ -166,7 +166,7 @@ jobs: egress-policy: audit - name: Download signed artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: signed${{ matrix.file_suffix }} From 1dd5e9c64912fbefa0b800a29a96ebfa00cce0e2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:09:30 +0100 Subject: [PATCH 07/41] build(deps): bump actions/checkout from 4.2.2 to 4.3.0 (#4180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [//]: # (dependabot-start) ⚠️ **Dependabot is rebasing this PR** ⚠️ Rebasing might not happen immediately, so don't worry if this takes some time. Note: if you make any changes to this PR yourself, they will take precedence over the rebase. --- [//]: # (dependabot-end) Bumps [actions/checkout](https://github.com/actions/checkout) from 4.2.2 to 4.3.0.
Release notes

Sourced from actions/checkout's releases.

v4.3.0

What's Changed

New Contributors

Full Changelog: https://github.com/actions/checkout/compare/v4...v4.3.0

Changelog

Sourced from actions/checkout's changelog.

Changelog

V4.3.0

v4.2.2

v4.2.1

v4.2.0

v4.1.7

v4.1.6

v4.1.5

v4.1.4

v4.1.3

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=4.2.2&new-version=4.3.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/PR-Demo-Comment-with-react.yml | 6 +++--- .github/workflows/PR-Demo-cleanup.yml | 2 +- .github/workflows/ai_pr_title_review.yml | 2 +- .github/workflows/auto-labelerV2.yml | 2 +- .github/workflows/build.yml | 12 ++++++------ .github/workflows/check_properties.yml | 2 +- .github/workflows/dependency-review.yml | 2 +- .github/workflows/licenses-update.yml | 2 +- .github/workflows/manage-label.yml | 2 +- .github/workflows/multiOSReleases.yml | 6 +++--- .github/workflows/pre_commit.yml | 2 +- .github/workflows/push-docker.yml | 2 +- .github/workflows/releaseArtifacts.yml | 2 +- .github/workflows/scorecards.yml | 2 +- .github/workflows/sonarqube.yml | 2 +- .github/workflows/swagger.yml | 2 +- .github/workflows/sync_files.yml | 2 +- .github/workflows/testdriver.yml | 4 ++-- 18 files changed, 28 insertions(+), 28 deletions(-) diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 066d85ef2..ff653ad15 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -46,7 +46,7 @@ jobs: egress-policy: audit - name: Checkout PR - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup GitHub App Bot if: github.actor != 'dependabot[bot]' @@ -157,7 +157,7 @@ jobs: egress-policy: audit - name: Checkout PR - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup GitHub App Bot if: github.actor != 'dependabot[bot]' @@ -169,7 +169,7 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - name: Checkout PR - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: repository: ${{ needs.check-comment.outputs.pr_repository }} ref: ${{ needs.check-comment.outputs.pr_ref }} diff --git a/.github/workflows/PR-Demo-cleanup.yml b/.github/workflows/PR-Demo-cleanup.yml index 29aea4389..67625c0a5 100644 --- a/.github/workflows/PR-Demo-cleanup.yml +++ b/.github/workflows/PR-Demo-cleanup.yml @@ -26,7 +26,7 @@ jobs: egress-policy: audit - name: Checkout PR - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup GitHub App Bot if: github.actor != 'dependabot[bot]' diff --git a/.github/workflows/ai_pr_title_review.yml b/.github/workflows/ai_pr_title_review.yml index 8a2e8b8ef..3f57edee5 100644 --- a/.github/workflows/ai_pr_title_review.yml +++ b/.github/workflows/ai_pr_title_review.yml @@ -23,7 +23,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 diff --git a/.github/workflows/auto-labelerV2.yml b/.github/workflows/auto-labelerV2.yml index bd998d197..fae92940f 100644 --- a/.github/workflows/auto-labelerV2.yml +++ b/.github/workflows/auto-labelerV2.yml @@ -17,7 +17,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup GitHub App Bot id: setup-bot diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b6c5237c2..60085f9c9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,7 +34,7 @@ jobs: project: ${{ steps.changes.outputs.project }} openapi: ${{ steps.changes.outputs.openapi }} steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Check for file changes uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 @@ -61,7 +61,7 @@ jobs: egress-policy: audit - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK ${{ matrix.jdk-version }} uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 @@ -134,7 +134,7 @@ jobs: egress-policy: audit - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK 17 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 @@ -167,7 +167,7 @@ jobs: egress-policy: audit - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK 17 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 @@ -213,7 +213,7 @@ jobs: egress-policy: audit - name: Checkout Repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up Java 17 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 @@ -262,7 +262,7 @@ jobs: egress-policy: audit - name: Checkout Repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK 17 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 diff --git a/.github/workflows/check_properties.yml b/.github/workflows/check_properties.yml index 32a970ef1..8633d2d62 100644 --- a/.github/workflows/check_properties.yml +++ b/.github/workflows/check_properties.yml @@ -35,7 +35,7 @@ jobs: egress-policy: audit - name: Checkout main branch first - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup GitHub App Bot id: setup-bot diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 30c96a1b0..8d938011d 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -22,6 +22,6 @@ jobs: egress-policy: audit - name: "Checkout Repository" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: "Dependency Review" uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1 diff --git a/.github/workflows/licenses-update.yml b/.github/workflows/licenses-update.yml index 4db087539..1f920e2da 100644 --- a/.github/workflows/licenses-update.yml +++ b/.github/workflows/licenses-update.yml @@ -36,7 +36,7 @@ jobs: egress-policy: audit - name: Check out code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 diff --git a/.github/workflows/manage-label.yml b/.github/workflows/manage-label.yml index 1388ef0fb..3f25fbaf1 100644 --- a/.github/workflows/manage-label.yml +++ b/.github/workflows/manage-label.yml @@ -20,7 +20,7 @@ jobs: egress-policy: audit - name: Check out the repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Run Labeler uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 # v5.3.0 diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index f13bb5c16..e043fd094 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -25,7 +25,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 @@ -64,7 +64,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK 21 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 @@ -152,7 +152,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK 21 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index 6560e9226..eccf235d1 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -22,7 +22,7 @@ jobs: egress-policy: audit - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index a766f5d5b..9a583c7b9 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -34,7 +34,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK 17 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 diff --git a/.github/workflows/releaseArtifacts.yml b/.github/workflows/releaseArtifacts.yml index 7ae70e1ec..7839ffd64 100644 --- a/.github/workflows/releaseArtifacts.yml +++ b/.github/workflows/releaseArtifacts.yml @@ -27,7 +27,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK 17 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 4922cac2a..a3a355845 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -39,7 +39,7 @@ jobs: egress-policy: audit - name: "Checkout code" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: persist-credentials: false diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 0ea32af59..1e0e3ec32 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -34,7 +34,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 74d3ec471..ebb51704c 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -30,7 +30,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK 17 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 diff --git a/.github/workflows/sync_files.yml b/.github/workflows/sync_files.yml index a76cd4acf..d2ff7e827 100644 --- a/.github/workflows/sync_files.yml +++ b/.github/workflows/sync_files.yml @@ -36,7 +36,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup GitHub App Bot id: setup-bot diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml index 5841879b4..209ce7435 100644 --- a/.github/workflows/testdriver.yml +++ b/.github/workflows/testdriver.yml @@ -29,7 +29,7 @@ jobs: egress-policy: audit - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 @@ -126,7 +126,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 From 91b2f5da5300d415915c87b895aa0d59753c8c94 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:09:47 +0100 Subject: [PATCH 08/41] build(deps): bump actions/ai-inference from 1.2.7 to 1.2.8 (#4181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [//]: # (dependabot-start) ⚠️ **Dependabot is rebasing this PR** ⚠️ Rebasing might not happen immediately, so don't worry if this takes some time. Note: if you make any changes to this PR yourself, they will take precedence over the rebase. --- [//]: # (dependabot-end) Bumps [actions/ai-inference](https://github.com/actions/ai-inference) from 1.2.7 to 1.2.8.
Release notes

Sourced from actions/ai-inference's releases.

v1.2.8

What's Changed

Full Changelog: https://github.com/actions/ai-inference/compare/v1...v1.2.8

Commits
  • b81b2af Merge pull request #88 from actions/sgoedecke/force-exit-once-inference-finishes
  • 9133f81 package
  • 7923b92 Merge pull request #89 from actions/sgoedecke/ensure-mcp-loops-output-desired...
  • e44da10 fixup format parsing
  • 866ae2b Ensure MCP loops output the right response format
  • 4685e0d Force exit once inference finishes in case we are holding any connections open
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/ai-inference&package-manager=github_actions&previous-version=1.2.7&new-version=1.2.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ai_pr_title_review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ai_pr_title_review.yml b/.github/workflows/ai_pr_title_review.yml index 3f57edee5..59a69ae5f 100644 --- a/.github/workflows/ai_pr_title_review.yml +++ b/.github/workflows/ai_pr_title_review.yml @@ -87,7 +87,7 @@ jobs: - name: AI PR Title Analysis if: steps.actor.outputs.is_repo_dev == 'true' id: ai-title-analysis - uses: actions/ai-inference@0cbed4a10641c75090de5968e66d70eb4660f751 # v1.2.7 + uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1.2.8 with: model: openai/gpt-4o system-prompt-file: ".github/config/system-prompt.txt" From 0afbd148cd31fd69193feae9ffcdcc873cefa1d7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:12:47 +0100 Subject: [PATCH 09/41] build(deps): bump edu.sc.seis.launch4j from 3.0.7 to 4.0.0 (#4182) Bumps edu.sc.seis.launch4j from 3.0.7 to 4.0.0. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=edu.sc.seis.launch4j&package-manager=gradle&previous-version=3.0.7&new-version=4.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 2c151d11b..1cd58b00e 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ plugins { id "org.springframework.boot" version "3.5.4" id "org.springdoc.openapi-gradle-plugin" version "1.9.0" id "io.swagger.swaggerhub" version "1.3.2" - id "edu.sc.seis.launch4j" version "3.0.7" + id "edu.sc.seis.launch4j" version "4.0.0" id "com.diffplug.spotless" version "7.2.1" id "com.github.jk1.dependency-license-report" version "2.9" //id "nebula.lint" version "19.0.3" From 8211fd8dc44367343cab032e0639c5e4dafbb1a3 Mon Sep 17 00:00:00 2001 From: albanobattistella <34811668+albanobattistella@users.noreply.github.com> Date: Mon, 11 Aug 2025 15:13:58 +0200 Subject: [PATCH 10/41] Update messages_it_IT.properties (#4183) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- app/core/src/main/resources/messages_it_IT.properties | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/core/src/main/resources/messages_it_IT.properties b/app/core/src/main/resources/messages_it_IT.properties index 26d492be3..7491624f0 100644 --- a/app/core/src/main/resources/messages_it_IT.properties +++ b/app/core/src/main/resources/messages_it_IT.properties @@ -1774,7 +1774,7 @@ audit.dashboard.filter.userPlaceholder=Filtra per utente audit.dashboard.filter.startDate=Data di inizio audit.dashboard.filter.endDate=Data di fine audit.dashboard.filter.apply=Applica filtri -audit.dashboard.filter.reset=Resetta Filtri +audit.dashboard.filter.reset=Resetta filtri # Table Headers audit.dashboard.table.id=ID @@ -1864,7 +1864,7 @@ scannerEffect.submit=Crea una falsa scansione #home.scannerEffect home.scannerEffect.title=Falsa scansione home.scannerEffect.desc=Crea un PDF che sembra scansionato -scannerEffect.tags=scansiona, simula, realistico, converti +scannerEffect.tags=scansiona,simula,realistico,converti # ScannerEffect advanced settings (frontend) scannerEffect.advancedSettings=Abilita impostazioni di scansione avanzate @@ -1886,7 +1886,7 @@ scannerEffect.resolution=Risoluzione (DPI) home.editTableOfContents.title=Modifica indice home.editTableOfContents.desc=Aggiungi o modifica segnalibri e sommario nei documenti PDF -editTableOfContents.tags=segnalibri, indice, navigazione, indice analitico, sommario, capitoli, sezioni, struttura +editTableOfContents.tags=segnalibri,indice,navigazione,indice analitico,sommario,capitoli,sezioni,struttura editTableOfContents.title=Modifica indice editTableOfContents.header=Aggiungi o modifica sommario PDF editTableOfContents.replaceExisting=Sostituisci i segnalibri esistenti (deseleziona per aggiungerli a quelli esistenti) From b41230db53faa7f95eacf65c8285c2e07bf08b6c Mon Sep 17 00:00:00 2001 From: "stirlingbot[bot]" <195170888+stirlingbot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:14:41 +0100 Subject: [PATCH 11/41] =?UTF-8?q?=F0=9F=A4=96=20format=20everything=20with?= =?UTF-8?q?=20pre-commit=20by=20stirlingbot=20(#4175)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-generated by [create-pull-request][1] with **stirlingbot** [1]: https://github.com/peter-evans/create-pull-request Signed-off-by: stirlingbot[bot] Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> --- app/core/src/main/resources/static/js/fetch-utils.js | 11 +++++------ app/core/src/main/resources/static/js/jwt-init.js | 4 ++-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/app/core/src/main/resources/static/js/fetch-utils.js b/app/core/src/main/resources/static/js/fetch-utils.js index 2a2fe894c..2cccbd19d 100644 --- a/app/core/src/main/resources/static/js/fetch-utils.js +++ b/app/core/src/main/resources/static/js/fetch-utils.js @@ -1,12 +1,12 @@ // Authentication utility for cookie-based JWT window.JWTManager = { - + // Logout - clear cookies and redirect to login logout: function() { - + // Clear JWT cookie manually (fallback) document.cookie = 'stirling_jwt=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; SameSite=None; Secure'; - + // Perform logout request to clear server-side session fetch('/logout', { method: 'POST', @@ -55,14 +55,13 @@ window.fetchWithCsrf = async function(url, options = {}) { // Make the request const response = await fetch(url, fetchOptions); - + // Handle 401 responses (unauthorized) if (response.status === 401) { console.warn('Authentication failed, redirecting to login'); window.JWTManager.logout(); return response; } - + return response; } - diff --git a/app/core/src/main/resources/static/js/jwt-init.js b/app/core/src/main/resources/static/js/jwt-init.js index 8cd63e189..35b736fd6 100644 --- a/app/core/src/main/resources/static/js/jwt-init.js +++ b/app/core/src/main/resources/static/js/jwt-init.js @@ -20,7 +20,7 @@ function initializeJWT() { // Clean up any JWT tokens from URL (OAuth flow) cleanupTokenFromUrl(); - + // Authentication is handled server-side // If user is not authenticated, server will redirect to login console.log('JWT initialization complete - authentication handled server-side'); @@ -41,4 +41,4 @@ } else { initializeJWT(); } -})(); \ No newline at end of file +})(); From 12ad8211feb4a5eff39ddc750a02c5c425b58462 Mon Sep 17 00:00:00 2001 From: "stirlingbot[bot]" <195170888+stirlingbot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:18:27 +0100 Subject: [PATCH 12/41] Update 3rd Party Licenses (#4184) Auto-generated by stirlingbot[bot] Signed-off-by: stirlingbot[bot] Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> --- .../resources/static/3rdPartyLicenses.json | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/app/core/src/main/resources/static/3rdPartyLicenses.json b/app/core/src/main/resources/static/3rdPartyLicenses.json index 23278a23f..062818603 100644 --- a/app/core/src/main/resources/static/3rdPartyLicenses.json +++ b/app/core/src/main/resources/static/3rdPartyLicenses.json @@ -132,6 +132,13 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "com.github.ben-manes.caffeine:caffeine", + "moduleUrl": "https://github.com/ben-manes/caffeine", + "moduleVersion": "3.2.2", + "moduleLicense": "Apache License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "com.github.jai-imageio:jai-imageio-core", "moduleUrl": "https://github.com/jai-imageio/jai-imageio-core", @@ -168,7 +175,7 @@ { "moduleName": "com.google.errorprone:error_prone_annotations", "moduleUrl": "https://errorprone.info/error_prone_annotations", - "moduleVersion": "2.38.0", + "moduleVersion": "2.40.0", "moduleLicense": "Apache 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -549,6 +556,27 @@ "moduleLicense": "MIT License", "moduleLicenseUrl": "http://www.opensource.org/licenses/mit-license.php" }, + { + "moduleName": "io.jsonwebtoken:jjwt-api", + "moduleUrl": "https://github.com/jwtk/jjwt", + "moduleVersion": "0.12.6", + "moduleLicense": "Apache License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, + { + "moduleName": "io.jsonwebtoken:jjwt-impl", + "moduleUrl": "https://github.com/jwtk/jjwt", + "moduleVersion": "0.12.6", + "moduleLicense": "Apache License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, + { + "moduleName": "io.jsonwebtoken:jjwt-jackson", + "moduleUrl": "https://github.com/jwtk/jjwt", + "moduleVersion": "0.12.6", + "moduleLicense": "Apache License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, { "moduleName": "io.micrometer:micrometer-commons", "moduleUrl": "https://github.com/micrometer-metrics/micrometer", @@ -1507,6 +1535,13 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, + { + "moduleName": "org.springframework.boot:spring-boot-starter-cache", + "moduleUrl": "https://spring.io/projects/spring-boot", + "moduleVersion": "3.5.4", + "moduleLicense": "Apache License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, { "moduleName": "org.springframework.boot:spring-boot-starter-data-jpa", "moduleUrl": "https://spring.io/projects/spring-boot", From d23c2eaa3091ccd6b484a57f816bff3b8e21ff2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Wed, 13 Aug 2025 23:52:06 +0200 Subject: [PATCH 13/41] feat: Auto-redact to support text removal on True PDFs/non-custom encoded PDFs, JUnit tests for RedactController, and TextFinder (#3936) # Description of Changes ## Overview This enhancement adds **true PDF text removal** to RedactController. It changes auto-redaction from visual covering to actual text removal. The feature removes text from True PDFs completely while keeping compatibility with other PDF types. ## Features ### 1. True PDF Text Removal - Removes text from PDF structure instead of just hiding it - No impact to manual redaction or other types of PDFs (e.g.: to searchable PDFs or custom encoded PDFs) ### 2. Advanced Content Stream Processing #### How It Works (only high level overview) - Token Processing: Breaks PDF content into small pieces for exact text finding - Font Tracking: Keeps track of fonts and formatting - Text Operators: Finds PDF commands that show text (`Tj`, `TJ`, `'`, `"`) - Position Mapping: Maps text to exact locations for removal - Rebuilds PDF: Rebuilds PDFs without the text, while keeping formatting operators #### No change for other types PDFs - Because the iteration through the PDF for token/text removal and for box placing are two separate completely methods - This means when the there is custom encoded PDF the token/text removal won't find any text to remove (because there is no logic for decoding for, for now) but the box finding methods still reliably finds redacted words and puts a box onto them. So no change. ### 3. Enhanced TextFinder Integration #### Minor Improvements - Page Grouping: Groups found text by page for faster processing ### JUnit tests for both of files. - Added JUnit tests for both files. - Might need future improvement. ### TODOs - Support for additional PDF types besides true PDFs (currently a WIP), e.g.: searchable PDF/custom encoded PDF - Feature to be expected in few weeks (best case scenario, and only if I succeed), sadly that is significantly harder task so only true PDFs for now ### UI - No UI change for now ### Sample files: [Free_Test_Data_500KB_PDF_redacted.pdf](https://github.com/user-attachments/files/21195841/Free_Test_Data_500KB_PDF_redacted.pdf) [lorem-ipsum_redacted.pdf](https://github.com/user-attachments/files/21195842/lorem-ipsum_redacted.pdf) [true-pdf-sample-1_redacted.pdf](https://github.com/user-attachments/files/21195843/true-pdf-sample-1_redacted.pdf) [true-pdf-sample-2_redacted.pdf](https://github.com/user-attachments/files/21195844/true-pdf-sample-2_redacted.pdf) [true-pdf-sample-3_redacted.pdf](https://github.com/user-attachments/files/21195845/true-pdf-sample-3_redacted.pdf) Closes: does not actually close any issues, since it only works with true PDFs --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../api/security/RedactController.java | 1650 +++++++++++++++-- .../software/SPDF/pdf/TextFinder.java | 237 ++- .../SPDF/utils/text/TextEncodingHelper.java | 351 ++++ .../SPDF/utils/text/TextFinderUtils.java | 140 ++ .../SPDF/utils/text/WidthCalculator.java | 136 ++ .../api/security/RedactControllerTest.java | 1327 +++++++++++++ .../software/SPDF/pdf/TextFinderTest.java | 588 ++++++ 7 files changed, 4250 insertions(+), 179 deletions(-) create mode 100644 app/core/src/main/java/stirling/software/SPDF/utils/text/TextEncodingHelper.java create mode 100644 app/core/src/main/java/stirling/software/SPDF/utils/text/TextFinderUtils.java create mode 100644 app/core/src/main/java/stirling/software/SPDF/utils/text/WidthCalculator.java create mode 100644 stirling-pdf/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerTest.java create mode 100644 stirling-pdf/src/test/java/stirling/software/SPDF/pdf/TextFinderTest.java diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java index 88d271cfb..51d5e5a53 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java @@ -1,19 +1,39 @@ package stirling.software.SPDF.controller.api.security; -import java.awt.*; +import java.awt.Color; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.apache.pdfbox.contentstream.operator.Operator; +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSBase; +import org.apache.pdfbox.cos.COSFloat; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSNumber; +import org.apache.pdfbox.cos.COSString; +import org.apache.pdfbox.pdfparser.PDFStreamParser; +import org.apache.pdfbox.pdfwriter.ContentStreamWriter; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; import org.apache.pdfbox.pdmodel.PDPageTree; +import org.apache.pdfbox.pdmodel.PDResources; import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.common.PDStream; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.graphics.PDXObject; +import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.annotation.InitBinder; @@ -27,6 +47,8 @@ import io.github.pixee.security.Filenames; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -34,6 +56,9 @@ import stirling.software.SPDF.model.PDFText; import stirling.software.SPDF.model.api.security.ManualRedactPdfRequest; import stirling.software.SPDF.model.api.security.RedactPdfRequest; import stirling.software.SPDF.pdf.TextFinder; +import stirling.software.SPDF.utils.text.TextEncodingHelper; +import stirling.software.SPDF.utils.text.TextFinderUtils; +import stirling.software.SPDF.utils.text.WidthCalculator; import stirling.software.common.model.api.security.RedactionArea; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.GeneralUtils; @@ -48,8 +73,24 @@ import stirling.software.common.util.propertyeditor.StringToArrayListPropertyEdi @RequiredArgsConstructor public class RedactController { + private static final float DEFAULT_TEXT_PADDING_MULTIPLIER = 0.6f; + private static final float PRECISION_THRESHOLD = 1e-3f; + private static final int FONT_SCALE_FACTOR = 1000; + + // Redaction box width reduction factor (10% reduction) + private static final float REDACTION_WIDTH_REDUCTION_FACTOR = 0.9f; + + // Text showing operators + private static final Set TEXT_SHOWING_OPERATORS = Set.of("Tj", "TJ", "'", "\""); + + private static final COSString EMPTY_COS_STRING = new COSString(""); + private final CustomPDFDocumentFactory pdfDocumentFactory; + private String removeFileExtension(String filename) { + return filename.replaceFirst("[.][^.]+$", ""); + } + @InitBinder public void initBinder(WebDataBinder binder) { binder.registerCustomEditor( @@ -58,126 +99,389 @@ public class RedactController { @PostMapping(value = "/redact", consumes = "multipart/form-data") @Operation( - summary = "Redacts areas and pages in a PDF document", + summary = "Redact PDF manually", description = - "This operation takes an input PDF file with a list of areas, page" - + " number(s)/range(s)/function(s) to redact. Input:PDF, Output:PDF," - + " Type:SISO") + "This endpoint redacts content from a PDF file based on manually specified areas. " + + "Users can specify areas to redact and optionally convert the PDF to an image. " + + "Input:PDF Output:PDF Type:SISO") public ResponseEntity redactPDF(@ModelAttribute ManualRedactPdfRequest request) throws IOException { + MultipartFile file = request.getFileInput(); List redactionAreas = request.getRedactions(); - PDDocument document = pdfDocumentFactory.load(file); + try (PDDocument document = pdfDocumentFactory.load(file)) { + PDPageTree allPages = document.getDocumentCatalog().getPages(); - PDPageTree allPages = document.getDocumentCatalog().getPages(); + redactPages(request, document, allPages); - redactPages(request, document, allPages); - redactAreas(redactionAreas, document, allPages); + redactAreas(redactionAreas, document, allPages); - if (Boolean.TRUE.equals(request.getConvertPDFToImage())) { - PDDocument convertedPdf = PdfUtils.convertPdfToPdfImage(document); - document.close(); - document = convertedPdf; + if (Boolean.TRUE.equals(request.getConvertPDFToImage())) { + try (PDDocument convertedPdf = PdfUtils.convertPdfToPdfImage(document)) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + convertedPdf.save(baos); + byte[] pdfContent = baos.toByteArray(); + + return WebResponseUtils.bytesToWebResponse( + pdfContent, + removeFileExtension( + Objects.requireNonNull( + Filenames.toSimpleFileName( + file.getOriginalFilename()))) + + "_redacted.pdf"); + } + } + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + document.save(baos); + byte[] pdfContent = baos.toByteArray(); + + return WebResponseUtils.bytesToWebResponse( + pdfContent, + removeFileExtension( + Objects.requireNonNull( + Filenames.toSimpleFileName(file.getOriginalFilename()))) + + "_redacted.pdf"); } - - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - document.save(baos); - document.close(); - - byte[] pdfContent = baos.toByteArray(); - return WebResponseUtils.bytesToWebResponse( - pdfContent, - Filenames.toSimpleFileName(file.getOriginalFilename()).replaceFirst("[.][^.]+$", "") - + "_redacted.pdf"); } private void redactAreas( List redactionAreas, PDDocument document, PDPageTree allPages) throws IOException { - // Group redaction areas by page + + if (redactionAreas == null || redactionAreas.isEmpty()) { + return; + } + Map> redactionsByPage = new HashMap<>(); - // Process and validate each redaction area for (RedactionArea redactionArea : redactionAreas) { + if (redactionArea.getPage() == null || redactionArea.getPage() <= 0 || redactionArea.getHeight() == null || redactionArea.getHeight() <= 0.0D || redactionArea.getWidth() == null - || redactionArea.getWidth() <= 0.0D) continue; + || redactionArea.getWidth() <= 0.0D) { + continue; + } - // Group by page number redactionsByPage .computeIfAbsent(redactionArea.getPage(), k -> new ArrayList<>()) .add(redactionArea); } - // Process each page only once for (Map.Entry> entry : redactionsByPage.entrySet()) { Integer pageNumber = entry.getKey(); List areasForPage = entry.getValue(); if (pageNumber > allPages.getCount()) { - continue; // Skip if page number is out of bounds + continue; // Skip if the page number is out of bounds } PDPage page = allPages.get(pageNumber - 1); - PDRectangle box = page.getBBox(); - // Create only one content stream per page - PDPageContentStream contentStream = + try (PDPageContentStream contentStream = new PDPageContentStream( - document, page, PDPageContentStream.AppendMode.APPEND, true, true); + document, page, PDPageContentStream.AppendMode.APPEND, true, true)) { - // Process all redactions for this page - for (RedactionArea redactionArea : areasForPage) { - Color redactColor = decodeOrDefault(redactionArea.getColor(), Color.BLACK); - contentStream.setNonStrokingColor(redactColor); + contentStream.saveGraphicsState(); + for (RedactionArea redactionArea : areasForPage) { + Color redactColor = decodeOrDefault(redactionArea.getColor()); - float x = redactionArea.getX().floatValue(); - float y = redactionArea.getY().floatValue(); - float width = redactionArea.getWidth().floatValue(); - float height = redactionArea.getHeight().floatValue(); + contentStream.setNonStrokingColor(redactColor); - contentStream.addRect(x, box.getHeight() - y - height, width, height); - contentStream.fill(); + float x = redactionArea.getX().floatValue(); + float y = redactionArea.getY().floatValue(); + float width = redactionArea.getWidth().floatValue(); + float height = redactionArea.getHeight().floatValue(); + + float pdfY = page.getBBox().getHeight() - y - height; + + contentStream.addRect(x, pdfY, width, height); + contentStream.fill(); + } + contentStream.restoreGraphicsState(); } - - contentStream.close(); } } private void redactPages( ManualRedactPdfRequest request, PDDocument document, PDPageTree allPages) throws IOException { - Color redactColor = decodeOrDefault(request.getPageRedactionColor(), Color.BLACK); + + Color redactColor = decodeOrDefault(request.getPageRedactionColor()); List pageNumbers = getPageNumbers(request, allPages.getCount()); + for (Integer pageNumber : pageNumbers) { + PDPage page = allPages.get(pageNumber); - PDPageContentStream contentStream = + try (PDPageContentStream contentStream = new PDPageContentStream( - document, page, PDPageContentStream.AppendMode.APPEND, true, true); - contentStream.setNonStrokingColor(redactColor); + document, page, PDPageContentStream.AppendMode.APPEND, true, true)) { + contentStream.setNonStrokingColor(redactColor); - PDRectangle box = page.getBBox(); + PDRectangle box = page.getBBox(); - contentStream.addRect(0, 0, box.getWidth(), box.getHeight()); - contentStream.fill(); - contentStream.close(); + contentStream.addRect(0, 0, box.getWidth(), box.getHeight()); + contentStream.fill(); + } } } - private Color decodeOrDefault(String hex, Color defaultColor) { - try { - if (hex != null && !hex.startsWith("#")) { - hex = "#" + hex; - } - return Color.decode(hex); - } catch (Exception e) { - return defaultColor; + private void redactFoundText( + PDDocument document, + List blocks, + float customPadding, + Color redactColor, + boolean isTextRemovalMode) + throws IOException { + + var allPages = document.getDocumentCatalog().getPages(); + + Map> blocksByPage = new HashMap<>(); + for (PDFText block : blocks) { + blocksByPage.computeIfAbsent(block.getPageIndex(), k -> new ArrayList<>()).add(block); } + + for (Map.Entry> entry : blocksByPage.entrySet()) { + Integer pageIndex = entry.getKey(); + List pageBlocks = entry.getValue(); + + if (pageIndex >= allPages.getCount()) { + continue; // Skip if page index is out of bounds + } + + var page = allPages.get(pageIndex); + try (PDPageContentStream contentStream = + new PDPageContentStream( + document, page, PDPageContentStream.AppendMode.APPEND, true, true)) { + + contentStream.saveGraphicsState(); + + try { + contentStream.setNonStrokingColor(redactColor); + PDRectangle pageBox = page.getBBox(); + + for (PDFText block : pageBlocks) { + float padding = + (block.getY2() - block.getY1()) * DEFAULT_TEXT_PADDING_MULTIPLIER + + customPadding; + + float originalWidth = block.getX2() - block.getX1(); + float boxWidth; + float boxX; + + // Only apply width reduction when text is actually being removed + if (isTextRemovalMode) { + // Calculate reduced width and center the box + boxWidth = + originalWidth + * REDACTION_WIDTH_REDUCTION_FACTOR; // 10% reduction + float widthReduction = originalWidth - boxWidth; + boxX = block.getX1() + (widthReduction / 2); // Center the reduced box + } else { + // Use original width for box-only redaction + boxWidth = originalWidth; + boxX = block.getX1(); + } + + contentStream.addRect( + boxX, + pageBox.getHeight() - block.getY2() - padding, + boxWidth, + block.getY2() - block.getY1() + 2 * padding); + } + + contentStream.fill(); + + } finally { + contentStream.restoreGraphicsState(); + } + } + } + } + + String createPlaceholderWithFont(String originalWord, PDFont font) { + if (originalWord == null || originalWord.isEmpty()) { + return originalWord; + } + + if (font != null && TextEncodingHelper.isFontSubset(font.getName())) { + try { + float originalWidth = safeGetStringWidth(font, originalWord) / FONT_SCALE_FACTOR; + return createAlternativePlaceholder(originalWord, originalWidth, font, 1.0f); + } catch (Exception e) { + log.debug( + "Subset font placeholder creation failed for {}: {}", + font.getName(), + e.getMessage()); + return ""; + } + } + + return " ".repeat(originalWord.length()); + } + + /** + * Enhanced placeholder creation using advanced width calculation. Incorporates font validation + * and sophisticated fallback strategies. + */ + String createPlaceholderWithWidth( + String originalWord, float targetWidth, PDFont font, float fontSize) { + if (originalWord == null || originalWord.isEmpty()) { + return originalWord; + } + + if (font == null || fontSize <= 0) { + return " ".repeat(originalWord.length()); + } + + try { + // Check font reliability before proceeding + if (!WidthCalculator.isWidthCalculationReliable(font)) { + log.debug( + "Font {} unreliable for width calculation, using simple placeholder", + font.getName()); + return " ".repeat(originalWord.length()); + } + + // Use enhanced subset font detection + if (TextEncodingHelper.isFontSubset(font.getName())) { + return createSubsetFontPlaceholder(originalWord, targetWidth, font, fontSize); + } + + // Enhanced space width calculation + float spaceWidth = WidthCalculator.calculateAccurateWidth(font, " ", fontSize); + + if (spaceWidth <= 0) { + return createAlternativePlaceholder(originalWord, targetWidth, font, fontSize); + } + + int spaceCount = Math.max(1, Math.round(targetWidth / spaceWidth)); + + // More conservative space limit based on original word characteristics + int maxSpaces = + Math.max( + originalWord.length() * 2, Math.round(targetWidth / spaceWidth * 1.5f)); + spaceCount = Math.min(spaceCount, maxSpaces); + + return " ".repeat(spaceCount); + + } catch (Exception e) { + log.debug("Enhanced placeholder creation failed: {}", e.getMessage()); + return createAlternativePlaceholder(originalWord, targetWidth, font, fontSize); + } + } + + private String createSubsetFontPlaceholder( + String originalWord, float targetWidth, PDFont font, float fontSize) { + try { + log.debug("Subset font {} - trying to find replacement characters", font.getName()); + String result = createAlternativePlaceholder(originalWord, targetWidth, font, fontSize); + + if (result.isEmpty()) { + log.debug( + "Subset font {} has no suitable replacement characters, using empty string", + font.getName()); + } + + return result; + + } catch (Exception e) { + log.debug("Subset font placeholder creation failed: {}", e.getMessage()); + return ""; + } + } + + private String createAlternativePlaceholder( + String originalWord, float targetWidth, PDFont font, float fontSize) { + try { + String[] alternatives = {" ", ".", "-", "_", "~", "°", "·"}; + + if (TextEncodingHelper.fontSupportsCharacter(font, " ")) { + float spaceWidth = safeGetStringWidth(font, " ") / FONT_SCALE_FACTOR * fontSize; + if (spaceWidth > 0) { + int spaceCount = Math.max(1, Math.round(targetWidth / spaceWidth)); + int maxSpaces = originalWord.length() * 2; + spaceCount = Math.min(spaceCount, maxSpaces); + log.debug("Using spaces for font {}", font.getName()); + return " ".repeat(spaceCount); + } + } + + for (String altChar : alternatives) { + if (" ".equals(altChar)) continue; // Already tried spaces + + try { + if (!TextEncodingHelper.fontSupportsCharacter(font, altChar)) { + continue; + } + + float charWidth = + safeGetStringWidth(font, altChar) / FONT_SCALE_FACTOR * fontSize; + if (charWidth > 0) { + int charCount = Math.max(1, Math.round(targetWidth / charWidth)); + int maxChars = originalWord.length() * 2; + charCount = Math.min(charCount, maxChars); + log.debug( + "Using character '{}' for width calculation but spaces for placeholder in font {}", + altChar, + font.getName()); + + return " ".repeat(charCount); + } + } catch (Exception e) { + } + } + + log.debug( + "All placeholder alternatives failed for font {}, using empty string", + font.getName()); + return ""; + + } catch (Exception e) { + log.debug("Alternative placeholder creation failed: {}", e.getMessage()); + return ""; + } + } + + void writeFilteredContentStream(PDDocument document, PDPage page, List tokens) + throws IOException { + + PDStream newStream = new PDStream(document); + + try { + try (var out = newStream.createOutputStream()) { + ContentStreamWriter writer = new ContentStreamWriter(out); + writer.writeTokens(tokens); + } + + page.setContents(newStream); + + } catch (IOException e) { + throw new IOException("Failed to write filtered content stream to page", e); + } + } + + Color decodeOrDefault(String hex) { + if (hex == null) { + return Color.BLACK; + } + + String colorString = hex.startsWith("#") ? hex : "#" + hex; + + try { + return Color.decode(colorString); + } catch (NumberFormatException e) { + return Color.BLACK; + } + } + + boolean isTextShowingOperator(String opName) { + return TEXT_SHOWING_OPERATORS.contains(opName); } private List getPageNumbers(ManualRedactPdfRequest request, int pagesCount) { @@ -192,78 +496,1194 @@ public class RedactController { @PostMapping(value = "/auto-redact", consumes = "multipart/form-data") @Operation( - summary = "Redacts listOfText in a PDF document", + summary = "Redact PDF automatically", description = - "This operation takes an input PDF file and redacts the provided listOfText." - + " Input:PDF, Output:PDF, Type:SISO") - public ResponseEntity redactPdf(@ModelAttribute RedactPdfRequest request) - throws Exception { - MultipartFile file = request.getFileInput(); - String listOfTextString = request.getListOfText(); + "This endpoint automatically redacts text from a PDF file based on specified patterns. " + + "Users can provide text patterns to redact, with options for regex and whole word matching. " + + "Input:PDF Output:PDF Type:SISO") + public ResponseEntity redactPdf(@ModelAttribute RedactPdfRequest request) { + String[] listOfText = request.getListOfText().split("\n"); boolean useRegex = Boolean.TRUE.equals(request.getUseRegex()); boolean wholeWordSearchBool = Boolean.TRUE.equals(request.getWholeWordSearch()); - String colorString = request.getRedactColor(); - float customPadding = request.getCustomPadding(); - boolean convertPDFToImage = Boolean.TRUE.equals(request.getConvertPDFToImage()); - String[] listOfText = listOfTextString.split("\n"); - PDDocument document = pdfDocumentFactory.load(file); - - Color redactColor; - try { - if (!colorString.startsWith("#")) { - colorString = "#" + colorString; - } - redactColor = Color.decode(colorString); - } catch (NumberFormatException e) { - log.warn("Invalid color string provided. Using default color BLACK for redaction."); - redactColor = Color.BLACK; + if (listOfText.length == 0 || (listOfText.length == 1 && listOfText[0].trim().isEmpty())) { + throw new IllegalArgumentException("No text patterns provided for redaction"); } + PDDocument document = null; + PDDocument fallbackDocument = null; + + try { + if (request.getFileInput() == null) { + log.error("File input is null"); + throw new IllegalArgumentException("File input cannot be null"); + } + + document = pdfDocumentFactory.load(request.getFileInput()); + + if (document == null) { + log.error("Failed to load PDF document"); + throw new IllegalArgumentException("Failed to load PDF document"); + } + + Map> allFoundTextsByPage = + findTextToRedact(document, listOfText, useRegex, wholeWordSearchBool); + + int totalMatches = allFoundTextsByPage.values().stream().mapToInt(List::size).sum(); + log.info( + "Redaction scan: {} occurrences across {} pages (patterns={}, regex={}, wholeWord={})", + totalMatches, + allFoundTextsByPage.size(), + listOfText.length, + useRegex, + wholeWordSearchBool); + + if (allFoundTextsByPage.isEmpty()) { + log.info("No text found matching redaction patterns"); + byte[] originalContent; + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + document.save(baos); + originalContent = baos.toByteArray(); + } + + return WebResponseUtils.bytesToWebResponse( + originalContent, + removeFileExtension( + Objects.requireNonNull( + Filenames.toSimpleFileName( + request.getFileInput() + .getOriginalFilename()))) + + "_redacted.pdf"); + } + + boolean fallbackToBoxOnlyMode; + try { + fallbackToBoxOnlyMode = + performTextReplacement( + document, + allFoundTextsByPage, + listOfText, + useRegex, + wholeWordSearchBool); + } catch (Exception e) { + log.warn( + "Text replacement redaction failed, falling back to box-only mode: {}", + e.getMessage()); + fallbackToBoxOnlyMode = true; + } + + if (fallbackToBoxOnlyMode) { + log.warn( + "Font compatibility issues detected. Using box-only redaction mode for better reliability."); + + fallbackDocument = pdfDocumentFactory.load(request.getFileInput()); + + allFoundTextsByPage = + findTextToRedact( + fallbackDocument, listOfText, useRegex, wholeWordSearchBool); + + byte[] pdfContent = + finalizeRedaction( + fallbackDocument, + allFoundTextsByPage, + request.getRedactColor(), + request.getCustomPadding(), + request.getConvertPDFToImage(), + false); // Box-only mode, use original box sizes + + return WebResponseUtils.bytesToWebResponse( + pdfContent, + removeFileExtension( + Objects.requireNonNull( + Filenames.toSimpleFileName( + request.getFileInput() + .getOriginalFilename()))) + + "_redacted.pdf"); + } + + byte[] pdfContent = + finalizeRedaction( + document, + allFoundTextsByPage, + request.getRedactColor(), + request.getCustomPadding(), + request.getConvertPDFToImage(), + true); // Text removal mode, use reduced box sizes + + return WebResponseUtils.bytesToWebResponse( + pdfContent, + removeFileExtension( + Objects.requireNonNull( + Filenames.toSimpleFileName( + request.getFileInput().getOriginalFilename()))) + + "_redacted.pdf"); + + } catch (Exception e) { + log.error("Redaction operation failed: {}", e.getMessage(), e); + throw new RuntimeException("Failed to perform PDF redaction: " + e.getMessage(), e); + + } finally { + if (document != null) { + try { + if (fallbackDocument == null) { + document.close(); + } + } catch (IOException e) { + log.warn("Failed to close main document: {}", e.getMessage()); + } + } + + if (fallbackDocument != null) { + try { + fallbackDocument.close(); + } catch (IOException e) { + log.warn("Failed to close fallback document: {}", e.getMessage()); + } + } + } + } + + private Map> findTextToRedact( + PDDocument document, String[] listOfText, boolean useRegex, boolean wholeWordSearch) { + Map> allFoundTextsByPage = new HashMap<>(); + for (String text : listOfText) { text = text.trim(); - TextFinder textFinder = new TextFinder(text, useRegex, wholeWordSearchBool); - List foundTexts = textFinder.getTextLocations(document); - redactFoundText(document, foundTexts, customPadding, redactColor); + if (text.isEmpty()) continue; + + log.debug( + "Searching for text: '{}' (regex: {}, wholeWord: {})", + text, + useRegex, + wholeWordSearch); + + try { + TextFinder textFinder = new TextFinder(text, useRegex, wholeWordSearch); + textFinder.getText(document); + + List foundTexts = textFinder.getFoundTexts(); + log.debug("TextFinder found {} instances of '{}'", foundTexts.size(), text); + + for (PDFText found : foundTexts) { + allFoundTextsByPage + .computeIfAbsent(found.getPageIndex(), k -> new ArrayList<>()) + .add(found); + log.debug( + "Added match on page {} at ({},{},{},{}): '{}'", + found.getPageIndex(), + found.getX1(), + found.getY1(), + found.getX2(), + found.getY2(), + found.getText()); + } + } catch (Exception e) { + log.error("Error processing search term '{}': {}", text, e.getMessage()); + } } - if (convertPDFToImage) { - PDDocument convertedPdf = PdfUtils.convertPdfToPdfImage(document); - document.close(); - document = convertedPdf; + return allFoundTextsByPage; + } + + private boolean performTextReplacement( + PDDocument document, + Map> allFoundTextsByPage, + String[] listOfText, + boolean useRegex, + boolean wholeWordSearchBool) { + if (allFoundTextsByPage.isEmpty()) { + return false; + } + + if (detectCustomEncodingFonts(document)) { + log.warn( + "Custom encoded fonts detected (non-standard encodings / DictionaryEncoding / damaged fonts). " + + "Text replacement is unreliable for these fonts. Falling back to box-only redaction mode."); + return true; // signal caller to fall back + } + + try { + Set allSearchTerms = + Arrays.stream(listOfText) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + + int pageCount = 0; + for (PDPage page : document.getPages()) { + pageCount++; + List filteredTokens = + createTokensWithoutTargetText( + document, page, allSearchTerms, useRegex, wholeWordSearchBool); + writeFilteredContentStream(document, page, filteredTokens); + } + log.info("Successfully performed text replacement redaction on {} pages.", pageCount); + return false; + } catch (Exception e) { + log.error( + "Text replacement redaction failed due to font or encoding issues. " + + "Will fall back to box-only redaction mode. Error: {}", + e.getMessage()); + return true; + } + } + + private byte[] finalizeRedaction( + PDDocument document, + Map> allFoundTextsByPage, + String colorString, + float customPadding, + Boolean convertToImage, + boolean isTextRemovalMode) + throws IOException { + + List allFoundTexts = new ArrayList<>(); + for (List pageTexts : allFoundTextsByPage.values()) { + allFoundTexts.addAll(pageTexts); + } + + if (!allFoundTexts.isEmpty()) { + Color redactColor = decodeOrDefault(colorString); + + redactFoundText(document, allFoundTexts, customPadding, redactColor, isTextRemovalMode); + + cleanDocumentMetadata(document); + } + + if (Boolean.TRUE.equals(convertToImage)) { + try (PDDocument convertedPdf = PdfUtils.convertPdfToPdfImage(document)) { + cleanDocumentMetadata(convertedPdf); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + convertedPdf.save(baos); + byte[] out = baos.toByteArray(); + + log.info( + "Redaction finalized (image mode): {} pages ➜ {} KB", + convertedPdf.getNumberOfPages(), + out.length / 1024); + + return out; + } } ByteArrayOutputStream baos = new ByteArrayOutputStream(); document.save(baos); - document.close(); + byte[] out = baos.toByteArray(); - byte[] pdfContent = baos.toByteArray(); - return WebResponseUtils.bytesToWebResponse( - pdfContent, - Filenames.toSimpleFileName(file.getOriginalFilename()).replaceFirst("[.][^.]+$", "") - + "_redacted.pdf"); + log.info( + "Redaction finalized: {} pages ➜ {} KB", + document.getNumberOfPages(), + out.length / 1024); + + return out; } - private void redactFoundText( - PDDocument document, List blocks, float customPadding, Color redactColor) - throws IOException { - var allPages = document.getDocumentCatalog().getPages(); + private void cleanDocumentMetadata(PDDocument document) { + try { + var documentInfo = document.getDocumentInformation(); + if (documentInfo != null) { + documentInfo.setAuthor(null); + documentInfo.setSubject(null); + documentInfo.setKeywords(null); - for (PDFText block : blocks) { - var page = allPages.get(block.getPageIndex()); - PDPageContentStream contentStream = - new PDPageContentStream( - document, page, PDPageContentStream.AppendMode.APPEND, true, true); - contentStream.setNonStrokingColor(redactColor); - float padding = (block.getY2() - block.getY1()) * 0.3f + customPadding; - PDRectangle pageBox = page.getBBox(); - contentStream.addRect( - block.getX1(), - pageBox.getHeight() - block.getY1() - padding, - block.getX2() - block.getX1(), - block.getY2() - block.getY1() + 2 * padding); - contentStream.fill(); - contentStream.close(); + documentInfo.setModificationDate(java.util.Calendar.getInstance()); + + log.debug("Cleaned document metadata for security"); + } + + if (document.getDocumentCatalog() != null) { + try { + document.getDocumentCatalog().setMetadata(null); + } catch (Exception e) { + log.debug("Could not clear XMP metadata: {}", e.getMessage()); + } + } + + } catch (Exception e) { + log.warn("Failed to clean document metadata: {}", e.getMessage()); } } + + List createTokensWithoutTargetText( + PDDocument document, + PDPage page, + Set targetWords, + boolean useRegex, + boolean wholeWordSearch) + throws IOException { + + PDFStreamParser parser = new PDFStreamParser(page); + List tokens = new ArrayList<>(); + Object token; + while ((token = parser.parseNextToken()) != null) { + tokens.add(token); + } + + PDResources resources = page.getResources(); + if (resources != null) { + processPageXObjects(document, resources, targetWords, useRegex, wholeWordSearch); + } + + List textSegments = extractTextSegments(page, tokens); + + String completeText = buildCompleteText(textSegments); + + List matches = + findAllMatches(completeText, targetWords, useRegex, wholeWordSearch); + + return applyRedactionsToTokens(tokens, textSegments, matches); + } + + private void processPageXObjects( + PDDocument document, + PDResources resources, + Set targetWords, + boolean useRegex, + boolean wholeWordSearch) { + + for (COSName xobjName : resources.getXObjectNames()) { + try { + PDXObject xobj = resources.getXObject(xobjName); + if (xobj instanceof PDFormXObject formXObj) { + processFormXObject(document, formXObj, targetWords, useRegex, wholeWordSearch); + log.debug("Processed Form XObject: {}", xobjName.getName()); + } + } catch (Exception e) { + log.warn("Failed to process XObject {}: {}", xobjName.getName(), e.getMessage()); + } + } + } + + @Data + private static class GraphicsState { + private PDFont font = null; + private float fontSize = 0; + } + + @Data + @AllArgsConstructor + private static class TextSegment { + private int tokenIndex; + private String operatorName; + private String text; + private int startPos; + private int endPos; + private PDFont font; + private float fontSize; + } + + @Data + @AllArgsConstructor + private static class MatchRange { + private int startPos; + private int endPos; + } + + private List extractTextSegments(PDPage page, List tokens) { + + List segments = new ArrayList<>(); + int currentTextPos = 0; + GraphicsState graphicsState = new GraphicsState(); + PDResources resources = page.getResources(); + + for (int i = 0; i < tokens.size(); i++) { + Object currentToken = tokens.get(i); + + if (currentToken instanceof Operator op) { + String opName = op.getName(); + + if ("Tf".equals(opName) && i >= 2) { + try { + COSName fontName = (COSName) tokens.get(i - 2); + COSBase fontSizeBase = (COSBase) tokens.get(i - 1); + if (fontSizeBase instanceof COSNumber cosNumber) { + graphicsState.setFont(resources.getFont(fontName)); + graphicsState.setFontSize(cosNumber.floatValue()); + } + } catch (ClassCastException | IOException e) { + log.debug( + "Failed to extract font and font size from Tf operator: {}", + e.getMessage()); + } + } + + currentTextPos = + getCurrentTextPos( + tokens, segments, currentTextPos, graphicsState, i, opName); + } + } + + return segments; + } + + private String buildCompleteText(List segments) { + StringBuilder sb = new StringBuilder(); + for (TextSegment segment : segments) { + sb.append(segment.text); + } + return sb.toString(); + } + + private List findAllMatches( + String completeText, + Set targetWords, + boolean useRegex, + boolean wholeWordSearch) { + + // Use the new utility for creating optimized patterns + List patterns = + TextFinderUtils.createOptimizedSearchPatterns( + targetWords, useRegex, wholeWordSearch); + + return patterns.stream() + .flatMap( + pattern -> { + try { + return pattern.matcher(completeText).results(); + } catch (Exception e) { + log.debug( + "Pattern matching failed for pattern {}: {}", + pattern.pattern(), + e.getMessage()); + return java.util.stream.Stream.empty(); + } + }) + .map(matchResult -> new MatchRange(matchResult.start(), matchResult.end())) + .sorted(Comparator.comparingInt(MatchRange::getStartPos)) + .collect(Collectors.toList()); + } + + private List applyRedactionsToTokens( + List tokens, List textSegments, List matches) { + + long startTime = System.currentTimeMillis(); + + try { + List newTokens = new ArrayList<>(tokens); + + Map> matchesBySegment = new HashMap<>(); + for (MatchRange match : matches) { + for (int i = 0; i < textSegments.size(); i++) { + TextSegment segment = textSegments.get(i); + int overlapStart = Math.max(match.startPos, segment.startPos); + int overlapEnd = Math.min(match.endPos, segment.endPos); + if (overlapStart < overlapEnd) { + matchesBySegment.computeIfAbsent(i, k -> new ArrayList<>()).add(match); + } + } + } + + List tasks = new ArrayList<>(); + for (Map.Entry> entry : matchesBySegment.entrySet()) { + int segmentIndex = entry.getKey(); + List segmentMatches = entry.getValue(); + TextSegment segment = textSegments.get(segmentIndex); + + if ("Tj".equals(segment.operatorName) || "'".equals(segment.operatorName)) { + String newText = applyRedactionsToSegmentText(segment, segmentMatches); + try { + float adjustment = calculateWidthAdjustment(segment, segmentMatches); + tasks.add(new ModificationTask(segment, newText, adjustment)); + } catch (Exception e) { + log.debug( + "Width adjustment calculation failed for segment: {}", + e.getMessage()); + } + } else if ("TJ".equals(segment.operatorName)) { + tasks.add(new ModificationTask(segment, null, 0)); + } + } + + tasks.sort((a, b) -> Integer.compare(b.segment.tokenIndex, a.segment.tokenIndex)); + + for (ModificationTask task : tasks) { + List segmentMatches = + matchesBySegment.getOrDefault( + textSegments.indexOf(task.segment), Collections.emptyList()); + modifyTokenForRedaction( + newTokens, task.segment, task.newText, task.adjustment, segmentMatches); + } + + return newTokens; + + } finally { + long processingTime = System.currentTimeMillis() - startTime; + log.debug( + "Token redaction processing completed in {} ms for {} matches", + processingTime, + matches.size()); + } + } + + @Data + @AllArgsConstructor + private static class ModificationTask { + private TextSegment segment; + private String newText; // Only for Tj + private float adjustment; // Only for Tj + } + + private String applyRedactionsToSegmentText(TextSegment segment, List matches) { + String text = segment.getText(); + + if (segment.getFont() != null + && !TextEncodingHelper.isTextSegmentRemovable(segment.getFont(), text)) { + log.debug( + "Skipping text segment '{}' - font {} cannot process this text reliably", + text, + segment.getFont().getName()); + return text; // Return original text unchanged + } + + StringBuilder result = new StringBuilder(text); + + for (MatchRange match : matches) { + int segmentStart = Math.max(0, match.getStartPos() - segment.getStartPos()); + int segmentEnd = Math.min(text.length(), match.getEndPos() - segment.getStartPos()); + + if (segmentStart < text.length() && segmentEnd > segmentStart) { + String originalPart = text.substring(segmentStart, segmentEnd); + + if (segment.getFont() != null + && !TextEncodingHelper.isTextSegmentRemovable( + segment.getFont(), originalPart)) { + log.debug( + "Skipping text part '{}' within segment - cannot be processed reliably", + originalPart); + continue; // Skip this match, process others + } + + float originalWidth = 0; + if (segment.getFont() != null && segment.getFontSize() > 0) { + try { + originalWidth = + safeGetStringWidth(segment.getFont(), originalPart) + / FONT_SCALE_FACTOR + * segment.getFontSize(); + } catch (Exception e) { + log.debug( + "Failed to calculate original width for placeholder: {}", + e.getMessage()); + } + } + + String placeholder = + (originalWidth > 0) + ? createPlaceholderWithWidth( + originalPart, + originalWidth, + segment.getFont(), + segment.getFontSize()) + : createPlaceholderWithFont(originalPart, segment.getFont()); + + result.replace(segmentStart, segmentEnd, placeholder); + } + } + + return result.toString(); + } + + private float safeGetStringWidth(PDFont font, String text) { + if (font == null || text == null || text.isEmpty()) { + return 0; + } + + if (!WidthCalculator.isWidthCalculationReliable(font)) { + log.debug( + "Font {} flagged as unreliable for width calculation, using fallback", + font.getName()); + return calculateConservativeWidth(font, text); + } + + if (!TextEncodingHelper.canEncodeCharacters(font, text)) { + log.debug( + "Text cannot be encoded by font {}, using character-based fallback", + font.getName()); + return calculateCharacterBasedWidth(font, text); + } + + try { + float width = font.getStringWidth(text); + log.debug("Direct width calculation successful for '{}': {}", text, width); + return width; + + } catch (Exception e) { + log.debug( + "Direct width calculation failed for font {}: {}", + font.getName(), + e.getMessage()); + return calculateFallbackWidth(font, text); + } + } + + private float calculateCharacterBasedWidth(PDFont font, String text) { + try { + float totalWidth = 0; + for (int i = 0; i < text.length(); i++) { + String character = text.substring(i, i + 1); + try { + // Validate character encoding first + if (!TextEncodingHelper.fontSupportsCharacter(font, character)) { + totalWidth += font.getAverageFontWidth(); + continue; + } + + byte[] encoded = font.encode(character); + if (encoded.length > 0) { + int glyphCode = encoded[0] & 0xFF; + float glyphWidth = font.getWidth(glyphCode); + + // Try alternative width methods if primary fails + if (glyphWidth == 0) { + try { + glyphWidth = font.getWidthFromFont(glyphCode); + } catch (Exception e2) { + glyphWidth = font.getAverageFontWidth(); + } + } + + totalWidth += glyphWidth; + } else { + totalWidth += font.getAverageFontWidth(); + } + } catch (Exception e2) { + // Character processing failed, use average width + totalWidth += font.getAverageFontWidth(); + } + } + + log.debug("Character-based width calculation: {}", totalWidth); + return totalWidth; + + } catch (Exception e) { + log.debug("Character-based width calculation failed: {}", e.getMessage()); + return calculateConservativeWidth(font, text); + } + } + + private float calculateFallbackWidth(PDFont font, String text) { + try { + // Method 1: Font bounding box approach + if (font.getFontDescriptor() != null + && font.getFontDescriptor().getFontBoundingBox() != null) { + + PDRectangle bbox = font.getFontDescriptor().getFontBoundingBox(); + float avgCharWidth = bbox.getWidth() * 0.6f; // Conservative estimate + float fallbackWidth = text.length() * avgCharWidth; + + log.debug("Bounding box fallback width: {}", fallbackWidth); + return fallbackWidth; + } + + // Method 2: Average font width + try { + float avgWidth = font.getAverageFontWidth(); + if (avgWidth > 0) { + float fallbackWidth = text.length() * avgWidth; + log.debug("Average width fallback: {}", fallbackWidth); + return fallbackWidth; + } + } catch (Exception e2) { + log.debug("Average font width calculation failed: {}", e2.getMessage()); + } + + // Method 3: Conservative estimate based on font metrics + return calculateConservativeWidth(font, text); + + } catch (Exception e) { + log.debug("Fallback width calculation failed: {}", e.getMessage()); + return calculateConservativeWidth(font, text); + } + } + + private float calculateConservativeWidth(PDFont font, String text) { + float conservativeWidth = text.length() * 500f; + + log.debug( + "Conservative width estimate for font {} text '{}': {}", + font.getName(), + text, + conservativeWidth); + return conservativeWidth; + } + + private float calculateWidthAdjustment(TextSegment segment, List matches) { + try { + if (segment.getFont() == null || segment.getFontSize() <= 0) { + return 0; + } + + String fontName = segment.getFont().getName(); + if (fontName != null + && (fontName.contains("HOEPAP") || TextEncodingHelper.isFontSubset(fontName))) { + log.debug("Skipping width adjustment for problematic/subset font: {}", fontName); + return 0; + } + + float totalOriginal = 0; + float totalPlaceholder = 0; + + String text = segment.getText(); + + for (MatchRange match : matches) { + int segStart = Math.max(0, match.getStartPos() - segment.getStartPos()); + int segEnd = Math.min(text.length(), match.getEndPos() - segment.getStartPos()); + + if (segStart < text.length() && segEnd > segStart) { + String originalPart = text.substring(segStart, segEnd); + + float originalWidth = + safeGetStringWidth(segment.getFont(), originalPart) + / FONT_SCALE_FACTOR + * segment.getFontSize(); + + String placeholderPart = + createPlaceholderWithWidth( + originalPart, + originalWidth, + segment.getFont(), + segment.getFontSize()); + + float origUnits = safeGetStringWidth(segment.getFont(), originalPart); + float placeUnits = safeGetStringWidth(segment.getFont(), placeholderPart); + + float orig = (origUnits / FONT_SCALE_FACTOR) * segment.getFontSize(); + float place = (placeUnits / FONT_SCALE_FACTOR) * segment.getFontSize(); + + totalOriginal += orig; + totalPlaceholder += place; + } + } + + float adjustment = totalOriginal - totalPlaceholder; + + float maxReasonableAdjustment = + Math.max( + segment.getText().length() * segment.getFontSize() * 2, + totalOriginal * 1.5f // Allow up to 50% more than original width + ); + + if (Math.abs(adjustment) > maxReasonableAdjustment) { + log.debug( + "Width adjustment {} seems unreasonable for text length {}, capping to 0", + adjustment, + segment.getText().length()); + return 0; + } + + return adjustment; + } catch (Exception ex) { + log.debug("Width adjustment failed: {}", ex.getMessage()); + return 0; + } + } + + private void modifyTokenForRedaction( + List tokens, + TextSegment segment, + String newText, + float adjustment, + List matches) { + + if (segment.getTokenIndex() < 0 || segment.getTokenIndex() >= tokens.size()) { + return; + } + + Object token = tokens.get(segment.getTokenIndex()); + String operatorName = segment.getOperatorName(); + + try { + if (("Tj".equals(operatorName) || "'".equals(operatorName)) + && token instanceof COSString) { + + if (Math.abs(adjustment) < PRECISION_THRESHOLD) { + if (newText.isEmpty()) { + tokens.set(segment.getTokenIndex(), EMPTY_COS_STRING); + } else { + tokens.set(segment.getTokenIndex(), new COSString(newText)); + } + } else { + COSArray newArray = new COSArray(); + newArray.add(new COSString(newText)); + if (segment.getFontSize() > 0) { + + float kerning = (-adjustment / segment.getFontSize()) * FONT_SCALE_FACTOR; + + newArray.add(new COSFloat(kerning)); + } + tokens.set(segment.getTokenIndex(), newArray); + + int operatorIndex = segment.getTokenIndex() + 1; + if (operatorIndex < tokens.size() + && tokens.get(operatorIndex) instanceof Operator op + && op.getName().equals(operatorName)) { + tokens.set(operatorIndex, Operator.getOperator("TJ")); + } + } + } else if ("TJ".equals(operatorName) && token instanceof COSArray) { + COSArray newArray = createRedactedTJArray((COSArray) token, segment, matches); + tokens.set(segment.getTokenIndex(), newArray); + } + } catch (Exception e) { + log.debug( + "Token modification failed for segment at index {}: {}", + segment.getTokenIndex(), + e.getMessage()); + } + } + + private COSArray createRedactedTJArray( + COSArray originalArray, TextSegment segment, List matches) { + try { + COSArray newArray = new COSArray(); + int textOffsetInSegment = 0; + + for (COSBase element : originalArray) { + if (element instanceof COSString cosString) { + String originalText = cosString.getString(); + + if (segment.getFont() != null + && !TextEncodingHelper.isTextSegmentRemovable( + segment.getFont(), originalText)) { + log.debug( + "Skipping TJ text part '{}' - cannot be processed reliably with font {}", + originalText, + segment.getFont().getName()); + newArray.add(element); // Keep original unchanged + textOffsetInSegment += originalText.length(); + continue; + } + + StringBuilder newText = new StringBuilder(originalText); + boolean modified = false; + + for (MatchRange match : matches) { + int stringStartInPage = segment.getStartPos() + textOffsetInSegment; + int stringEndInPage = stringStartInPage + originalText.length(); + + int overlapStart = Math.max(match.getStartPos(), stringStartInPage); + int overlapEnd = Math.min(match.getEndPos(), stringEndInPage); + + if (overlapStart < overlapEnd) { + int redactionStartInString = overlapStart - stringStartInPage; + int redactionEndInString = overlapEnd - stringStartInPage; + if (redactionStartInString >= 0 + && redactionEndInString <= originalText.length()) { + String originalPart = + originalText.substring( + redactionStartInString, redactionEndInString); + + if (segment.getFont() != null + && !TextEncodingHelper.isTextSegmentRemovable( + segment.getFont(), originalPart)) { + log.debug( + "Skipping TJ text part '{}' - cannot be redacted reliably", + originalPart); + continue; // Skip this redaction, keep original text + } + + modified = true; + float originalWidth = 0; + if (segment.getFont() != null && segment.getFontSize() > 0) { + try { + originalWidth = + safeGetStringWidth(segment.getFont(), originalPart) + / FONT_SCALE_FACTOR + * segment.getFontSize(); + } catch (Exception e) { + log.debug( + "Failed to calculate original width for TJ placeholder: {}", + e.getMessage()); + } + } + + String placeholder = + (originalWidth > 0) + ? createPlaceholderWithWidth( + originalPart, + originalWidth, + segment.getFont(), + segment.getFontSize()) + : createPlaceholderWithFont( + originalPart, segment.getFont()); + + newText.replace( + redactionStartInString, redactionEndInString, placeholder); + } + } + } + + String modifiedString = newText.toString(); + newArray.add(new COSString(modifiedString)); + + if (modified && segment.getFont() != null && segment.getFontSize() > 0) { + try { + float originalWidth = + safeGetStringWidth(segment.getFont(), originalText) + / FONT_SCALE_FACTOR + * segment.getFontSize(); + float modifiedWidth = + safeGetStringWidth(segment.getFont(), modifiedString) + / FONT_SCALE_FACTOR + * segment.getFontSize(); + float adjustment = originalWidth - modifiedWidth; + if (Math.abs(adjustment) > PRECISION_THRESHOLD) { + float kerning = + (-adjustment / segment.getFontSize()) + * FONT_SCALE_FACTOR + * 1.10f; + + newArray.add(new COSFloat(kerning)); + } + } catch (Exception e) { + log.debug( + "Width adjustment calculation failed for segment: {}", + e.getMessage()); + } + } + + textOffsetInSegment += originalText.length(); + } else { + newArray.add(element); + } + } + return newArray; + } catch (Exception e) { + return originalArray; + } + } + + private String extractTextFromToken(Object token, String operatorName) { + return switch (operatorName) { + case "Tj", "'" -> { + if (token instanceof COSString cosString) { + yield cosString.getString(); + } + yield ""; + } + case "TJ" -> { + if (token instanceof COSArray cosArray) { + StringBuilder sb = new StringBuilder(); + for (COSBase element : cosArray) { + if (element instanceof COSString cosString) { + sb.append(cosString.getString()); + } + } + yield sb.toString(); + } + yield ""; + } + default -> ""; + }; + } + + private boolean detectCustomEncodingFonts(PDDocument document) { + try { + var documentCatalog = document.getDocumentCatalog(); + if (documentCatalog == null) { + return false; + } + + int totalFonts = 0; + int customEncodedFonts = 0; + int subsetFonts = 0; + int unreliableFonts = 0; + + for (PDPage page : document.getPages()) { + if (TextFinderUtils.hasProblematicFonts(page)) { + log.debug("Page contains fonts flagged as problematic by TextFinderUtils"); + } + + PDResources resources = page.getResources(); + if (resources == null) { + continue; + } + + for (COSName fontName : resources.getFontNames()) { + try { + PDFont font = resources.getFont(fontName); + if (font != null) { + totalFonts++; + + // Enhanced analysis using helper classes + boolean isSubset = TextEncodingHelper.isFontSubset(font.getName()); + boolean hasCustomEncoding = TextEncodingHelper.hasCustomEncoding(font); + boolean isReliable = WidthCalculator.isWidthCalculationReliable(font); + boolean canCalculateWidths = + TextEncodingHelper.canCalculateBasicWidths(font); + + if (isSubset) { + subsetFonts++; + } + + if (hasCustomEncoding) { + customEncodedFonts++; + log.debug("Font {} has custom encoding", font.getName()); + } + + if (!isReliable || !canCalculateWidths) { + unreliableFonts++; + log.debug( + "Font {} flagged as unreliable: reliable={}, canCalculateWidths={}", + font.getName(), + isReliable, + canCalculateWidths); + } + + if (!TextFinderUtils.validateFontReliability(font)) { + log.debug( + "Font {} failed comprehensive reliability check", + font.getName()); + } + } + } catch (Exception e) { + log.debug( + "Font loading/analysis failed for {}: {}", + fontName.getName(), + e.getMessage()); + customEncodedFonts++; + unreliableFonts++; + totalFonts++; + } + } + } + + log.info( + "Enhanced font analysis: {}/{} custom encoding, {}/{} subset, {}/{} unreliable fonts", + customEncodedFonts, + totalFonts, + subsetFonts, + totalFonts, + unreliableFonts, + totalFonts); + + // Consider document problematic if we have custom encodings or unreliable fonts + return customEncodedFonts > 0 || unreliableFonts > 0; + + } catch (Exception e) { + log.warn("Enhanced font detection analysis failed: {}", e.getMessage()); + return true; // Assume problematic if analysis fails + } + } + + private void processFormXObject( + PDDocument document, + PDFormXObject formXObject, + Set targetWords, + boolean useRegex, + boolean wholeWordSearch) { + + try { + PDResources xobjResources = formXObject.getResources(); + if (xobjResources == null) { + return; + } + + for (COSName xobjName : xobjResources.getXObjectNames()) { + PDXObject nestedXObj = xobjResources.getXObject(xobjName); + if (nestedXObj instanceof PDFormXObject nestedFormXObj) { + processFormXObject( + document, nestedFormXObj, targetWords, useRegex, wholeWordSearch); + } + } + + PDFStreamParser parser = new PDFStreamParser(formXObject); + List tokens = new ArrayList<>(); + Object token; + while ((token = parser.parseNextToken()) != null) { + tokens.add(token); + } + + List textSegments = extractTextSegmentsFromXObject(xobjResources, tokens); + String completeText = buildCompleteText(textSegments); + + List matches = + findAllMatches(completeText, targetWords, useRegex, wholeWordSearch); + + if (!matches.isEmpty()) { + List redactedTokens = + applyRedactionsToTokens(tokens, textSegments, matches); + writeRedactedContentToXObject(document, formXObject, redactedTokens); + log.debug("Processed {} redactions in Form XObject", matches.size()); + } + + } catch (Exception e) { + log.warn("Failed to process Form XObject: {}", e.getMessage()); + } + } + + private List extractTextSegmentsFromXObject( + PDResources resources, List tokens) { + List segments = new ArrayList<>(); + int currentTextPos = 0; + GraphicsState graphicsState = new GraphicsState(); + + for (int i = 0; i < tokens.size(); i++) { + Object currentToken = tokens.get(i); + + if (currentToken instanceof Operator op) { + String opName = op.getName(); + + if ("Tf".equals(opName) && i >= 2) { + try { + COSName fontName = (COSName) tokens.get(i - 2); + COSBase fontSizeBase = (COSBase) tokens.get(i - 1); + if (fontSizeBase instanceof COSNumber cosNumber) { + graphicsState.setFont(resources.getFont(fontName)); + graphicsState.setFontSize(cosNumber.floatValue()); + } + } catch (ClassCastException | IOException e) { + log.debug("Font extraction failed in XObject: {}", e.getMessage()); + } + } + + currentTextPos = + getCurrentTextPos( + tokens, segments, currentTextPos, graphicsState, i, opName); + } + } + + return segments; + } + + private int getCurrentTextPos( + List tokens, + List segments, + int currentTextPos, + GraphicsState graphicsState, + int i, + String opName) { + if (isTextShowingOperator(opName) && i > 0) { + String textContent = extractTextFromToken(tokens.get(i - 1), opName); + if (!textContent.isEmpty()) { + segments.add( + new TextSegment( + i - 1, + opName, + textContent, + currentTextPos, + currentTextPos + textContent.length(), + graphicsState.font, + graphicsState.fontSize)); + currentTextPos += textContent.length(); + } + } + return currentTextPos; + } + + private void writeRedactedContentToXObject( + PDDocument document, PDFormXObject formXObject, List redactedTokens) + throws IOException { + + PDStream newStream = new PDStream(document); + + try (var out = newStream.createOutputStream()) { + ContentStreamWriter writer = new ContentStreamWriter(out); + writer.writeTokens(redactedTokens); + } + + formXObject.getCOSObject().removeItem(COSName.CONTENTS); + formXObject.getCOSObject().setItem(COSName.CONTENTS, newStream.getCOSObject()); + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/pdf/TextFinder.java b/app/core/src/main/java/stirling/software/SPDF/pdf/TextFinder.java index 4119b3eac..432fad101 100644 --- a/app/core/src/main/java/stirling/software/SPDF/pdf/TextFinder.java +++ b/app/core/src/main/java/stirling/software/SPDF/pdf/TextFinder.java @@ -6,7 +6,7 @@ import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; -import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.text.PDFTextStripper; import org.apache.pdfbox.text.TextPosition; @@ -17,91 +17,200 @@ import stirling.software.SPDF.model.PDFText; @Slf4j public class TextFinder extends PDFTextStripper { - private final String searchText; + private final String searchTerm; private final boolean useRegex; private final boolean wholeWordSearch; - private final List textOccurrences = new ArrayList<>(); + private final List foundTexts = new ArrayList<>(); - public TextFinder(String searchText, boolean useRegex, boolean wholeWordSearch) + private final List pageTextPositions = new ArrayList<>(); + private final StringBuilder pageTextBuilder = new StringBuilder(); + + public TextFinder(String searchTerm, boolean useRegex, boolean wholeWordSearch) throws IOException { - this.searchText = searchText.toLowerCase(); + super(); + this.searchTerm = searchTerm; this.useRegex = useRegex; this.wholeWordSearch = wholeWordSearch; - setSortByPosition(true); + this.setWordSeparator(" "); } - private List findOccurrencesInText(String searchText, String content) { - List matches = new ArrayList<>(); - - Pattern pattern; - - if (useRegex) { - // Use regex-based search - pattern = - wholeWordSearch - ? Pattern.compile("\\b" + searchText + "\\b") - : Pattern.compile(searchText); - } else { - // Use normal text search - pattern = - wholeWordSearch - ? Pattern.compile("\\b" + Pattern.quote(searchText) + "\\b") - : Pattern.compile(Pattern.quote(searchText)); - } - - Matcher matcher = pattern.matcher(content); - while (matcher.find()) { - matches.add(new MatchInfo(matcher.start(), matcher.end() - matcher.start())); - } - return matches; + @Override + protected void startPage(PDPage page) throws IOException { + super.startPage(page); + pageTextPositions.clear(); + pageTextBuilder.setLength(0); } @Override protected void writeString(String text, List textPositions) { - for (MatchInfo match : findOccurrencesInText(searchText, text.toLowerCase())) { - int index = match.startIndex; - if (index + match.matchLength <= textPositions.size()) { - // Initial values based on the first character - TextPosition first = textPositions.get(index); - float minX = first.getX(); - float minY = first.getY(); - float maxX = first.getX() + first.getWidth(); - float maxY = first.getY() + first.getHeight(); + pageTextBuilder.append(text); + pageTextPositions.addAll(textPositions); + } - // Loop over the rest of the characters and adjust bounding box values - for (int i = index; i < index + match.matchLength; i++) { - TextPosition position = textPositions.get(i); - minX = Math.min(minX, position.getX()); - minY = Math.min(minY, position.getY()); - maxX = Math.max(maxX, position.getX() + position.getWidth()); - maxY = Math.max(maxY, position.getY() + position.getHeight()); - } + @Override + protected void writeWordSeparator() { + pageTextBuilder.append(getWordSeparator()); + pageTextPositions.add(null); // Placeholder for separator + } - textOccurrences.add( - new PDFText(getCurrentPageNo() - 1, minX, minY, maxX, maxY, text)); + @Override + protected void writeLineSeparator() { + pageTextBuilder.append(getLineSeparator()); + pageTextPositions.add(null); // Placeholder for separator + } + + @Override + protected void endPage(PDPage page) throws IOException { + String text = pageTextBuilder.toString(); + if (text.isEmpty() || this.searchTerm == null || this.searchTerm.isEmpty()) { + super.endPage(page); + return; + } + + String processedSearchTerm = this.searchTerm.trim(); + String regex = this.useRegex ? processedSearchTerm : "\\Q" + processedSearchTerm + "\\E"; + if (this.wholeWordSearch) { + if (processedSearchTerm.length() == 1 + && Character.isDigit(processedSearchTerm.charAt(0))) { + regex = "(? getTextLocations(PDDocument document) throws Exception { - this.getText(document); + Pattern pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE); + Matcher matcher = pattern.matcher(text); + log.debug( - "Found " - + textOccurrences.size() - + " occurrences of '" - + searchText - + "' in the document."); + "Searching for '{}' in page {} with regex '{}' (wholeWord: {}, useRegex: {})", + processedSearchTerm, + getCurrentPageNo(), + regex, + wholeWordSearch, + useRegex); - return textOccurrences; + int matchCount = 0; + while (matcher.find()) { + matchCount++; + int matchStart = matcher.start(); + int matchEnd = matcher.end(); + + log.debug( + "Found match #{} at positions {}-{}: '{}'", + matchCount, + matchStart, + matchEnd, + matcher.group()); + + float minX = Float.MAX_VALUE; + float minY = Float.MAX_VALUE; + float maxX = Float.MIN_VALUE; + float maxY = Float.MIN_VALUE; + boolean foundPosition = false; + + for (int i = matchStart; i < matchEnd; i++) { + if (i >= pageTextPositions.size()) { + log.debug( + "Position index {} exceeds available positions ({})", + i, + pageTextPositions.size()); + continue; + } + TextPosition pos = pageTextPositions.get(i); + if (pos != null) { + foundPosition = true; + minX = Math.min(minX, pos.getX()); + maxX = Math.max(maxX, pos.getX() + pos.getWidth()); + minY = Math.min(minY, pos.getY() - pos.getHeight()); + maxY = Math.max(maxY, pos.getY()); + } + } + + if (!foundPosition && matchStart < pageTextPositions.size()) { + log.debug( + "Attempting to find nearby positions for match at {}-{}", + matchStart, + matchEnd); + + for (int i = Math.max(0, matchStart - 5); + i < Math.min(pageTextPositions.size(), matchEnd + 5); + i++) { + TextPosition pos = pageTextPositions.get(i); + if (pos != null) { + foundPosition = true; + minX = Math.min(minX, pos.getX()); + maxX = Math.max(maxX, pos.getX() + pos.getWidth()); + minY = Math.min(minY, pos.getY() - pos.getHeight()); + maxY = Math.max(maxY, pos.getY()); + break; + } + } + } + + if (foundPosition) { + foundTexts.add( + new PDFText( + this.getCurrentPageNo() - 1, + minX, + minY, + maxX, + maxY, + matcher.group())); + log.debug( + "Added PDFText for match: page={}, bounds=({},{},{},{}), text='{}'", + getCurrentPageNo() - 1, + minX, + minY, + maxX, + maxY, + matcher.group()); + } else { + log.warn( + "Found text match '{}' but no valid position data at {}-{}", + matcher.group(), + matchStart, + matchEnd); + } + } + + log.debug( + "Page {} search complete: found {} matches for '{}'", + getCurrentPageNo(), + matchCount, + processedSearchTerm); + + super.endPage(page); } - private class MatchInfo { - int startIndex; - int matchLength; + public List getFoundTexts() { + return foundTexts; + } - MatchInfo(int startIndex, int matchLength) { - this.startIndex = startIndex; - this.matchLength = matchLength; + public String getDebugInfo() { + StringBuilder debug = new StringBuilder(); + debug.append("Extracted text length: ").append(pageTextBuilder.length()).append("\n"); + debug.append("Position count: ").append(pageTextPositions.size()).append("\n"); + debug.append("Text content: '") + .append(pageTextBuilder.toString().replace("\n", "\\n").replace("\r", "\\r")) + .append("'\n"); + + String text = pageTextBuilder.toString(); + for (int i = 0; i < Math.min(text.length(), 50); i++) { + char c = text.charAt(i); + TextPosition pos = i < pageTextPositions.size() ? pageTextPositions.get(i) : null; + debug.append( + String.format( + " [%d] '%c' (0x%02X) -> %s\n", + i, + c, + (int) c, + pos != null + ? String.format("(%.1f,%.1f)", pos.getX(), pos.getY()) + : "null")); } + + return debug.toString(); } } diff --git a/app/core/src/main/java/stirling/software/SPDF/utils/text/TextEncodingHelper.java b/app/core/src/main/java/stirling/software/SPDF/utils/text/TextEncodingHelper.java new file mode 100644 index 000000000..4292e6c52 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/utils/text/TextEncodingHelper.java @@ -0,0 +1,351 @@ +package stirling.software.SPDF.utils.text; + +import java.io.IOException; + +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDSimpleFont; +import org.apache.pdfbox.pdmodel.font.encoding.DictionaryEncoding; +import org.apache.pdfbox.pdmodel.font.encoding.Encoding; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class TextEncodingHelper { + + public static boolean canEncodeCharacters(PDFont font, String text) { + if (font == null || text == null || text.isEmpty()) { + return false; + } + + try { + // Step 1: Primary check - full-string encoding (permissive for "good" cases) + byte[] encoded = font.encode(text); + if (encoded.length > 0) { + log.debug( + "Text '{}' has good full-string encoding for font {} - permissively allowing", + text, + font.getName() != null ? font.getName() : "Unknown"); + return true; + } + + // Step 2: Smart array-based fallback for TJ operator-style text + log.debug( + "Full encoding failed for '{}' - using array-based fallback for font {}", + text, + font.getName() != null ? font.getName() : "Unknown"); + + return validateAsCodePointArray(font, text); + + } catch (IOException | IllegalArgumentException e) { + log.debug( + "Encoding exception for text '{}' with font {} - trying array fallback: {}", + text, + font.getName() != null ? font.getName() : "Unknown", + e.getMessage()); + + if (isFontSubset(font.getName()) || hasCustomEncoding(font)) { + return validateAsCodePointArray(font, text); + } + + return false; // Non-subset fonts with encoding exceptions are likely problematic + } + } + + private static boolean validateAsCodePointArray(PDFont font, String text) { + int totalCodePoints = 0; + int successfulCodePoints = 0; + + // Iterate through code points (handles surrogates correctly per Unicode docs) + for (int i = 0; i < text.length(); ) { + int codePoint = text.codePointAt(i); + String charStr = new String(Character.toChars(codePoint)); + totalCodePoints++; + + try { + // Test encoding for this code point + byte[] charEncoded = font.encode(charStr); + if (charEncoded.length > 0) { + float charWidth = font.getStringWidth(charStr); + + if (charWidth >= 0) { + successfulCodePoints++; + log.debug( + "Code point '{}' (U+{}) encoded successfully", + charStr, + Integer.toHexString(codePoint).toUpperCase()); + } else { + log.debug( + "Code point '{}' (U+{}) has invalid width: {}", + charStr, + Integer.toHexString(codePoint).toUpperCase(), + charWidth); + } + } else { + log.debug( + "Code point '{}' (U+{}) encoding failed - empty result", + charStr, + Integer.toHexString(codePoint).toUpperCase()); + } + } catch (IOException | IllegalArgumentException e) { + log.debug( + "Code point '{}' (U+{}) validation failed: {}", + charStr, + Integer.toHexString(codePoint).toUpperCase(), + e.getMessage()); + } + + i += Character.charCount(codePoint); // Handle surrogates properly + } + + double successRate = + totalCodePoints > 0 ? (double) successfulCodePoints / totalCodePoints : 0; + boolean isAcceptable = successRate >= 0.95; + + log.debug( + "Array validation for '{}': {}/{} code points successful ({:.1f}%) - {}", + text, + successfulCodePoints, + totalCodePoints, + successRate * 100, + isAcceptable ? "ALLOWING" : "rejecting"); + + return isAcceptable; + } + + public static boolean isTextSegmentRemovable(PDFont font, String text) { + if (font == null || text == null || text.isEmpty()) { + return false; + } + + // Log the attempt + log.debug( + "Evaluating text segment for removal: '{}' with font {}", + text, + font.getName() != null ? font.getName() : "Unknown Font"); + + if (isSimpleCharacter(text)) { + try { + font.encode(text); + font.getStringWidth(text); + log.debug( + "Text '{}' is a simple character and passed validation - allowing removal", + text); + return true; + } catch (Exception e) { + log.debug( + "Simple character '{}' failed basic validation with font {}: {}", + text, + font.getName() != null ? font.getName() : "Unknown", + e.getMessage()); + return false; + } + } + + // For complex text, require comprehensive validation + return isTextFullyRemovable(font, text); + } + + public static boolean isTextFullyRemovable(PDFont font, String text) { + if (font == null || text == null || text.isEmpty()) { + return false; + } + + try { + // Check 1: Verify encoding capability using new smart approach + if (!canEncodeCharacters(font, text)) { + log.debug( + "Text '{}' failed encoding validation for font {}", + text, + font.getName() != null ? font.getName() : "Unknown"); + return false; + } + + // Check 2: Validate width calculation capability + float width = font.getStringWidth(text); + if (width < 0) { // Allow zero width (invisible chars) but reject negative (invalid) + log.debug( + "Text '{}' has invalid width {} for font {}", + text, + width, + font.getName() != null ? font.getName() : "Unknown"); + return false; // Invalid metrics prevent accurate removal + } + + // Check 3: Verify font descriptor completeness for redaction area calculation + if (font.getFontDescriptor() == null) { + log.debug( + "Missing font descriptor for font {}", + font.getName() != null ? font.getName() : "Unknown"); + return false; + } + + // Check 4: Test bounding box calculation for redaction area + try { + font.getFontDescriptor().getFontBoundingBox(); + } catch (IllegalArgumentException e) { + log.debug( + "Font bounding box unavailable for font {}: {}", + font.getName() != null ? font.getName() : "Unknown", + e.getMessage()); + return false; + } + + log.debug( + "Text '{}' passed comprehensive validation for font {}", + text, + font.getName() != null ? font.getName() : "Unknown"); + return true; + + } catch (IOException e) { + log.debug( + "Text '{}' failed validation for font {} due to IO error: {}", + text, + font.getName() != null ? font.getName() : "Unknown", + e.getMessage()); + return false; + } catch (IllegalArgumentException e) { + log.debug( + "Text '{}' failed validation for font {} due to argument error: {}", + text, + font.getName() != null ? font.getName() : "Unknown", + e.getMessage()); + return false; + } + } + + private static boolean isSimpleCharacter(String text) { + if (text == null || text.isEmpty()) { + return false; + } + + if (text.length() > 20) { + return false; + } + + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + + // Allow letters, digits, and whitespace (most common cases) + if (Character.isLetterOrDigit(c) || Character.isWhitespace(c)) { + continue; + } + + // Allow common ASCII punctuation + if (c >= 32 && c <= 126 && ".,!?;:()-[]{}\"'/@#$%&*+=<>|\\~`".indexOf(c) >= 0) { + continue; + } + + return false; + } + + return true; + } + + public static boolean hasCustomEncoding(PDFont font) { + try { + if (font instanceof PDSimpleFont simpleFont) { + try { + Encoding encoding = simpleFont.getEncoding(); + if (encoding != null) { + // Check for dictionary-based custom encodings + if (encoding instanceof DictionaryEncoding) { + log.debug("Font {} uses DictionaryEncoding (custom)", font.getName()); + return true; + } + + String encodingName = encoding.getClass().getSimpleName(); + if (encodingName.contains("Custom") + || encodingName.contains("Dictionary")) { + log.debug( + "Font {} uses custom encoding: {}", + font.getName(), + encodingName); + return true; + } + } + } catch (Exception e) { + log.debug( + "Encoding detection failed for font {}: {}", + font.getName(), + e.getMessage()); + return true; // Assume custom if detection fails + } + } + + if (font instanceof org.apache.pdfbox.pdmodel.font.PDType0Font) { + log.debug( + "Font {} is Type0 (CID) - generally uses standard CMaps", + font.getName() != null ? font.getName() : "Unknown"); + return false; + } + + log.debug( + "Font {} type {} - assuming standard encoding", + font.getName() != null ? font.getName() : "Unknown", + font.getClass().getSimpleName()); + return false; + + } catch (IllegalArgumentException e) { + log.debug( + "Custom encoding detection failed for font {}: {}", + font.getName() != null ? font.getName() : "Unknown", + e.getMessage()); + return false; // Be forgiving on detection failure + } + } + + public static boolean fontSupportsCharacter(PDFont font, String character) { + if (font == null || character == null || character.isEmpty()) { + return false; + } + + try { + byte[] encoded = font.encode(character); + if (encoded.length == 0) { + return false; + } + + float width = font.getStringWidth(character); + return width > 0; + + } catch (IOException | IllegalArgumentException e) { + log.debug( + "Character '{}' not supported by font {}: {}", + character, + font.getName() != null ? font.getName() : "Unknown", + e.getMessage()); + return false; + } + } + + public static boolean isFontSubset(String fontName) { + if (fontName == null) { + return false; + } + return fontName.matches("^[A-Z]{6}\\+.*"); + } + + public static boolean canCalculateBasicWidths(PDFont font) { + try { + float spaceWidth = font.getStringWidth(" "); + if (spaceWidth <= 0) { + return false; + } + + String[] testChars = {"a", "A", "0", ".", "e", "!"}; + for (String ch : testChars) { + try { + float width = font.getStringWidth(ch); + if (width > 0) { + return true; + } + } catch (IOException | IllegalArgumentException e) { + } + } + + return false; // Can't calculate width for any test characters + } catch (IOException | IllegalArgumentException e) { + return false; // Font failed basic width calculation + } + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/utils/text/TextFinderUtils.java b/app/core/src/main/java/stirling/software/SPDF/utils/text/TextFinderUtils.java new file mode 100644 index 000000000..4c7d86abd --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/utils/text/TextFinderUtils.java @@ -0,0 +1,140 @@ +package stirling.software.SPDF.utils.text; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDResources; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class TextFinderUtils { + + public static boolean validateFontReliability(org.apache.pdfbox.pdmodel.font.PDFont font) { + if (font == null) { + return false; + } + + if (font.isDamaged()) { + log.debug( + "Font {} is marked as damaged - using TextEncodingHelper validation", + font.getName()); + } + + if (TextEncodingHelper.canCalculateBasicWidths(font)) { + log.debug( + "Font {} passed basic width calculations - considering reliable", + font.getName()); + return true; + } + + String[] basicTests = {"1", "2", "3", "a", "A", "e", "E", " "}; + + int workingChars = 0; + for (String testChar : basicTests) { + if (TextEncodingHelper.canEncodeCharacters(font, testChar)) { + workingChars++; + } + } + + if (workingChars > 0) { + log.debug( + "Font {} can process {}/{} basic characters - considering reliable", + font.getName(), + workingChars, + basicTests.length); + return true; + } + + log.debug("Font {} failed all basic tests - considering unreliable", font.getName()); + return false; + } + + public static List createOptimizedSearchPatterns( + Set searchTerms, boolean useRegex, boolean wholeWordSearch) { + List patterns = new ArrayList<>(); + + for (String term : searchTerms) { + if (term == null || term.trim().isEmpty()) { + continue; + } + + try { + String patternString = useRegex ? term.trim() : Pattern.quote(term.trim()); + + if (wholeWordSearch) { + patternString = applyWordBoundaries(term.trim(), patternString); + } + + Pattern pattern = + Pattern.compile( + patternString, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE); + patterns.add(pattern); + + log.debug("Created search pattern: '{}' -> '{}'", term.trim(), patternString); + + } catch (Exception e) { + log.warn("Failed to create pattern for term '{}': {}", term, e.getMessage()); + } + } + + return patterns; + } + + private static String applyWordBoundaries(String originalTerm, String patternString) { + if (originalTerm.length() == 1 && Character.isDigit(originalTerm.charAt(0))) { + return "(? 0 && (completelyUnusableFonts * 2 > totalFonts); + log.debug( + "Page font analysis: {}/{} fonts are completely unusable - page {} problematic", + completelyUnusableFonts, + totalFonts, + hasProblems ? "IS" : "is NOT"); + + return hasProblems; + + } catch (Exception e) { + log.warn("Font analysis failed for page: {}", e.getMessage()); + return false; // Be permissive if analysis fails + } + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/utils/text/WidthCalculator.java b/app/core/src/main/java/stirling/software/SPDF/utils/text/WidthCalculator.java new file mode 100644 index 000000000..fde3809c4 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/utils/text/WidthCalculator.java @@ -0,0 +1,136 @@ +package stirling.software.SPDF.utils.text; + +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class WidthCalculator { + + private static final int FONT_SCALE_FACTOR = 1000; + + public static float calculateAccurateWidth(PDFont font, String text, float fontSize) { + if (font == null || text == null || text.isEmpty() || fontSize <= 0) { + return 0; + } + + if (!TextEncodingHelper.canEncodeCharacters(font, text)) { + log.debug( + "Text cannot be encoded by font {}, using fallback width calculation", + font.getName()); + return calculateFallbackWidth(font, text, fontSize); + } + + try { + float rawWidth = font.getStringWidth(text); + float scaledWidth = (rawWidth / FONT_SCALE_FACTOR) * fontSize; + + log.debug( + "Direct width calculation successful for font {}: {} -> {}", + font.getName(), + rawWidth, + scaledWidth); + return scaledWidth; + + } catch (Exception e) { + log.debug( + "Direct width calculation failed for font {}: {}", + font.getName(), + e.getMessage()); + return calculateWidthWithCharacterIteration(font, text, fontSize); + } + } + + private static float calculateWidthWithCharacterIteration( + PDFont font, String text, float fontSize) { + try { + float totalWidth = 0; + + for (int i = 0; i < text.length(); i++) { + String character = text.substring(i, i + 1); + try { + byte[] encoded = font.encode(character); + if (encoded.length > 0) { + int glyphCode = encoded[0] & 0xFF; + float glyphWidth = font.getWidth(glyphCode); + + if (glyphWidth == 0) { + try { + glyphWidth = font.getWidthFromFont(glyphCode); + } catch (Exception e2) { + glyphWidth = font.getAverageFontWidth(); + } + } + + totalWidth += (glyphWidth / FONT_SCALE_FACTOR) * fontSize; + } else { + totalWidth += (font.getAverageFontWidth() / FONT_SCALE_FACTOR) * fontSize; + } + } catch (Exception e2) { + totalWidth += (font.getAverageFontWidth() / FONT_SCALE_FACTOR) * fontSize; + } + } + + log.debug("Character iteration width calculation: {}", totalWidth); + return totalWidth; + + } catch (Exception e) { + log.debug("Character iteration failed: {}", e.getMessage()); + return calculateFallbackWidth(font, text, fontSize); + } + } + + private static float calculateFallbackWidth(PDFont font, String text, float fontSize) { + try { + if (font.getFontDescriptor() != null + && font.getFontDescriptor().getFontBoundingBox() != null) { + + PDRectangle bbox = font.getFontDescriptor().getFontBoundingBox(); + float avgCharWidth = + bbox.getWidth() / FONT_SCALE_FACTOR * 0.6f; // Conservative estimate + float fallbackWidth = text.length() * avgCharWidth * fontSize; + + log.debug("Bounding box fallback width: {}", fallbackWidth); + return fallbackWidth; + } + + float avgWidth = font.getAverageFontWidth(); + float fallbackWidth = (text.length() * avgWidth / FONT_SCALE_FACTOR) * fontSize; + + log.debug("Average width fallback: {}", fallbackWidth); + return fallbackWidth; + + } catch (Exception e) { + float conservativeWidth = text.length() * 0.5f * fontSize; + log.debug( + "Conservative fallback width for font {}: {}", + font.getName(), + conservativeWidth); + return conservativeWidth; + } + } + + public static boolean isWidthCalculationReliable(PDFont font) { + if (font == null) { + return false; + } + + if (font.isDamaged()) { + log.debug("Font {} is damaged", font.getName()); + return false; + } + + if (!TextEncodingHelper.canCalculateBasicWidths(font)) { + log.debug("Font {} cannot perform basic width calculations", font.getName()); + return false; + } + + if (TextEncodingHelper.hasCustomEncoding(font)) { + log.debug("Font {} has custom encoding", font.getName()); + return false; + } + + return true; + } +} diff --git a/stirling-pdf/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerTest.java b/stirling-pdf/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerTest.java new file mode 100644 index 000000000..3e83650d6 --- /dev/null +++ b/stirling-pdf/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerTest.java @@ -0,0 +1,1327 @@ +package stirling.software.SPDF.controller.api.security; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.awt.Color; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import org.apache.pdfbox.contentstream.operator.Operator; +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSFloat; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSString; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.PDPageTree; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.model.api.security.ManualRedactPdfRequest; +import stirling.software.SPDF.model.api.security.RedactPdfRequest; +import stirling.software.common.model.api.security.RedactionArea; +import stirling.software.common.service.CustomPDFDocumentFactory; + +@DisplayName("PDF Redaction Controller tests") +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class RedactControllerTest { + + private static final Logger log = LoggerFactory.getLogger(RedactControllerTest.class); + + @Mock + private CustomPDFDocumentFactory pdfDocumentFactory; + + @InjectMocks + private RedactController redactController; + + private MockMultipartFile mockPdfFile; + private PDDocument mockDocument; + private PDPageTree mockPages; + private PDPage mockPage; + + private PDDocument realDocument; + private PDPage realPage; + + // Helpers + private void testAutoRedaction(String searchText, boolean useRegex, boolean wholeWordSearch, + String redactColor, float padding, boolean convertToImage, + boolean expectSuccess) throws Exception { + RedactPdfRequest request = createRedactPdfRequest(); + request.setListOfText(searchText); + request.setUseRegex(useRegex); + request.setWholeWordSearch(wholeWordSearch); + request.setRedactColor(redactColor); + request.setCustomPadding(padding); + request.setConvertPDFToImage(convertToImage); + + try { + ResponseEntity response = redactController.redactPdf(request); + + if (expectSuccess && response != null) { + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().length > 0); + verify(mockDocument, times(1)).save(any(ByteArrayOutputStream.class)); + verify(mockDocument, times(1)).close(); + } + } catch (Exception e) { + if (expectSuccess) { + log.info("Redaction test completed with graceful handling: {}", e.getMessage()); + } else { + assertNotNull(e.getMessage()); + } + } + } + + private void testManualRedaction(List redactionAreas, boolean convertToImage) throws Exception { + ManualRedactPdfRequest request = createManualRedactPdfRequest(); + request.setRedactions(redactionAreas); + request.setConvertPDFToImage(convertToImage); + + try { + ResponseEntity response = redactController.redactPDF(request); + + if (response != null) { + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + verify(mockDocument, times(1)).save(any(ByteArrayOutputStream.class)); + } + } catch (Exception e) { + log.info("Manual redaction test completed with graceful handling: {}", e.getMessage()); + } + } + + @BeforeEach + void setUp() throws IOException { + mockPdfFile = new MockMultipartFile( + "fileInput", + "test.pdf", + "application/pdf", + createSimplePdfContent() + ); + + // Mock PDF document and related objects + mockDocument = mock(PDDocument.class); + mockPages = mock(PDPageTree.class); + mockPage = mock(PDPage.class); + org.apache.pdfbox.pdmodel.PDDocumentCatalog mockCatalog = mock(org.apache.pdfbox.pdmodel.PDDocumentCatalog.class); + + // Setup document structure properly + when(pdfDocumentFactory.load(any(MockMultipartFile.class))).thenReturn(mockDocument); + when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog); + when(mockCatalog.getPages()).thenReturn(mockPages); + when(mockDocument.getNumberOfPages()).thenReturn(1); + when(mockDocument.getPages()).thenReturn(mockPages); + + // Setup page tree + when(mockPages.getCount()).thenReturn(1); + when(mockPages.get(0)).thenReturn(mockPage); + when(mockPages.iterator()).thenReturn(Collections.singletonList(mockPage).iterator()); + + PDRectangle pageRect = new PDRectangle(0, 0, 612, 792); + when(mockPage.getCropBox()).thenReturn(pageRect); + when(mockPage.getMediaBox()).thenReturn(pageRect); + when(mockPage.getBBox()).thenReturn(pageRect); + + InputStream mockInputStream = new ByteArrayInputStream("BT /F1 12 Tf 100 200 Td (test content) Tj ET".getBytes()); + when(mockPage.getContents()).thenReturn(mockInputStream); + + when(mockPage.hasContents()).thenReturn(true); + + org.apache.pdfbox.cos.COSDocument mockCOSDocument = mock(org.apache.pdfbox.cos.COSDocument.class); + org.apache.pdfbox.cos.COSStream mockCOSStream = mock(org.apache.pdfbox.cos.COSStream.class); + when(mockDocument.getDocument()).thenReturn(mockCOSDocument); + when(mockCOSDocument.createCOSStream()).thenReturn(mockCOSStream); + + ByteArrayOutputStream mockOutputStream = new ByteArrayOutputStream(); + when(mockCOSStream.createOutputStream()).thenReturn(mockOutputStream); + when(mockCOSStream.createOutputStream(any())).thenReturn(mockOutputStream); + + doAnswer(invocation -> { + ByteArrayOutputStream baos = invocation.getArgument(0); + baos.write("Mock PDF Content".getBytes()); + return null; + }).when(mockDocument).save(any(ByteArrayOutputStream.class)); + doNothing().when(mockDocument).close(); + + // Initialize a real document for unit tests + setupRealDocument(); + } + + private void setupRealDocument() throws IOException { + realDocument = new PDDocument(); + realPage = new PDPage(PDRectangle.A4); + realDocument.addPage(realPage); + + // Set up basic page resources + PDResources resources = new PDResources(); + resources.put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + realPage.setResources(resources); + } + + @AfterEach + void tearDown() throws IOException { + reset(mockDocument, mockPages, mockPage, pdfDocumentFactory); + if (realDocument != null) { + realDocument.close(); + } + } + + @Nested + @DisplayName("Automatic Text Redaction") + class AutomaticRedactionTests { + + @Test + @DisplayName("Should redact basic text successfully") + void redactBasicText() throws Exception { + testAutoRedaction("confidential\nsecret", false, false, "#000000", 2.0f, false, true); + } + + @Test + @DisplayName("Should handle simple text redaction") + void handleSimpleTextRedaction() throws Exception { + testAutoRedaction("sensitive", false, false, "#000000", 1.0f, false, true); + } + + @Test + @DisplayName("Should handle empty text list gracefully") + void handleEmptyTextList() throws Exception { + testAutoRedaction("", false, false, "#000000", 1.0f, false, true); + } + + @Test + @DisplayName("Should redact multiple search terms") + void redactMultipleSearchTerms() throws Exception { + testAutoRedaction("confidential\nsecret\nprivate\nclassified", false, true, "#FF0000", 2.0f, false, true); + } + + @Test + @DisplayName("Should handle very large number of search terms") + void handleLargeNumberOfSearchTerms() throws Exception { + StringBuilder terms = new StringBuilder(); + for (int i = 0; i < 100; i++) { + terms.append("term").append(i).append("\n"); + } + testAutoRedaction(terms.toString(), false, false, "#000000", 1.0f, false, true); + } + + @Test + @DisplayName("Should handle complex document structure") + void handleComplexDocumentStructure() throws Exception { + when(mockPages.getCount()).thenReturn(5); + when(mockDocument.getNumberOfPages()).thenReturn(5); + + List pageList = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + PDPage page = mock(PDPage.class); + PDRectangle pageRect = new PDRectangle(0, 0, 612, 792); + when(page.getCropBox()).thenReturn(pageRect); + when(page.getMediaBox()).thenReturn(pageRect); + when(page.getBBox()).thenReturn(pageRect); + when(page.hasContents()).thenReturn(true); + + InputStream mockInputStream = new ByteArrayInputStream( + ("BT /F1 12 Tf 100 200 Td (page " + i + " content with confidential info) Tj ET").getBytes()); + when(page.getContents()).thenReturn(mockInputStream); + + pageList.add(page); + } + + when(mockPages.iterator()).thenReturn(pageList.iterator()); + for (int i = 0; i < 5; i++) { + when(mockPages.get(i)).thenReturn(pageList.get(i)); + } + + testAutoRedaction("confidential", false, false, "#000000", 1.0f, false, true); + + // Reset to original state + reset(mockPages); + when(mockPages.getCount()).thenReturn(1); + when(mockPages.get(0)).thenReturn(mockPage); + when(mockPages.iterator()).thenReturn(Collections.singletonList(mockPage).iterator()); + when(mockDocument.getNumberOfPages()).thenReturn(1); + } + + @Test + @DisplayName("Should handle document with metadata") + void handleDocumentWithMetadata() throws Exception { + RedactPdfRequest request = createRedactPdfRequest(); + request.setListOfText("confidential"); + request.setUseRegex(false); + request.setWholeWordSearch(false); + request.setRedactColor("#000000"); + request.setCustomPadding(1.0f); + request.setConvertPDFToImage(false); + + when(mockPages.get(0)).thenReturn(mockPage); + + org.apache.pdfbox.pdmodel.PDDocumentInformation mockInfo = mock(org.apache.pdfbox.pdmodel.PDDocumentInformation.class); + when(mockDocument.getDocumentInformation()).thenReturn(mockInfo); + + ResponseEntity response = redactController.redactPdf(request); + + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + + verify(mockDocument).save(any(ByteArrayOutputStream.class)); + verify(mockDocument).close(); + } + } + + @Nested + @DisplayName("Regular Expression Redaction") + class RegexRedactionTests { + + @Test + @DisplayName("Should redact using regex patterns") + void redactUsingRegexPatterns() throws Exception { + testAutoRedaction("\\d{3}-\\d{2}-\\d{4}", true, false, "#FF0000", 1.0f, false, true); + } + + @Test + @DisplayName("Should handle email pattern redaction") + void handleEmailPatternRedaction() throws Exception { + testAutoRedaction("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", true, false, "#0000FF", 1.5f, false, true); + } + + @Test + @DisplayName("Should handle phone number patterns") + void handlePhoneNumberPatterns() throws Exception { + testAutoRedaction("\\(\\d{3}\\)\\s*\\d{3}-\\d{4}", true, false, "#FF0000", 1.0f, false, true); + } + + @ParameterizedTest + @ValueSource(strings = { + "\\d{3}-\\d{2}-\\d{4}", // SSN pattern + "\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}", // Credit card pattern + "\\b[A-Z]{2,}\\b", // Uppercase words + "\\$\\d+\\.\\d{2}", // Currency pattern + "\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\b" // IP address pattern + }) + @DisplayName("Should handle various regex patterns") + void handleVariousRegexPatterns(String regexPattern) throws Exception { + testAutoRedaction(regexPattern, true, false, "#000000", 1.0f, false, true); + } + + @Test + @DisplayName("Should handle invalid regex gracefully") + void handleInvalidRegex() throws Exception { + testAutoRedaction("[invalid regex(", true, false, "#000000", 1.0f, false, false); + } + } + + @Nested + @DisplayName("Whole Word Search Redaction") + class WholeWordRedactionTests { + + @Test + @DisplayName("Should redact whole words only") + void redactWholeWordsOnly() throws Exception { + testAutoRedaction("test", false, true, "#0000FF", 0.5f, false, true); + } + + @Test + @DisplayName("Should handle word boundaries correctly") + void handleWordBoundariesCorrectly() throws Exception { + testAutoRedaction("confidential", false, true, "#FF0000", 1.0f, false, true); + } + } + + @Nested + @DisplayName("Color and Styling Options") + class ColorAndStylingTests { + + @Test + @DisplayName("Should handle red hex color") + void handleRedHexColor() throws Exception { + testAutoRedaction("test", false, false, "#FF0000", 1.0f, false, true); + } + + @Test + @DisplayName("Should handle green hex color") + void handleGreenHexColor() throws Exception { + testAutoRedaction("test", false, false, "#00FF00", 1.0f, false, true); + } + + @Test + @DisplayName("Should handle blue hex color") + void handleBlueHexColor() throws Exception { + testAutoRedaction("test", false, false, "#0000FF", 1.0f, false, true); + } + + @Test + @DisplayName("Should default to black for invalid colors") + void defaultToBlackForInvalidColors() throws Exception { + testAutoRedaction("test", false, false, "invalid-color", 1.0f, false, true); + } + + @Test + @DisplayName("Should handle yellow hex color") + void handleYellowHexColor() throws Exception { + testAutoRedaction("test", false, false, "#FFFF00", 1.0f, false, true); + } + + @Test + @DisplayName("Should handle magenta hex color") + void handleMagentaHexColor() throws Exception { + testAutoRedaction("test", false, false, "#FF00FF", 1.0f, false, true); + } + + @Test + @DisplayName("Should handle cyan hex color") + void handleCyanHexColor() throws Exception { + testAutoRedaction("test", false, false, "#00FFFF", 1.0f, false, true); + } + + @Test + @DisplayName("Should handle black hex color") + void handleBlackHexColor() throws Exception { + testAutoRedaction("test", false, false, "#000000", 1.0f, false, true); + } + + @Test + @DisplayName("Should handle white hex color") + void handleWhiteHexColor() throws Exception { + testAutoRedaction("test", false, false, "#FFFFFF", 1.0f, false, true); + } + + @Test + @DisplayName("Should handle zero padding") + void handleZeroPadding() throws Exception { + testAutoRedaction("test", false, false, "#000000", 0.0f, false, true); + } + + @Test + @DisplayName("Should handle normal padding") + void handleNormalPadding() throws Exception { + testAutoRedaction("test", false, false, "#000000", 1.0f, false, true); + } + + @Test + @DisplayName("Should handle large padding") + void handleLargePadding() throws Exception { + testAutoRedaction("test", false, false, "#000000", 2.5f, false, true); + } + + @Test + @DisplayName("Should handle extra large padding") + void handleExtraLargePadding() throws Exception { + testAutoRedaction("test", false, false, "#000000", 5.0f, false, true); + } + } + + @Nested + @DisplayName("Manual Redaction Areas") + class ManualRedactionTests { + + @Test + @DisplayName("Should redact using manual areas") + void redactUsingManualAreas() throws Exception { + List redactionAreas = createValidRedactionAreas(); + testManualRedaction(redactionAreas, false); + } + + @Test + @DisplayName("Should handle null redaction areas") + void handleNullRedactionAreas() throws Exception { + testManualRedaction(null, false); + } + + @Test + @DisplayName("Should handle empty redaction areas") + void handleEmptyRedactionAreas() throws Exception { + testManualRedaction(new ArrayList<>(), false); + } + + @Test + @DisplayName("Should handle invalid redaction area coordinates") + void handleInvalidRedactionAreaCoordinates() throws Exception { + List invalidAreas = createInvalidRedactionAreas(); + testManualRedaction(invalidAreas, false); + } + + @Test + @DisplayName("Should handle multiple redaction areas") + void handleMultipleRedactionAreas() throws Exception { + List multipleAreas = createMultipleRedactionAreas(); + testManualRedaction(multipleAreas, false); + } + + @Test + @DisplayName("Should handle overlapping redaction areas") + void handleOverlappingRedactionAreas() throws Exception { + List overlappingAreas = createOverlappingRedactionAreas(); + testManualRedaction(overlappingAreas, false); + } + + @Test + @DisplayName("Should handle redaction areas with different colors") + void handleRedactionAreasWithDifferentColors() throws Exception { + List areas = new ArrayList<>(); + + String[] colors = {"FF0000", "00FF00", "0000FF", "FFFF00", "FF00FF", "00FFFF"}; + for (int i = 0; i < colors.length; i++) { + RedactionArea area = new RedactionArea(); + area.setPage(1); + area.setX(50.0 + (i * 60)); + area.setY(50.0); + area.setWidth(50.0); + area.setHeight(30.0); + area.setColor(colors[i]); + areas.add(area); + } + + testManualRedaction(areas, false); + } + + @Test + @DisplayName("Should handle redaction areas on multiple pages") + void handleRedactionAreasOnMultiplePages() throws Exception { + when(mockPages.getCount()).thenReturn(3); + when(mockDocument.getNumberOfPages()).thenReturn(3); + + List pageList = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + PDPage page = mock(PDPage.class); + PDRectangle pageRect = new PDRectangle(0, 0, 612, 792); + when(page.getCropBox()).thenReturn(pageRect); + when(page.getMediaBox()).thenReturn(pageRect); + when(page.getBBox()).thenReturn(pageRect); + when(page.hasContents()).thenReturn(true); + + InputStream mockInputStream = new ByteArrayInputStream( + ("BT /F1 12 Tf 100 200 Td (page " + i + " content) Tj ET").getBytes()); + when(page.getContents()).thenReturn(mockInputStream); + + pageList.add(page); + } + + when(mockPages.iterator()).thenReturn(pageList.iterator()); + for (int i = 0; i < 3; i++) { + when(mockPages.get(i)).thenReturn(pageList.get(i)); + } + + List areas = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + RedactionArea area = new RedactionArea(); + area.setPage(i + 1); // Pages are 1-indexed + area.setX(100.0); + area.setY(100.0); + area.setWidth(200.0); + area.setHeight(50.0); + area.setColor("000000"); + areas.add(area); + } + + testManualRedaction(areas, false); + + reset(mockPages); + when(mockPages.getCount()).thenReturn(1); + when(mockPages.get(0)).thenReturn(mockPage); + when(mockPages.iterator()).thenReturn(Collections.singletonList(mockPage).iterator()); + when(mockDocument.getNumberOfPages()).thenReturn(1); + } + } + + @Nested + @DisplayName("Image Conversion Options") + class ImageConversionTests { + + @Test + @DisplayName("Should handle PDF to image conversion disabled") + void handlePdfToImageConversionDisabled() throws Exception { + testAutoRedaction("sensitive", false, false, "#000000", 1.0f, false, true); + } + + @Test + @DisplayName("Should handle PDF to image conversion enabled") + void handlePdfToImageConversionEnabled() throws Exception { + testAutoRedaction("sensitive", false, false, "#000000", 1.0f, true, true); + } + + @Test + @DisplayName("Should handle manual redaction with image conversion") + void handleManualRedactionWithImageConversion() throws Exception { + List areas = createValidRedactionAreas(); + testManualRedaction(areas, true); + } + } + + @Nested + @DisplayName("Error Handling and Edge Cases") + class ErrorHandlingTests { + + @Test + @DisplayName("Should handle null file input gracefully") + void handleNullFileInput() throws Exception { + RedactPdfRequest request = new RedactPdfRequest(); + request.setFileInput(null); + request.setListOfText("test"); + + assertDoesNotThrow(() -> { + try { + redactController.redactPdf(request); + } catch (Exception e) { + assertNotNull(e); + } + }); + } + + @Test + @DisplayName("Should handle malformed PDF gracefully") + void handleMalformedPdfGracefully() throws Exception { + MockMultipartFile malformedFile = new MockMultipartFile( + "fileInput", + "malformed.pdf", + "application/pdf", + "Not a real PDF content".getBytes() + ); + + RedactPdfRequest request = new RedactPdfRequest(); + request.setFileInput(malformedFile); + request.setListOfText("test"); + + assertDoesNotThrow(() -> { + try { + redactController.redactPdf(request); + } catch (Exception e) { + assertNotNull(e); + } + }); + } + + @Test + @DisplayName("Should handle extremely long search text") + void handleExtremelyLongSearchText() throws Exception { + String longText = "a".repeat(10000); + testAutoRedaction(longText, false, false, "#000000", 1.0f, false, true); + } + + @Test + @DisplayName("Should handle special characters in search text") + void handleSpecialCharactersInSearchText() throws Exception { + testAutoRedaction("特殊字符测试 ñáéíóú àèìòù", false, false, "#000000", 1.0f, false, true); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "\t", "\n", "\r\n", " \t\n "}) + @DisplayName("Should handle whitespace-only search terms") + void handleWhitespaceOnlySearchTerms(String whitespacePattern) throws Exception { + testAutoRedaction(whitespacePattern, false, false, "#000000", 1.0f, false, true); + } + + @Test + @DisplayName("Should handle null redact color gracefully") + void handleNullRedactColor() throws Exception { + RedactPdfRequest request = createRedactPdfRequest(); + request.setListOfText("test"); + request.setRedactColor(null); + + ResponseEntity response = redactController.redactPdf(request); + + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + } + + @Test + @DisplayName("Should handle negative padding gracefully") + void handleNegativePadding() throws Exception { + testAutoRedaction("test", false, false, "#000000", -1.0f, false, true); + } + + @Test + @DisplayName("Should handle extremely large padding") + void handleExtremelyLargePadding() throws Exception { + testAutoRedaction("test", false, false, "#000000", 100.0f, false, true); + } + + @Test + @DisplayName("Should handle null manual redaction areas gracefully") + void handleNullManualRedactionAreas() throws Exception { + ManualRedactPdfRequest request = createManualRedactPdfRequest(); + request.setRedactions(null); + + ResponseEntity response = redactController.redactPDF(request); + + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + } + + @Test + @DisplayName("Should handle out of bounds page numbers gracefully") + void handleOutOfBoundsPageNumbers() throws Exception { + ManualRedactPdfRequest request = createManualRedactPdfRequest(); + request.setPageNumbers("100-200"); + + ResponseEntity response = redactController.redactPDF(request); + + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + } + } + + @Nested + @DisplayName("Color Decoding Utility Tests") + class ColorDecodingTests { + + @Test + @DisplayName("Should decode valid hex color with hash") + void decodeValidHexColorWithHash() throws Exception { + Color result = redactController.decodeOrDefault("#FF0000"); + assertEquals(Color.RED, result); + } + + @Test + @DisplayName("Should decode valid hex color without hash") + void decodeValidHexColorWithoutHash() throws Exception { + Color result = redactController.decodeOrDefault("FF0000"); + assertEquals(Color.RED, result); + } + + @Test + @DisplayName("Should default to black for null color") + void defaultToBlackForNullColor() throws Exception { + Color result = redactController.decodeOrDefault(null); + assertEquals(Color.BLACK, result); + } + + @Test + @DisplayName("Should default to black for invalid color") + void defaultToBlackForInvalidColor() throws Exception { + Color result = redactController.decodeOrDefault("invalid-color"); + assertEquals(Color.BLACK, result); + } + + @ParameterizedTest + @ValueSource(strings = {"#FF0000", "#00FF00", "#0000FF", "#FFFFFF", "#000000", "FF0000", "00FF00", "0000FF"}) + @DisplayName("Should handle various valid color formats") + void handleVariousValidColorFormats(String colorInput) throws Exception { + Color result = redactController.decodeOrDefault(colorInput); + assertNotNull(result); + assertTrue(result.getRed() >= 0 && result.getRed() <= 255, "Red component should be in valid range"); + assertTrue(result.getGreen() >= 0 && result.getGreen() <= 255, "Green component should be in valid range"); + assertTrue(result.getBlue() >= 0 && result.getBlue() <= 255, "Blue component should be in valid range"); + } + + @Test + @DisplayName("Should handle short hex codes appropriately") + void handleShortHexCodes() throws Exception { + Color result1 = redactController.decodeOrDefault("123"); + Color result2 = redactController.decodeOrDefault("#12"); + + assertNotNull(result1); + assertNotNull(result2); + } + } + + @Nested + @DisplayName("Content Stream Unit Tests") + class ContentStreamUnitTests { + + @Test + @DisplayName("createTokensWithoutTargetText should remove simple text tokens") + void shouldRemoveSimpleTextTokens() throws Exception { + createRealPageWithSimpleText("This document contains confidential information."); + + Set targetWords = Set.of("confidential"); + + List tokens = redactController.createTokensWithoutTargetText(realPage, targetWords, false, false); + + assertNotNull(tokens); + assertFalse(tokens.isEmpty()); + + String reconstructedText = extractTextFromTokens(tokens); + assertFalse(reconstructedText.contains("confidential"), + "Target text should be replaced with placeholder"); + assertTrue(reconstructedText.contains("document"), + "Non-target text should remain"); + } + + @Test + @DisplayName("createTokensWithoutTargetText should handle TJ operator arrays") + void shouldHandleTJOperatorArrays() throws Exception { + createRealPageWithTJArrayText(); + + Set targetWords = Set.of("secret"); + + List tokens = redactController.createTokensWithoutTargetText(realPage, targetWords, false, false); + + assertNotNull(tokens); + + boolean foundModifiedTJArray = false; + for (Object token : tokens) { + if (token instanceof COSArray array) { + for (int i = 0; i < array.size(); i++) { + if (array.getObject(i) instanceof COSString cosString) { + String text = cosString.getString(); + if (text.contains("secret")) { + fail("Target text 'secret' should have been redacted from TJ array"); + } + foundModifiedTJArray = true; + } + } + } + } + assertTrue(foundModifiedTJArray, "Should find at least one TJ array"); + } + + @Test + @DisplayName("createTokensWithoutTargetText should preserve non-text tokens") + void shouldPreserveNonTextTokens() throws Exception { + createRealPageWithMixedContent(); + + Set targetWords = Set.of("redact"); + + List originalTokens = getOriginalTokens(); + List filteredTokens = redactController.createTokensWithoutTargetText(realPage, targetWords, false, false); + + long originalNonTextCount = originalTokens.stream() + .filter(token -> token instanceof Operator op && !redactController.isTextShowingOperator(op.getName())) + .count(); + + long filteredNonTextCount = filteredTokens.stream() + .filter(token -> token instanceof Operator op && !redactController.isTextShowingOperator(op.getName())) + .count(); + + assertTrue(filteredNonTextCount > 0, + "Non-text operators should be preserved"); + + assertTrue(filteredNonTextCount >= originalNonTextCount / 2, + "A reasonable number of non-text operators should be preserved"); + } + + @Test + @DisplayName("createTokensWithoutTargetText should handle regex patterns") + void shouldHandleRegexPatterns() throws Exception { + createRealPageWithSimpleText("Phone: 123-456-7890 and SSN: 111-22-3333"); + + Set targetWords = Set.of("\\d{3}-\\d{2}-\\d{4}"); // SSN pattern + + List tokens = redactController.createTokensWithoutTargetText(realPage, targetWords, true, false); + + String reconstructedText = extractTextFromTokens(tokens); + assertFalse(reconstructedText.contains("111-22-3333"), "SSN should be redacted"); + assertTrue(reconstructedText.contains("123-456-7890"), "Phone should remain"); + } + + @Test + @DisplayName("createTokensWithoutTargetText should handle whole word search") + void shouldHandleWholeWordSearch() throws Exception { + createRealPageWithSimpleText("This test testing tested document"); + + Set targetWords = Set.of("test"); + + List tokens = redactController.createTokensWithoutTargetText(realPage, targetWords, false, true); + + String reconstructedText = extractTextFromTokens(tokens); + assertTrue(reconstructedText.contains("testing"), "Partial matches should remain"); + assertTrue(reconstructedText.contains("tested"), "Partial matches should remain"); + } + + @ParameterizedTest + @ValueSource(strings = {"Tj", "TJ", "'", "\""}) + @DisplayName("createTokensWithoutTargetText should handle all text operators") + void shouldHandleAllTextOperators(String operatorName) throws Exception { + createRealPageWithSpecificOperator(operatorName); + + Set targetWords = Set.of("sensitive"); + + List tokens = redactController.createTokensWithoutTargetText(realPage, targetWords, false, false); + + String reconstructedText = extractTextFromTokens(tokens); + assertFalse(reconstructedText.contains("sensitive"), + "Text should be redacted regardless of operator type"); + } + + @Test + @DisplayName("writeFilteredContentStream should write tokens to new stream") + void shouldWriteTokensToNewContentStream() throws Exception { + List tokens = createSampleTokenList(); + + redactController.writeFilteredContentStream(realDocument, realPage, tokens); + + assertNotNull(realPage.getContents(), "Page should have content stream"); + + // Verify the content can be read back + try (InputStream inputStream = realPage.getContents()) { + byte[] content = readAllBytes(inputStream); + assertTrue(content.length > 0, "Content stream should not be empty"); + } + } + + @Test + @DisplayName("writeFilteredContentStream should handle empty token list") + void shouldHandleEmptyTokenList() throws Exception { + List emptyTokens = Collections.emptyList(); + + assertDoesNotThrow(() -> redactController.writeFilteredContentStream(realDocument, realPage, emptyTokens)); + + assertNotNull(realPage.getContents(), "Page should still have content stream"); + } + + @Test + @DisplayName("writeFilteredContentStream should replace existing content") + void shouldReplaceExistingContentStream() throws Exception { + createRealPageWithSimpleText("Original content"); + String originalContent = extractTextFromModifiedPage(realPage); + + List newTokens = createSampleTokenList(); + redactController.writeFilteredContentStream(realDocument, realPage, newTokens); + + String newContent = extractTextFromModifiedPage(realPage); + assertNotEquals(originalContent, newContent, "Content stream should be replaced"); + } + + @Test + @DisplayName("Placeholder creation should maintain text width") + void shouldCreateWidthMatchingPlaceholder() throws Exception { + String originalText = "confidential"; + String placeholder = redactController.createPlaceholder(originalText); + + assertEquals(originalText.length(), placeholder.length(), + "Placeholder should maintain character count for width preservation"); + } + + @Test + @DisplayName("Placeholder should handle special characters") + void shouldHandleSpecialCharactersInPlaceholder() throws Exception { + String originalText = "café naïve"; + String placeholder = redactController.createPlaceholder(originalText); + + assertEquals(originalText.length(), placeholder.length()); + assertFalse(placeholder.contains("café"), "Placeholder should not contain original text"); + } + + @Test + @DisplayName("Integration test: createTokens and writeStream") + void shouldIntegrateTokenCreationAndWriting() throws Exception { + createRealPageWithSimpleText("This document contains secret information."); + + Set targetWords = Set.of("secret"); + + List filteredTokens = redactController.createTokensWithoutTargetText(realPage, targetWords, false, false); + + redactController.writeFilteredContentStream(realDocument, realPage, filteredTokens); + assertNotNull(realPage.getContents()); + + String finalText = extractTextFromModifiedPage(realPage); + assertFalse(finalText.contains("secret"), "Target text should be completely removed"); + assertTrue(finalText.contains("document"), "Other text should remain"); + } + + @Test + @DisplayName("Should preserve text positioning operators") + void shouldPreserveTextPositioning() throws Exception { + createRealPageWithPositionedText(); + + Set targetWords = Set.of("confidential"); + + List filteredTokens = redactController.createTokensWithoutTargetText(realPage, targetWords, false, false); + + long filteredPositioning = filteredTokens.stream() + .filter(token -> token instanceof Operator op && + (op.getName().equals("Td") || op.getName().equals("TD") || op.getName().equals("Tm"))) + .count(); + + assertTrue(filteredPositioning > 0, + "Positioning operators should be preserved"); + } + + @Test + @DisplayName("Should handle complex content streams with multiple operators") + void shouldHandleComplexContentStreams() throws Exception { + realPage = new PDPage(PDRectangle.A4); + while (realDocument.getNumberOfPages() > 0) { + realDocument.removePage(0); + } + realDocument.addPage(realPage); + realPage.setResources(new PDResources()); + realPage.getResources().put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + + try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) { + contentStream.setLineWidth(2); + contentStream.moveTo(100, 100); + contentStream.lineTo(200, 200); + contentStream.stroke(); + + contentStream.beginText(); + contentStream.setFont(realPage.getResources().getFont(COSName.getPDFName("F1")), 12); + contentStream.newLineAtOffset(50, 750); + contentStream.showText("This is a complex document with "); + contentStream.setTextRise(5); + contentStream.showText("confidential"); + contentStream.setTextRise(0); + contentStream.showText(" information."); + contentStream.endText(); + + contentStream.addRect(300, 300, 100, 100); + contentStream.fill(); + } + + Set targetWords = Set.of("confidential"); + + List tokens = redactController.createTokensWithoutTargetText(realPage, targetWords, false, false); + + assertNotNull(tokens); + assertFalse(tokens.isEmpty()); + + String reconstructedText = extractTextFromTokens(tokens); + assertFalse(reconstructedText.contains("confidential"), "Target text should be redacted"); + + boolean hasGraphicsOperators = tokens.stream() + .anyMatch(token -> token instanceof Operator op && + (op.getName().equals("re") || op.getName().equals("f") || + op.getName().equals("m") || op.getName().equals("l") || + op.getName().equals("S"))); + + assertTrue(hasGraphicsOperators, "Graphics operators should be preserved"); + } + + @Test + @DisplayName("Should handle documents with multiple text blocks") + void shouldHandleDocumentsWithMultipleTextBlocks() throws Exception { + // Create a document with multiple text blocks + realPage = new PDPage(PDRectangle.A4); + while (realDocument.getNumberOfPages() > 0) { + realDocument.removePage(0); + } + realDocument.addPage(realPage); + + // Create resources + PDResources resources = new PDResources(); + resources.put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + realPage.setResources(resources); + + try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) { + contentStream.beginText(); + contentStream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + contentStream.newLineAtOffset(50, 750); + contentStream.showText("This is the first text block"); + contentStream.endText(); + + contentStream.setLineWidth(2); + contentStream.moveTo(100, 700); + contentStream.lineTo(200, 700); + contentStream.stroke(); + + contentStream.beginText(); + contentStream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + contentStream.newLineAtOffset(50, 650); + contentStream.showText("This block contains confidential information"); + contentStream.endText(); + + contentStream.addRect(100, 600, 100, 50); + contentStream.fill(); + + contentStream.beginText(); + contentStream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + contentStream.newLineAtOffset(50, 550); + contentStream.showText("This is the third text block"); + contentStream.endText(); + } + + RedactPdfRequest request = createRedactPdfRequest(); + request.setListOfText("confidential"); + request.setUseRegex(false); + request.setWholeWordSearch(false); + + ResponseEntity response = redactController.redactPdf(request); + + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().length > 0); + } + } + + private RedactPdfRequest createRedactPdfRequest() { + RedactPdfRequest request = new RedactPdfRequest(); + request.setFileInput(mockPdfFile); + return request; + } + + private ManualRedactPdfRequest createManualRedactPdfRequest() { + ManualRedactPdfRequest request = new ManualRedactPdfRequest(); + request.setFileInput(mockPdfFile); + return request; + } + + private byte[] createSimplePdfContent() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + try (PDPageContentStream contentStream = new PDPageContentStream(doc, page)) { + contentStream.beginText(); + contentStream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + contentStream.newLineAtOffset(100, 700); + contentStream.showText("This is a simple PDF."); + contentStream.endText(); + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + return baos.toByteArray(); + } + } + + private List createValidRedactionAreas() { + List areas = new ArrayList<>(); + + RedactionArea area1 = new RedactionArea(); + area1.setPage(1); + area1.setX(100.0); + area1.setY(100.0); + area1.setWidth(200.0); + area1.setHeight(50.0); + area1.setColor("000000"); + areas.add(area1); + + RedactionArea area2 = new RedactionArea(); + area2.setPage(1); + area2.setX(300.0); + area2.setY(200.0); + area2.setWidth(150.0); + area2.setHeight(30.0); + area2.setColor("FF0000"); + areas.add(area2); + + return areas; + } + + private List createInvalidRedactionAreas() { + List areas = new ArrayList<>(); + + RedactionArea invalidArea = new RedactionArea(); + invalidArea.setPage(null); // Invalid - null page + invalidArea.setX(100.0); + invalidArea.setY(100.0); + invalidArea.setWidth(200.0); + invalidArea.setHeight(50.0); + areas.add(invalidArea); + + return areas; + } + + private List createMultipleRedactionAreas() { + List areas = new ArrayList<>(); + + for (int i = 0; i < 5; i++) { + RedactionArea area = new RedactionArea(); + area.setPage(1); + area.setX(50.0 + (i * 60)); + area.setY(50.0 + (i * 40)); + area.setWidth(50.0); + area.setHeight(30.0); + area.setColor(String.format("%06X", i * 0x333333)); + areas.add(area); + } + + return areas; + } + + private List createOverlappingRedactionAreas() { + List areas = new ArrayList<>(); + + RedactionArea area1 = new RedactionArea(); + area1.setPage(1); + area1.setX(100.0); + area1.setY(100.0); + area1.setWidth(200.0); + area1.setHeight(100.0); + area1.setColor("FF0000"); + areas.add(area1); + + RedactionArea area2 = new RedactionArea(); + area2.setPage(1); + area2.setX(150.0); // Overlaps with area1 + area2.setY(150.0); // Overlaps with area1 + area2.setWidth(200.0); + area2.setHeight(100.0); + area2.setColor("00FF00"); + areas.add(area2); + + return areas; + } + + // Helper methods for real PDF content creation + private void createRealPageWithSimpleText(String text) throws IOException { + realPage = new PDPage(PDRectangle.A4); + while (realDocument.getNumberOfPages() > 0) { + realDocument.removePage(0); + } + realDocument.addPage(realPage); + realPage.setResources(new PDResources()); + realPage.getResources().put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + + try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) { + contentStream.beginText(); + contentStream.setFont(realPage.getResources().getFont(COSName.getPDFName("F1")), 12); + contentStream.newLineAtOffset(50, 750); + contentStream.showText(text); + contentStream.endText(); + } + } + + private void createRealPageWithTJArrayText() throws IOException { + realPage = new PDPage(PDRectangle.A4); + while (realDocument.getNumberOfPages() > 0) { + realDocument.removePage(0); + } + realDocument.addPage(realPage); + realPage.setResources(new PDResources()); + realPage.getResources().put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + + try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) { + contentStream.beginText(); + contentStream.setFont(realPage.getResources().getFont(COSName.getPDFName("F1")), 12); + contentStream.newLineAtOffset(50, 750); + + contentStream.showText("This is "); + contentStream.newLineAtOffset(-10, 0); // Simulate positioning + contentStream.showText("secret"); + contentStream.newLineAtOffset(10, 0); // Reset positioning + contentStream.showText(" information"); + contentStream.endText(); + } + } + + private void createRealPageWithMixedContent() throws IOException { + realPage = new PDPage(PDRectangle.A4); + while (realDocument.getNumberOfPages() > 0) { + realDocument.removePage(0); + } + realDocument.addPage(realPage); + realPage.setResources(new PDResources()); + realPage.getResources().put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + + try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) { + contentStream.setLineWidth(2); + contentStream.moveTo(100, 100); + contentStream.lineTo(200, 200); + contentStream.stroke(); + + contentStream.beginText(); + contentStream.setFont(realPage.getResources().getFont(COSName.getPDFName("F1")), 12); + contentStream.newLineAtOffset(50, 750); + contentStream.showText("Please redact this content"); + contentStream.endText(); + } + } + + private void createRealPageWithSpecificOperator(String operatorName) throws IOException { + createRealPageWithSimpleText("sensitive data"); + } + + private void createRealPageWithPositionedText() throws IOException { + realPage = new PDPage(PDRectangle.A4); + while (realDocument.getNumberOfPages() > 0) { + realDocument.removePage(0); + } + realDocument.addPage(realPage); + realPage.setResources(new PDResources()); + realPage.getResources().put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + + try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) { + contentStream.beginText(); + contentStream.setFont(realPage.getResources().getFont(COSName.getPDFName("F1")), 12); + contentStream.newLineAtOffset(50, 750); + contentStream.showText("Normal text "); + contentStream.newLineAtOffset(100, 0); + contentStream.showText("confidential"); + contentStream.newLineAtOffset(100, 0); + contentStream.showText(" more text"); + contentStream.endText(); + } + } + + // Helper for token creation + private List createSampleTokenList() { + return List.of( + Operator.getOperator("BT"), + COSName.getPDFName("F1"), + new COSFloat(12), + Operator.getOperator("Tf"), + new COSString("Sample text"), + Operator.getOperator("Tj"), + Operator.getOperator("ET") + ); + } + + private List getOriginalTokens() throws Exception { + // Create a new page to avoid side effects from other tests + PDPage pageForTokenExtraction = new PDPage(PDRectangle.A4); + pageForTokenExtraction.setResources(realPage.getResources()); + try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, pageForTokenExtraction)) { + contentStream.beginText(); + contentStream.setFont(realPage.getResources().getFont(COSName.getPDFName("F1")), 12); + contentStream.newLineAtOffset(50, 750); + contentStream.showText("Original content"); + contentStream.endText(); + } + return redactController.createTokensWithoutTargetText(pageForTokenExtraction, Collections.emptySet(), false, false); + } + + private String extractTextFromTokens(List tokens) { + StringBuilder text = new StringBuilder(); + for (Object token : tokens) { + if (token instanceof COSString cosString) { + text.append(cosString.getString()); + } else if (token instanceof COSArray array) { + for (int i = 0; i < array.size(); i++) { + if (array.getObject(i) instanceof COSString cosString) { + text.append(cosString.getString()); + } + } + } + } + return text.toString(); + } + + private String extractTextFromModifiedPage(PDPage page) throws IOException { + if (page.getContents() != null) { + try (InputStream inputStream = page.getContents()) { + return new String(readAllBytes(inputStream)); + } + } + return ""; + } + + private byte[] readAllBytes(InputStream inputStream) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + int nRead; + byte[] data = new byte[1024]; + while ((nRead = inputStream.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, nRead); + } + return buffer.toByteArray(); + } +} diff --git a/stirling-pdf/src/test/java/stirling/software/SPDF/pdf/TextFinderTest.java b/stirling-pdf/src/test/java/stirling/software/SPDF/pdf/TextFinderTest.java new file mode 100644 index 000000000..ebb5bebf7 --- /dev/null +++ b/stirling-pdf/src/test/java/stirling/software/SPDF/pdf/TextFinderTest.java @@ -0,0 +1,588 @@ +package stirling.software.SPDF.pdf; + +import java.io.IOException; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.junit.jupiter.api.AfterEach; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.junit.jupiter.MockitoExtension; + +import stirling.software.SPDF.model.PDFText; + +@DisplayName("PDF Text Finder tests") +@ExtendWith(MockitoExtension.class) +class TextFinderTest { + + private PDDocument document; + private PDPage page; + + // Helpers + private void testTextFinding(String pageContent, String searchTerm, boolean useRegex, boolean wholeWord, + String[] expectedTexts, int expectedCount) throws IOException { + addTextToPage(pageContent); + TextFinder textFinder = new TextFinder(searchTerm, useRegex, wholeWord); + + textFinder.getText(document); + List foundTexts = textFinder.getFoundTexts(); + + assertEquals(expectedCount, foundTexts.size(), + String.format("Expected %d matches for search term '%s'", expectedCount, searchTerm)); + + if (expectedTexts != null) { + for (String expectedText : expectedTexts) { + assertTrue(foundTexts.stream().anyMatch(text -> text.getText().equals(expectedText)), + String.format("Expected to find text: '%s'", expectedText)); + } + } + + // Verify basic properties of found texts + foundTexts.forEach(text -> { + assertNotNull(text.getText()); + assertTrue(text.getX1() >= 0); + assertTrue(text.getY1() >= 0); + assertTrue(text.getX2() >= text.getX1()); + assertTrue(text.getY2() >= text.getY1()); + assertEquals(0, text.getPageIndex()); // Single page test + }); + } + + @BeforeEach + void setUp() { + document = new PDDocument(); + page = new PDPage(PDRectangle.A4); + document.addPage(page); + } + + @AfterEach + void tearDown() throws IOException { + if (document != null) { + document.close(); + } + } + + @Nested + @DisplayName("Basic Text Search") + class BasicSearchTests { + + @Test + @DisplayName("Should find simple text correctly") + void findSimpleText() throws IOException { + testTextFinding("This is a confidential document with secret information.", + "confidential", false, false, + new String[]{"confidential"}, 1); + } + + @Test + @DisplayName("Should perform case-insensitive search") + void performCaseInsensitiveSearch() throws IOException { + testTextFinding("This document contains CONFIDENTIAL information.", + "confidential", false, false, + new String[]{"CONFIDENTIAL"}, 1); + } + + @Test + @DisplayName("Should find multiple occurrences of same term") + void findMultipleOccurrences() throws IOException { + testTextFinding("The secret code is secret123. Keep this secret safe!", + "secret", false, false, + new String[]{"secret", "secret", "secret"}, 3); + } + + @Test + @DisplayName("Should handle empty search term gracefully") + void handleEmptySearchTerm() throws IOException { + testTextFinding("This is a test document.", "", false, false, null, 0); + } + + @Test + @DisplayName("Should handle null search term gracefully") + void handleNullSearchTerm() throws IOException { + testTextFinding("This is a test document.", null, false, false, null, 0); + } + + @Test + @DisplayName("Should return no results when no match found") + void returnNoResultsWhenNoMatch() throws IOException { + testTextFinding("This is a test document.", "nonexistent", false, false, null, 0); + } + } + + @Nested + @DisplayName("Whole Word Search") + class WholeWordSearchTests { + + @Test + @DisplayName("Should find only whole words when enabled") + void findOnlyWholeWords() throws IOException { + testTextFinding("This is a test testing document with tested results.", + "test", false, true, + new String[]{"test"}, 1); + } + + @Test + @DisplayName("Should find partial matches when whole word search disabled") + void findPartialMatches() throws IOException { + testTextFinding("This is a test testing document with tested results.", + "test", false, false, + new String[]{"test", "test", "test"}, 3); + } + + @Test + @DisplayName("Should handle punctuation boundaries correctly") + void handlePunctuationBoundaries() throws IOException { + testTextFinding("Hello, world! Testing: test-case (test).", + "test", false, true, + new String[]{"test"}, 2); // Both standalone "test" and "test" in "test-case" + } + + @Test + @DisplayName("Should handle word boundaries with special characters") + void handleSpecialCharacterBoundaries() throws IOException { + testTextFinding("Email: test@example.com and test.txt file", + "test", false, true, + new String[]{"test"}, 2); // Both in email and filename should match + } + } + + @Nested + @DisplayName("Regular Expression Search") + class RegexSearchTests { + + @Test + @DisplayName("Should find text matching regex pattern") + void findTextMatchingRegex() throws IOException { + testTextFinding("Contact John at 123-45-6789 or Jane at 987-65-4321 for details.", + "\\d{3}-\\d{2}-\\d{4}", true, false, + new String[]{"123-45-6789", "987-65-4321"}, 2); + } + + @Test + @DisplayName("Should find email addresses with regex") + void findEmailAddresses() throws IOException { + testTextFinding("Email: test@example.com and admin@test.org", + "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", true, false, + new String[]{"test@example.com", "admin@test.org"}, 2); + } + + @Test + @DisplayName("Should combine regex with whole word search") + void combineRegexWithWholeWord() throws IOException { + testTextFinding("Email: test@example.com and admin@test.org", + "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", true, true, + new String[]{"test@example.com", "admin@test.org"}, 2); + } + + @Test + @DisplayName("Should find currency patterns") + void findCurrencyPatterns() throws IOException { + testTextFinding("Price: $100.50 and €75.25", + "\\$\\d+\\.\\d{2}", true, false, + new String[]{"$100.50"}, 1); + } + + @ParameterizedTest + @ValueSource(strings = { + "\\d{4}-\\d{2}-\\d{2}", // Date pattern + "\\b[A-Z]{2,}\\b", // Uppercase words + "\\w+@\\w+\\.\\w+", // Simple email pattern + "\\$\\d+", // Simple currency + "\\b\\d{3,4}\\b" // 3-4 digit numbers + }) + @DisplayName("Should handle various regex patterns") + void handleVariousRegexPatterns(String regexPattern) throws IOException { + String testContent = "Date: 2023-12-25, Email: test@domain.com, Price: $250, Code: ABC123, Number: 1234"; + addTextToPage(testContent); + + TextFinder textFinder = new TextFinder(regexPattern, true, false); + textFinder.getText(document); + List foundTexts = textFinder.getFoundTexts(); + + // Each pattern should find at least one match in our test content + assertFalse(foundTexts.isEmpty(), String.format("Pattern '%s' should find at least one match", regexPattern)); + } + + @Test + @DisplayName("Should handle invalid regex gracefully") + void handleInvalidRegex() throws IOException { + addTextToPage("This is test content."); + + try { + TextFinder textFinder = new TextFinder("[invalid regex(", true, false); + textFinder.getText(document); + List foundTexts = textFinder.getFoundTexts(); + assertNotNull(foundTexts); + } catch (java.util.regex.PatternSyntaxException e) { + assertNotNull(e.getMessage()); + assertTrue(e.getMessage().contains("Unclosed character class") || + e.getMessage().contains("syntax"), + "Exception should indicate regex syntax error"); + } catch (RuntimeException | IOException e) { + assertNotNull(e.getMessage()); + } + } + } + + @Nested + @DisplayName("Special Characters and Encoding") + class SpecialCharacterTests { + + @Test + @DisplayName("Should handle international characters") + void handleInternationalCharacters() throws IOException { + testTextFinding("Hello café naïve résumé", + "café", false, false, + new String[]{"café"}, 1); + } + + @Test + @DisplayName("Should find text with accented characters") + void findAccentedCharacters() throws IOException { + testTextFinding("Café, naïve, résumé, piñata", + "café", false, false, + new String[]{"Café"}, 1); // Case insensitive + } + + @Test + @DisplayName("Should handle special symbols") + void handleSpecialSymbols() throws IOException { + testTextFinding("Symbols: © ® ™ ± × ÷ § ¶", + "©", false, false, + new String[]{"©"}, 1); + } + + @Test + @DisplayName("Should find currency symbols") + void findCurrencySymbols() throws IOException { + testTextFinding("Prices: $100 €75 £50 ¥1000", + "[€£¥]", true, false, + new String[]{"€", "£", "¥"}, 3); + } + } + + @Nested + @DisplayName("Multi-page Document Tests") + class MultiPageTests { + + @Test + @DisplayName("Should find text across multiple pages") + void findTextAcrossPages() throws IOException { + PDPage secondPage = new PDPage(PDRectangle.A4); + document.addPage(secondPage); + + addTextToPage("First page with confidential data."); + + addTextToPage(secondPage, "Second page with secret information."); + + TextFinder textFinder = new TextFinder("confidential|secret", true, false); + textFinder.getText(document); + List foundTexts = textFinder.getFoundTexts(); + + assertEquals(2, foundTexts.size()); + + long page0Count = foundTexts.stream().filter(text -> text.getPageIndex() == 0).count(); + long page1Count = foundTexts.stream().filter(text -> text.getPageIndex() == 1).count(); + + assertEquals(1, page0Count); + assertEquals(1, page1Count); + } + + @Test + @DisplayName("Should handle empty pages gracefully") + void handleEmptyPages() throws IOException { + PDPage emptyPage = new PDPage(PDRectangle.A4); + document.addPage(emptyPage); + + addTextToPage("Content on first page only."); + + TextFinder textFinder = new TextFinder("content", false, false); + textFinder.getText(document); + List foundTexts = textFinder.getFoundTexts(); + + assertEquals(1, foundTexts.size()); + assertEquals(0, foundTexts.get(0).getPageIndex()); + } + } + + @Nested + @DisplayName("Performance and Boundary Tests") + class PerformanceTests { + + @Test + @DisplayName("Should handle very long search terms") + void handleLongSearchTerms() throws IOException { + String longTerm = "a".repeat(1000); + String content = "Short text with " + longTerm + " embedded."; + + testTextFinding(content, longTerm, false, false, new String[]{longTerm}, 1); + } + + @Test + @DisplayName("Should handle documents with many pages efficiently") + void handleManyPages() throws IOException { + for (int i = 0; i < 10; i++) { + if (i > 0) { // The first page already exists + document.addPage(new PDPage(PDRectangle.A4)); + } + addTextToPage(document.getPage(i), "Page " + i + " contains searchable content."); + } + + long startTime = System.currentTimeMillis(); + TextFinder textFinder = new TextFinder("searchable", false, false); + textFinder.getText(document); + List foundTexts = textFinder.getFoundTexts(); + long endTime = System.currentTimeMillis(); + + assertEquals(10, foundTexts.size()); + assertTrue(endTime - startTime < 3000, + "Multi-page search should complete within 3 seconds"); + } + } + + @Nested + @DisplayName("Error Handling and Edge Cases") + class ErrorHandlingTests { + + @Test + @DisplayName("Should handle null document gracefully") + void handleNullDocument() throws IOException { + TextFinder textFinder = new TextFinder("test", false, false); + + try { + textFinder.getText(null); + List foundTexts = textFinder.getFoundTexts(); + assertNotNull(foundTexts); + assertEquals(0, foundTexts.size()); + } catch (Exception e) { + assertNotNull(e.getMessage()); + } + } + + @Test + @DisplayName("Should handle document without pages") + void handleDocumentWithoutPages() throws IOException { + try (PDDocument emptyDocument = new PDDocument()) { + TextFinder textFinder = new TextFinder("test", false, false); + textFinder.getText(emptyDocument); + List foundTexts = textFinder.getFoundTexts(); + assertEquals(0, foundTexts.size()); + } + } + + @Test + @DisplayName("Should handle pages without content") + void handlePagesWithoutContent() throws IOException { + TextFinder textFinder = new TextFinder("test", false, false); + textFinder.getText(document); + List foundTexts = textFinder.getFoundTexts(); + + assertEquals(0, foundTexts.size()); + } + + @Test + @DisplayName("Should handle extremely complex regex patterns") + void handleComplexRegexPatterns() throws IOException { + addTextToPage("Complex content with various patterns: abc123, def456, XYZ789"); + + String complexRegex = "(?=.*\\d)(?=.*[a-z])(?=.*[A-Z])[a-zA-Z\\d]{6}"; + + assertDoesNotThrow(() -> { + TextFinder textFinder = new TextFinder(complexRegex, true, false); + textFinder.getText(document); + List foundTexts = textFinder.getFoundTexts(); + assertNotNull(foundTexts); + }); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "\t", "\n", "\r\n", " \t\n "}) + @DisplayName("Should handle whitespace-only search terms") + void handleWhitespaceSearchTerms(String whitespacePattern) throws IOException { + addTextToPage("This is normal text content."); + + TextFinder textFinder = new TextFinder(whitespacePattern, false, false); + textFinder.getText(document); + List foundTexts = textFinder.getFoundTexts(); + + assertEquals(0, foundTexts.size()); + } + } + + @Nested + @DisplayName("Text Coordinate Verification") + class CoordinateTests { + + @Test + @DisplayName("Should provide accurate text coordinates") + void provideAccurateCoordinates() throws IOException { + addTextToPage("Sample text for coordinate testing."); + + TextFinder textFinder = new TextFinder("coordinate", false, false); + textFinder.getText(document); + List foundTexts = textFinder.getFoundTexts(); + + assertEquals(1, foundTexts.size()); + PDFText foundText = foundTexts.get(0); + + assertTrue(foundText.getX1() >= 0, "X1 should be non-negative"); + assertTrue(foundText.getY1() >= 0, "Y1 should be non-negative"); + assertTrue(foundText.getX2() > foundText.getX1(), "X2 should be greater than X1"); + assertTrue(foundText.getY2() > foundText.getY1(), "Y2 should be greater than Y1"); + + double width = foundText.getX2() - foundText.getX1(); + double height = foundText.getY2() - foundText.getY1(); + + assertTrue(width > 0, "Text width should be positive"); + assertTrue(height > 0, "Text height should be positive"); + assertTrue(width < 1000, "Text width should be reasonable"); + assertTrue(height < 100, "Text height should be reasonable"); + } + + @Test + @DisplayName("Should handle overlapping text regions") + void handleOverlappingTextRegions() throws IOException { + addTextToPage("Overlapping test text content."); + + TextFinder textFinder = new TextFinder("test", false, false); + textFinder.getText(document); + List foundTexts = textFinder.getFoundTexts(); + + assertFalse(foundTexts.isEmpty()); + foundTexts.forEach(text -> { + assertNotNull(text.getText()); + assertTrue(text.getX1() >= 0 && text.getY1() >= 0); + }); + } + } + + @Nested + @DisplayName("Single Character and Digit Tests") + class SingleCharacterAndDigitTests { + + @Test + @DisplayName("Should find single digits in various contexts with whole word search") + void findSingleDigitsWholeWord() throws IOException { + String content = "Item 1 of 5 costs $2.50. Order number: 1234. Reference: A1B."; + addTextToPage(content); + + TextFinder textFinder = new TextFinder("1", false, true); + textFinder.getText(document); + List foundTexts = textFinder.getFoundTexts(); + + assertEquals(1, foundTexts.size(), + "Should find exactly one standalone '1', not the ones embedded in other numbers/codes"); + assertEquals("1", foundTexts.get(0).getText()); + } + + @Test + @DisplayName("Should find single digits without whole word search") + void findSingleDigitsNoWholeWord() throws IOException { + String content = "Item 1 of 5 costs $2.50. Order number: 1234. Reference: A1B."; + addTextToPage(content); + + TextFinder textFinder = new TextFinder("1", false, false); + textFinder.getText(document); + List foundTexts = textFinder.getFoundTexts(); + + assertTrue(foundTexts.size() >= 3, + "Should find multiple instances of '1' including standalone, in '1234', and in 'A1B'"); + } + + @Test + @DisplayName("Should find single characters in various contexts") + void findSingleCharacters() throws IOException { + String content = "Grade: A. Section B has item A-1. The letter A appears multiple times."; + addTextToPage(content); + + TextFinder textFinder = new TextFinder("A", false, true); + textFinder.getText(document); + List foundTexts = textFinder.getFoundTexts(); + + assertTrue(foundTexts.size() >= 2, "Should find multiple standalone 'A' characters"); + + for (PDFText found : foundTexts) { + assertEquals("A", found.getText()); + } + } + + @Test + @DisplayName("Should handle digits at word boundaries correctly") + void findDigitsAtWordBoundaries() throws IOException { + String content = "Numbers: 1, 2, 3. Code: 123. Version: 1.0. Item1 and Item2."; + addTextToPage(content); + + TextFinder textFinder1 = new TextFinder("1", false, true); + textFinder1.getText(document); + List foundTexts1 = textFinder1.getFoundTexts(); + + assertEquals(1, foundTexts1.size(), + "Should find only the standalone '1' at the beginning"); + + TextFinder textFinder2 = new TextFinder("2", false, true); + textFinder2.getText(document); + List foundTexts2 = textFinder2.getFoundTexts(); + + assertEquals(1, foundTexts2.size(), + "Should find only the standalone '2' in the number list"); + } + + @Test + @DisplayName("Should handle special characters and punctuation boundaries") + void findDigitsWithPunctuationBoundaries() throws IOException { + String content = "Items: (1), [2], {3}, item#4, price$5, and 6%."; + addTextToPage(content); + + TextFinder textFinder = new TextFinder("1", false, true); + textFinder.getText(document); + List foundTexts = textFinder.getFoundTexts(); + + assertEquals(1, foundTexts.size(), "Should find '1' surrounded by parentheses"); + assertEquals("1", foundTexts.get(0).getText()); + } + + @Test + @DisplayName("Should handle edge case with spacing and formatting") + void findDigitsWithSpacingIssues() throws IOException { + String content = "List: 1 , 2 , 3 and item 1 here."; + addTextToPage(content); + + TextFinder textFinder = new TextFinder("1", false, true); + textFinder.getText(document); + List foundTexts = textFinder.getFoundTexts(); + + assertEquals(2, foundTexts.size(), + "Should find both '1' instances despite spacing variations"); + } + } + + // Helper methods + private void addTextToPage(String text) throws IOException { + addTextToPage(page, text); + } + + private void addTextToPage(PDPage targetPage, String text) throws IOException { + try (PDPageContentStream contentStream = new PDPageContentStream(document, targetPage)) { + contentStream.beginText(); + contentStream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + contentStream.newLineAtOffset(50, 750); + contentStream.showText(text); + contentStream.endText(); + } + } +} From 28b1b96cfb63b1cb56fbe97094c311baf3d81c15 Mon Sep 17 00:00:00 2001 From: Ludy Date: Mon, 18 Aug 2025 13:03:57 +0200 Subject: [PATCH 14/41] feat(audit): introduce structured Audit API with export, stats, and cleanup endpoints (#4217) # Description of Changes - Added new REST-based `AuditDashboardController` under `/api/v1/audit` with endpoints for: - Audit data retrieval with pagination (`/data`) - Statistics retrieval (`/stats`) - Export in CSV and JSON (`/export/csv`, `/export/json`) - Cleanup of audit events before a given date (`/cleanup/before`) - Retrieval of distinct audit event types (`/types`) - Extracted web dashboard logic into `AuditDashboardWebController` (view rendering only). - Introduced new API models: - `AuditDataRequest`, `AuditDataResponse` - `AuditExportRequest`, `AuditDateExportRequest` - `AuditStatsResponse` - Extended `PersistentAuditEventRepository` with richer query methods (histograms, counts, top/latest events, distinct principals). - Updated `dashboard.js` to use new API endpoints under `/api/v1/audit`. - Enhanced authentication handlers and user endpoints with `@Audited` annotations for login/logout/password change events. - Cleaned up `LicenseKeyChecker` by removing unused `updateLicenseKey` method. - Moved admin-related controllers into `controller.api` namespace with proper OpenAPI annotations (`@Operation`, `@Tag`). - Improved `CleanUrlInterceptor` whitelist for new query parameters (`days`, `date`). --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../SPDF/config/CleanUrlInterceptor.java | 2 + .../common/controller/JobController.java | 24 +- .../{ => api}/AdminJobController.java | 18 +- .../{ => api}/AuditDashboardController.java | 330 ++++++++++-------- .../web/AuditDashboardWebController.java | 41 +++ .../model/api/audit/AuditDataRequest.java | 21 ++ .../model/api/audit/AuditDataResponse.java | 34 ++ .../api/audit/AuditDateExportRequest.java | 30 ++ .../model/api/audit/AuditExportRequest.java | 37 ++ .../model/api/audit/AuditStatsResponse.java | 33 ++ .../PersistentAuditEventRepository.java | 74 +++- .../configuration/ee/LicenseKeyChecker.java | 7 - .../controller/api/UserController.java | 6 + ...tomOAuth2AuthenticationFailureHandler.java | 5 + ...tomOAuth2AuthenticationSuccessHandler.java | 4 + ...stomSaml2AuthenticationFailureHandler.java | 5 + ...stomSaml2AuthenticationSuccessHandler.java | 4 + .../resources/static/js/audit/dashboard.js | 8 +- 18 files changed, 508 insertions(+), 175 deletions(-) rename app/proprietary/src/main/java/stirling/software/proprietary/controller/{ => api}/AdminJobController.java (80%) rename app/proprietary/src/main/java/stirling/software/proprietary/controller/{ => api}/AuditDashboardController.java (56%) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/controller/web/AuditDashboardWebController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDataRequest.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDataResponse.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDateExportRequest.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditExportRequest.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditStatsResponse.java diff --git a/app/core/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java b/app/core/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java index d37d4bfb6..9038e3a15 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/CleanUrlInterceptor.java @@ -20,6 +20,8 @@ public class CleanUrlInterceptor implements HandlerInterceptor { "endpoints", "logout", "error", + "days", + "date", "errorOAuth", "file", "messageType", diff --git a/app/core/src/main/java/stirling/software/common/controller/JobController.java b/app/core/src/main/java/stirling/software/common/controller/JobController.java index 44b15265b..ad346f145 100644 --- a/app/core/src/main/java/stirling/software/common/controller/JobController.java +++ b/app/core/src/main/java/stirling/software/common/controller/JobController.java @@ -10,8 +10,12 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -27,6 +31,8 @@ import stirling.software.common.service.TaskManager; @RestController @RequiredArgsConstructor @Slf4j +@RequestMapping("/api/v1/general") +@Tag(name = "Job Management", description = "Job Management API") public class JobController { private final TaskManager taskManager; @@ -40,7 +46,8 @@ public class JobController { * @param jobId The job ID * @return The job result */ - @GetMapping("/api/v1/general/job/{jobId}") + @GetMapping("/job/{jobId}") + @Operation(summary = "Get job status") public ResponseEntity getJobStatus(@PathVariable("jobId") String jobId) { JobResult result = taskManager.getJobResult(jobId); if (result == null) { @@ -68,7 +75,8 @@ public class JobController { * @param jobId The job ID * @return The job result */ - @GetMapping("/api/v1/general/job/{jobId}/result") + @GetMapping("/job/{jobId}/result") + @Operation(summary = "Get job result") public ResponseEntity getJobResult(@PathVariable("jobId") String jobId) { JobResult result = taskManager.getJobResult(jobId); if (result == null) { @@ -130,7 +138,8 @@ public class JobController { * @param jobId The job ID * @return Response indicating whether the job was cancelled */ - @DeleteMapping("/api/v1/general/job/{jobId}") + @DeleteMapping("/job/{jobId}") + @Operation(summary = "Cancel a job") public ResponseEntity cancelJob(@PathVariable("jobId") String jobId) { log.debug("Request to cancel job: {}", jobId); @@ -197,7 +206,8 @@ public class JobController { * @param jobId The job ID * @return List of files for the job */ - @GetMapping("/api/v1/general/job/{jobId}/result/files") + @GetMapping("/job/{jobId}/result/files") + @Operation(summary = "Get job result files") public ResponseEntity getJobFiles(@PathVariable("jobId") String jobId) { JobResult result = taskManager.getJobResult(jobId); if (result == null) { @@ -226,7 +236,8 @@ public class JobController { * @param fileId The file ID * @return The file metadata */ - @GetMapping("/api/v1/general/files/{fileId}/metadata") + @GetMapping("/files/{fileId}/metadata") + @Operation(summary = "Get file metadata") public ResponseEntity getFileMetadata(@PathVariable("fileId") String fileId) { try { // Verify file exists @@ -266,7 +277,8 @@ public class JobController { * @param fileId The file ID * @return The file content */ - @GetMapping("/api/v1/general/files/{fileId}") + @GetMapping("/files/{fileId}") + @Operation(summary = "Download a file") public ResponseEntity downloadFile(@PathVariable("fileId") String fileId) { try { // Verify file exists diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/AdminJobController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminJobController.java similarity index 80% rename from app/proprietary/src/main/java/stirling/software/proprietary/controller/AdminJobController.java rename to app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminJobController.java index cdb8f24a3..d68237dfb 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/AdminJobController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AdminJobController.java @@ -1,4 +1,4 @@ -package stirling.software.proprietary.controller; +package stirling.software.proprietary.controller.api; import java.util.Map; @@ -6,8 +6,12 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,6 +26,9 @@ import stirling.software.common.service.TaskManager; @RestController @RequiredArgsConstructor @Slf4j +@RequestMapping("/api/v1/admin") +@PreAuthorize("hasRole('ROLE_ADMIN')") +@Tag(name = "Admin Job Management", description = "Admin-only Job Management APIs") public class AdminJobController { private final TaskManager taskManager; @@ -32,7 +39,8 @@ public class AdminJobController { * * @return Job statistics */ - @GetMapping("/api/v1/admin/job/stats") + @GetMapping("/job/stats") + @Operation(summary = "Get job statistics") @PreAuthorize("hasRole('ROLE_ADMIN')") public ResponseEntity getJobStats() { JobStats stats = taskManager.getJobStats(); @@ -48,7 +56,8 @@ public class AdminJobController { * * @return Queue statistics */ - @GetMapping("/api/v1/admin/job/queue/stats") + @GetMapping("/job/queue/stats") + @Operation(summary = "Get job queue statistics") @PreAuthorize("hasRole('ROLE_ADMIN')") public ResponseEntity getQueueStats() { Map queueStats = jobQueue.getQueueStats(); @@ -61,7 +70,8 @@ public class AdminJobController { * * @return A response indicating how many jobs were cleaned up */ - @PostMapping("/api/v1/admin/job/cleanup") + @PostMapping("/job/cleanup") + @Operation(summary = "Cleanup old jobs") @PreAuthorize("hasRole('ROLE_ADMIN')") public ResponseEntity cleanupOldJobs() { int beforeCount = taskManager.getJobStats().getTotalJobs(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/AuditDashboardController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditDashboardController.java similarity index 56% rename from app/proprietary/src/main/java/stirling/software/proprietary/controller/AuditDashboardController.java rename to app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditDashboardController.java index 67b71ccd8..cd1c8b9e5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/AuditDashboardController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditDashboardController.java @@ -1,4 +1,4 @@ -package stirling.software.proprietary.controller; +package stirling.software.proprietary.controller.api; import java.time.Instant; import java.time.LocalDate; @@ -6,13 +6,13 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; 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.stream.Collectors; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -22,133 +22,101 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.http.HttpServletRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.audit.AuditEventType; -import stirling.software.proprietary.audit.AuditLevel; -import stirling.software.proprietary.config.AuditConfigurationProperties; +import stirling.software.proprietary.model.api.audit.AuditDataRequest; +import stirling.software.proprietary.model.api.audit.AuditDataResponse; +import stirling.software.proprietary.model.api.audit.AuditExportRequest; +import stirling.software.proprietary.model.api.audit.AuditStatsResponse; import stirling.software.proprietary.model.security.PersistentAuditEvent; import stirling.software.proprietary.repository.PersistentAuditEventRepository; import stirling.software.proprietary.security.config.EnterpriseEndpoint; -/** Controller for the audit dashboard. Admin-only access. */ +/** REST endpoints for the audit dashboard. */ @Slf4j -@Controller -@RequestMapping("/audit") -@PreAuthorize("hasRole('ADMIN')") +@RestController +@RequestMapping("/api/v1/audit") +@PreAuthorize("hasRole('ROLE_ADMIN')") @RequiredArgsConstructor @EnterpriseEndpoint +@Tag(name = "Audit", description = "Only Enterprise - Audit related operations") public class AuditDashboardController { private final PersistentAuditEventRepository auditRepository; - private final AuditConfigurationProperties auditConfig; private final ObjectMapper objectMapper; - /** Display the audit dashboard. */ - @GetMapping - public String showDashboard(Model model) { - model.addAttribute("auditEnabled", auditConfig.isEnabled()); - model.addAttribute("auditLevel", auditConfig.getAuditLevel()); - model.addAttribute("auditLevelInt", auditConfig.getLevel()); - model.addAttribute("retentionDays", auditConfig.getRetentionDays()); - - // Add audit level enum values for display - model.addAttribute("auditLevels", AuditLevel.values()); - - // Add audit event types for the dropdown - model.addAttribute("auditEventTypes", AuditEventType.values()); - - return "audit/dashboard"; - } - /** Get audit events data for the dashboard tables. */ @GetMapping("/data") - @ResponseBody - public Map getAuditData( - @RequestParam(value = "page", defaultValue = "0") int page, - @RequestParam(value = "size", defaultValue = "30") int size, - @RequestParam(value = "type", required = false) String type, - @RequestParam(value = "principal", required = false) String principal, - @RequestParam(value = "startDate", required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) - LocalDate startDate, - @RequestParam(value = "endDate", required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) - LocalDate endDate, - HttpServletRequest request) { + @Operation(summary = "Get audit events data") + public AuditDataResponse getAuditData(@ParameterObject AuditDataRequest request) { - Pageable pageable = PageRequest.of(page, size, Sort.by("timestamp").descending()); + Pageable pageable = + PageRequest.of( + request.getPage(), request.getSize(), Sort.by("timestamp").descending()); Page events; - String mode; + String type = request.getType(); + String principal = request.getPrincipal(); + LocalDate startDate = request.getStartDate(); + LocalDate endDate = request.getEndDate(); if (type != null && principal != null && startDate != null && endDate != null) { - mode = "principal + type + startDate + endDate"; Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); events = auditRepository.findByPrincipalAndTypeAndTimestampBetween( principal, type, start, end, pageable); } else if (type != null && principal != null) { - mode = "principal + type"; events = auditRepository.findByPrincipalAndType(principal, type, pageable); } else if (type != null && startDate != null && endDate != null) { - mode = "type + startDate + endDate"; Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); events = auditRepository.findByTypeAndTimestampBetween(type, start, end, pageable); } else if (principal != null && startDate != null && endDate != null) { - mode = "principal + startDate + endDate"; Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); events = auditRepository.findByPrincipalAndTimestampBetween( principal, start, end, pageable); } else if (startDate != null && endDate != null) { - mode = "startDate + endDate"; Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); events = auditRepository.findByTimestampBetween(start, end, pageable); } else if (type != null) { - mode = "type"; events = auditRepository.findByType(type, pageable); } else if (principal != null) { - mode = "principal"; events = auditRepository.findByPrincipal(principal, pageable); } else { - mode = "all"; events = auditRepository.findAll(pageable); } // Logging List content = events.getContent(); - Map response = new HashMap<>(); - response.put("content", content); - response.put("totalPages", events.getTotalPages()); - response.put("totalElements", events.getTotalElements()); - response.put("currentPage", events.getNumber()); - - return response; + return new AuditDataResponse( + content, events.getTotalPages(), events.getTotalElements(), events.getNumber()); } - /** Get statistics for charts. */ + /** Get statistics for charts (last X days). Existing behavior preserved. */ @GetMapping("/stats") - @ResponseBody - public Map getAuditStats( + @Operation(summary = "Get audit statistics for the last N days") + public AuditStatsResponse getAuditStats( + @Schema(description = "Number of days to look back for audit events", example = "7", required = true) @RequestParam(value = "days", defaultValue = "7") int days) { // Get events from the last X days @@ -181,18 +149,53 @@ public class AuditDashboardController { .format(DateTimeFormatter.ISO_LOCAL_DATE), Collectors.counting())); - Map stats = new HashMap<>(); - stats.put("eventsByType", eventsByType); - stats.put("eventsByPrincipal", eventsByPrincipal); - stats.put("eventsByDay", eventsByDay); - stats.put("totalEvents", events.size()); - - return stats; + return new AuditStatsResponse(eventsByType, eventsByPrincipal, eventsByDay, events.size()); } + // /** Advanced statistics using repository aggregations, with explicit date range. */ + // @GetMapping("/stats/range") + // @Operation(summary = "Get audit statistics for a date range (aggregated in DB)") + // public Map getAuditStatsRange(@ParameterObject AuditDateExportRequest + // request) { + + // LocalDate startDate = request.getStartDate(); + // LocalDate endDate = request.getEndDate(); + // Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + // Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + + // Map byType = toStringLongMap(auditRepository.countByTypeBetween(start, + // end)); + // Map byPrincipal = + // toStringLongMap(auditRepository.countByPrincipalBetween(start, end)); + + // Map byDay = new HashMap<>(); + // for (Object[] row : auditRepository.histogramByDayBetween(start, end)) { + // int y = ((Number) row[0]).intValue(); + // int m = ((Number) row[1]).intValue(); + // int d = ((Number) row[2]).intValue(); + // long count = ((Number) row[3]).longValue(); + // String key = String.format("%04d-%02d-%02d", y, m, d); + // byDay.put(key, count); + // } + + // Map byHour = new HashMap<>(); + // for (Object[] row : auditRepository.histogramByHourBetween(start, end)) { + // int hour = ((Number) row[0]).intValue(); + // long count = ((Number) row[1]).longValue(); + // byHour.put(String.format("%02d:00", hour), count); + // } + + // Map payload = new HashMap<>(); + // payload.put("byType", byType); + // payload.put("byPrincipal", byPrincipal); + // payload.put("byDay", byDay); + // payload.put("byHour", byHour); + // return payload; + // } + /** Get all unique event types from the database for filtering. */ @GetMapping("/types") - @ResponseBody + @Operation(summary = "Get all unique audit event types") public List getAuditTypes() { // Get distinct event types from the database List dbTypes = auditRepository.findDistinctEventTypes(); @@ -212,49 +215,11 @@ public class AuditDashboardController { } /** Export audit data as CSV. */ - @GetMapping("/export") - public ResponseEntity exportAuditData( - @RequestParam(value = "type", required = false) String type, - @RequestParam(value = "principal", required = false) String principal, - @RequestParam(value = "startDate", required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) - LocalDate startDate, - @RequestParam(value = "endDate", required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) - LocalDate endDate) { + @GetMapping("/export/csv") + @Operation(summary = "Export audit data as CSV") + public ResponseEntity exportAuditData(@ParameterObject AuditExportRequest request) { - // Get data with same filtering as getAuditData - List events; - - if (type != null && principal != null && startDate != null && endDate != null) { - Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); - Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); - events = - auditRepository.findAllByPrincipalAndTypeAndTimestampBetweenForExport( - principal, type, start, end); - } else if (type != null && principal != null) { - events = auditRepository.findAllByPrincipalAndTypeForExport(principal, type); - } else if (type != null && startDate != null && endDate != null) { - Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); - Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); - events = auditRepository.findAllByTypeAndTimestampBetweenForExport(type, start, end); - } else if (principal != null && startDate != null && endDate != null) { - Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); - Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); - events = - auditRepository.findAllByPrincipalAndTimestampBetweenForExport( - principal, start, end); - } else if (startDate != null && endDate != null) { - Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); - Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); - events = auditRepository.findAllByTimestampBetweenForExport(start, end); - } else if (type != null) { - events = auditRepository.findByTypeForExport(type); - } else if (principal != null) { - events = auditRepository.findAllByPrincipalForExport(principal); - } else { - events = auditRepository.findAll(); - } + List events = getAuditEventsByCriteria(request); // Convert to CSV StringBuilder csv = new StringBuilder(); @@ -282,15 +247,113 @@ public class AuditDashboardController { /** Export audit data as JSON. */ @GetMapping("/export/json") - public ResponseEntity exportAuditDataJson( - @RequestParam(value = "type", required = false) String type, - @RequestParam(value = "principal", required = false) String principal, - @RequestParam(value = "startDate", required = false) + @Operation(summary = "Export audit data as JSON") + public ResponseEntity exportAuditDataJson(@ParameterObject AuditExportRequest request) { + + List events = getAuditEventsByCriteria(request); + + // Convert to JSON + try { + byte[] jsonBytes = objectMapper.writeValueAsBytes(events); + + // Set up HTTP headers for download + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setContentDispositionFormData("attachment", "audit_export.json"); + + return ResponseEntity.ok().headers(headers).body(jsonBytes); + } catch (JsonProcessingException e) { + log.error("Error serializing audit events to JSON", e); + return ResponseEntity.internalServerError().build(); + } + } + + // /** Get all unique principals. */ + // @GetMapping("/principals") + // @Operation(summary = "Get all distinct principals") + // public List getPrincipals() { + // return auditRepository.findDistinctPrincipals(); + // } + + // /** Get principals by event type. */ + // @GetMapping("/types/{type}/principals") + // @Operation(summary = "Get distinct principals for a given type") + // public List getPrincipalsByType(@PathVariable("type") String type) { + // return auditRepository.findDistinctPrincipalsByType(type); + // } + + // /** Latest helpers */ + // @GetMapping("/latest") + // @Operation(summary = "Get the latest audit event, optionally filtered by type or principal") + // public ResponseEntity getLatest( + // @RequestParam(value = "type", required = false) String type, + // @RequestParam(value = "principal", required = false) String principal) { + // if (type != null) { + // return auditRepository + // .findTopByTypeOrderByTimestampDesc(type) + // .map(ResponseEntity::ok) + // .orElse(ResponseEntity.noContent().build()); + // } else if (principal != null) { + // return auditRepository + // .findTopByPrincipalOrderByTimestampDesc(principal) + // .map(ResponseEntity::ok) + // .orElse(ResponseEntity.noContent().build()); + // } + // return auditRepository + // .findTopByOrderByTimestampDesc() + // .map(ResponseEntity::ok) + // .orElse(ResponseEntity.noContent().build()); + // } + + /** Cleanup endpoints data before a certain date */ + @DeleteMapping("/cleanup/before") + @Operation( + summary = "Cleanup audit events before a certain date", + description = "Deletes all audit events before the specified date.") + public Map cleanupBefore( + @RequestParam(value = "date", required = true) + @Schema( + description = "The cutoff date for cleanup", + example = "2025-01-01", + format = "date") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) - LocalDate startDate, - @RequestParam(value = "endDate", required = false) - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) - LocalDate endDate) { + LocalDate date) { + if (date != null && !date.isAfter(LocalDate.now())) { + Instant cutoff = date.atStartOfDay(ZoneId.systemDefault()).toInstant(); + int deleted = auditRepository.deleteByTimestampBefore(cutoff); + return Map.of("deleted", deleted, "cutoffDate", date.toString()); + } + return Map.of( + "error", + "Invalid date format. Use ISO date format (YYYY-MM-DD). Date must be in the past."); + } + + // // ===== Helpers ===== + + // private Map toStringLongMap(List rows) { + // Map map = new HashMap<>(); + // for (Object[] row : rows) { + // String key = String.valueOf(row[0]); + // long val = ((Number) row[1]).longValue(); + // map.put(key, val); + // } + // return map; + // } + + /** Helper method to escape CSV fields. */ + private String escapeCSV(String field) { + if (field == null) { + return ""; + } + // Replace double quotes with two double quotes and wrap in quotes + return "\"" + field.replace("\"", "\"\"") + "\""; + } + + private List getAuditEventsByCriteria(AuditExportRequest request) { + String type = request.getType(); + String principal = request.getPrincipal(); + LocalDate startDate = request.getStartDate(); + LocalDate endDate = request.getEndDate(); // Get data with same filtering as getAuditData List events; @@ -324,29 +387,6 @@ public class AuditDashboardController { } else { events = auditRepository.findAll(); } - - // Convert to JSON - try { - byte[] jsonBytes = objectMapper.writeValueAsBytes(events); - - // Set up HTTP headers for download - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.setContentDispositionFormData("attachment", "audit_export.json"); - - return ResponseEntity.ok().headers(headers).body(jsonBytes); - } catch (JsonProcessingException e) { - log.error("Error serializing audit events to JSON", e); - return ResponseEntity.internalServerError().build(); - } - } - - /** Helper method to escape CSV fields. */ - private String escapeCSV(String field) { - if (field == null) { - return ""; - } - // Replace double quotes with two double quotes and wrap in quotes - return "\"" + field.replace("\"", "\"\"") + "\""; + return events; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/web/AuditDashboardWebController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/web/AuditDashboardWebController.java new file mode 100644 index 000000000..e5c80e162 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/web/AuditDashboardWebController.java @@ -0,0 +1,41 @@ +package stirling.software.proprietary.controller.web; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +import io.swagger.v3.oas.annotations.Hidden; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.config.AuditConfigurationProperties; +import stirling.software.proprietary.security.config.EnterpriseEndpoint; + +@Controller +@PreAuthorize("hasRole('ADMIN')") +@RequiredArgsConstructor +@EnterpriseEndpoint +public class AuditDashboardWebController { + private final AuditConfigurationProperties auditConfig; + + /** Display the audit dashboard. */ + @GetMapping("/audit") + @Hidden + public String showDashboard(Model model) { + model.addAttribute("auditEnabled", auditConfig.isEnabled()); + model.addAttribute("auditLevel", auditConfig.getAuditLevel()); + model.addAttribute("auditLevelInt", auditConfig.getLevel()); + model.addAttribute("retentionDays", auditConfig.getRetentionDays()); + + // Add audit level enum values for display + model.addAttribute("auditLevels", AuditLevel.values()); + + // Add audit event types for the dropdown + model.addAttribute("auditEventTypes", AuditEventType.values()); + + return "audit/dashboard"; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDataRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDataRequest.java new file mode 100644 index 000000000..c9a792119 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDataRequest.java @@ -0,0 +1,21 @@ +package stirling.software.proprietary.model.api.audit; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import stirling.software.proprietary.security.config.EnterpriseEndpoint; + +/** Request object used for querying audit events. */ +@Data +@EnterpriseEndpoint +@EqualsAndHashCode(callSuper = true) +public class AuditDataRequest extends AuditExportRequest { + + @Schema(description = "Page number for pagination", example = "0", defaultValue = "0") + private int page = 0; + + @Schema(description = "Page size for pagination", example = "30", defaultValue = "30") + private int size = 30; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDataResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDataResponse.java new file mode 100644 index 000000000..3d207af39 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDataResponse.java @@ -0,0 +1,34 @@ +package stirling.software.proprietary.model.api.audit; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import stirling.software.proprietary.model.security.PersistentAuditEvent; +import stirling.software.proprietary.security.config.EnterpriseEndpoint; + +/** Response object returned when querying audit data. */ +@Data +@EnterpriseEndpoint +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class AuditDataResponse { + + @Schema(description = "List of audit events matching the query") + private List content; + + @Schema(description = "Total number of pages available", example = "5") + private int totalPages; + + @Schema(description = "Total number of events", example = "150") + private long totalElements; + + @Schema(description = "Current page index", example = "0") + private int currentPage; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDateExportRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDateExportRequest.java new file mode 100644 index 000000000..6ce947d09 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditDateExportRequest.java @@ -0,0 +1,30 @@ +package stirling.software.proprietary.model.api.audit; + +import java.time.LocalDate; + +import org.springframework.format.annotation.DateTimeFormat; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import stirling.software.proprietary.security.config.EnterpriseEndpoint; + +@Data +@EnterpriseEndpoint +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class AuditDateExportRequest { + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + @Schema(description = "Start date for the export range", example = "2025-01-01") + private LocalDate startDate; + + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + @Schema(description = "End date for the export range", example = "2025-12-31") + private LocalDate endDate; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditExportRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditExportRequest.java new file mode 100644 index 000000000..c484fe43c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditExportRequest.java @@ -0,0 +1,37 @@ +package stirling.software.proprietary.model.api.audit; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import stirling.software.proprietary.security.config.EnterpriseEndpoint; + +/** Request object used for exporting audit data with filters. */ +@Data +@EnterpriseEndpoint +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class AuditExportRequest extends AuditDateExportRequest { + + @Schema( + description = "Audit event type to filter by", + example = "USER_LOGIN", + allowableValues = { + "USER_LOGIN", + "USER_LOGOUT", + "USER_FAILED_LOGIN", + "USER_PROFILE_UPDATE", + "SETTINGS_CHANGED", + "FILE_OPERATION", + "PDF_PROCESS", + "HTTP_REQUEST" + }) + private String type; + + @Schema(description = "Principal (username) to filter by", example = "admin") + private String principal; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditStatsResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditStatsResponse.java new file mode 100644 index 000000000..933661cb4 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/AuditStatsResponse.java @@ -0,0 +1,33 @@ +package stirling.software.proprietary.model.api.audit; + +import java.util.Map; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import stirling.software.proprietary.security.config.EnterpriseEndpoint; + +/** Response object for audit statistics. */ +@Data +@EnterpriseEndpoint +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class AuditStatsResponse { + + @Schema(description = "Count of events grouped by type") + private Map eventsByType; + + @Schema(description = "Count of events grouped by principal") + private Map eventsByPrincipal; + + @Schema(description = "Count of events grouped by day") + private Map eventsByDay; + + @Schema(description = "Total number of events in the period", example = "42") + private int totalEvents; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java index af6d7d554..121d8a95c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.repository; import java.time.Instant; import java.util.List; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -19,7 +20,8 @@ public interface PersistentAuditEventRepository extends JpaRepository findByPrincipal( @Param("principal") String principal, Pageable pageable); @@ -29,12 +31,14 @@ public interface PersistentAuditEventRepository extends JpaRepository findByPrincipalAndType( @Param("principal") String principal, @Param("type") String type, Pageable pageable); @Query( - "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.timestamp BETWEEN :startDate AND :endDate") + "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%'," + + " :principal, '%')) AND e.timestamp BETWEEN :startDate AND :endDate") Page findByPrincipalAndTimestampBetween( @Param("principal") String principal, @Param("startDate") Instant startDate, @@ -45,7 +49,9 @@ public interface PersistentAuditEventRepository extends JpaRepository findByPrincipalAndTypeAndTimestampBetween( @Param("principal") String principal, @Param("type") String type, @@ -55,7 +61,8 @@ public interface PersistentAuditEventRepository extends JpaRepository findAllByPrincipalForExport(@Param("principal") String principal); @Query("SELECT e FROM PersistentAuditEvent e WHERE e.type = :type") @@ -69,26 +76,31 @@ public interface PersistentAuditEventRepository extends JpaRepository findByTimestampAfter(@Param("startDate") Instant startDate); @Query( - "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.type = :type") + "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%'," + + " :principal, '%')) AND e.type = :type") List findAllByPrincipalAndTypeForExport( @Param("principal") String principal, @Param("type") String type); @Query( - "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.timestamp BETWEEN :startDate AND :endDate") + "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%'," + + " :principal, '%')) AND e.timestamp BETWEEN :startDate AND :endDate") List findAllByPrincipalAndTimestampBetweenForExport( @Param("principal") String principal, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); @Query( - "SELECT e FROM PersistentAuditEvent e WHERE e.type = :type AND e.timestamp BETWEEN :startDate AND :endDate") + "SELECT e FROM PersistentAuditEvent e WHERE e.type = :type AND e.timestamp BETWEEN" + + " :startDate AND :endDate") List findAllByTypeAndTimestampBetweenForExport( @Param("type") String type, @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); @Query( - "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%', :principal, '%')) AND e.type = :type AND e.timestamp BETWEEN :startDate AND :endDate") + "SELECT e FROM PersistentAuditEvent e WHERE UPPER(e.principal) LIKE UPPER(CONCAT('%'," + + " :principal, '%')) AND e.type = :type AND e.timestamp BETWEEN :startDate AND" + + " :endDate") List findAllByPrincipalAndTypeAndTimestampBetweenForExport( @Param("principal") String principal, @Param("type") String type, @@ -112,7 +124,51 @@ public interface PersistentAuditEventRepository extends JpaRepository countByPrincipal(); + @Query( + "SELECT e.type, COUNT(e) FROM PersistentAuditEvent e WHERE e.timestamp BETWEEN" + + " :startDate AND :endDate GROUP BY e.type") + List countByTypeBetween( + @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + + @Query( + "SELECT e.principal, COUNT(e) FROM PersistentAuditEvent e WHERE e.timestamp BETWEEN" + + " :startDate AND :endDate GROUP BY e.principal") + List countByPrincipalBetween( + @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + + // Portable time-bucketing using YEAR/MONTH/DAY functions (works across most dialects) + @Query( + "SELECT YEAR(e.timestamp), MONTH(e.timestamp), DAY(e.timestamp), COUNT(e) " + + "FROM PersistentAuditEvent e " + + "WHERE e.timestamp BETWEEN :startDate AND :endDate " + + "GROUP BY YEAR(e.timestamp), MONTH(e.timestamp), DAY(e.timestamp) " + + "ORDER BY YEAR(e.timestamp), MONTH(e.timestamp), DAY(e.timestamp)") + List histogramByDayBetween( + @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + + @Query( + "SELECT HOUR(e.timestamp), COUNT(e) FROM PersistentAuditEvent e WHERE e.timestamp" + + " BETWEEN :startDate AND :endDate GROUP BY HOUR(e.timestamp) ORDER BY" + + " HOUR(e.timestamp)") + List histogramByHourBetween( + @Param("startDate") Instant startDate, @Param("endDate") Instant endDate); + // Get distinct event types for filtering @Query("SELECT DISTINCT e.type FROM PersistentAuditEvent e ORDER BY e.type") List findDistinctEventTypes(); + + @Query("SELECT DISTINCT e.principal FROM PersistentAuditEvent e ORDER BY e.principal") + List findDistinctPrincipals(); + + @Query( + "SELECT DISTINCT e.principal FROM PersistentAuditEvent e WHERE e.type = :type ORDER BY" + + " e.principal") + List findDistinctPrincipalsByType(@Param("type") String type); + + // Top/Latest helpers & existence checks + Optional findTopByOrderByTimestampDesc(); + + Optional findTopByPrincipalOrderByTimestampDesc(String principal); + + Optional findTopByTypeOrderByTimestampDesc(String type); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java index 15baef7db..987d5fb6f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java @@ -11,7 +11,6 @@ import org.springframework.stereotype.Component; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; -import stirling.software.common.util.GeneralUtils; import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License; @Slf4j @@ -86,12 +85,6 @@ public class LicenseKeyChecker { return keyOrFilePath; } - public void updateLicenseKey(String newKey) throws IOException { - applicationProperties.getPremium().setKey(newKey); - GeneralUtils.saveKeyToSettings("EnterpriseEdition.key", newKey); - checkLicense(); - } - public License getPremiumLicenseEnabledResult() { return premiumEnabledResult; } 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 4401403c6..f89290e93 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 @@ -33,6 +33,9 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.exception.UnsupportedProviderException; +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.audit.Audited; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.AuthenticationType; @@ -82,6 +85,7 @@ public class UserController { @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/change-username") + @Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC) public RedirectView changeUsername( Principal principal, @RequestParam(name = "currentPasswordChangeUsername") String currentPassword, @@ -125,6 +129,7 @@ public class UserController { @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/change-password-on-login") + @Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC) public RedirectView changePasswordOnLogin( Principal principal, @RequestParam(name = "currentPassword") String currentPassword, @@ -153,6 +158,7 @@ public class UserController { @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/change-password") + @Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC) public RedirectView changePassword( Principal principal, @RequestParam(name = "currentPassword") String currentPassword, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java index 7175a5b5d..2f85bd566 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java @@ -16,11 +16,16 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.audit.Audited; + @Slf4j public class CustomOAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { @Override + @Audited(type = AuditEventType.USER_FAILED_LOGIN, level = AuditLevel.BASIC) public void onAuthenticationFailure( HttpServletRequest request, HttpServletResponse response, 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 4e7ed9d9e..eba2bcc62 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 @@ -24,6 +24,9 @@ import lombok.RequiredArgsConstructor; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.common.util.RequestUriUtils; +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.audit.Audited; import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; @@ -39,6 +42,7 @@ public class CustomOAuth2AuthenticationSuccessHandler private final JwtServiceInterface jwtService; @Override + @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationFailureHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationFailureHandler.java index 7bf0c3a3b..21c1da953 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationFailureHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationFailureHandler.java @@ -14,11 +14,16 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.audit.Audited; + @Slf4j @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") public class CustomSaml2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { @Override + @Audited(type = AuditEventType.USER_FAILED_LOGIN, level = AuditLevel.BASIC) public void onAuthenticationFailure( HttpServletRequest request, HttpServletResponse response, 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 3255cbc15..0f0c50d7d 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 @@ -23,6 +23,9 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.common.util.RequestUriUtils; +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.audit.Audited; import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; @@ -39,6 +42,7 @@ public class CustomSaml2AuthenticationSuccessHandler private final JwtServiceInterface jwtService; @Override + @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException { diff --git a/app/proprietary/src/main/resources/static/js/audit/dashboard.js b/app/proprietary/src/main/resources/static/js/audit/dashboard.js index c0b93bd8e..35f0ab3d5 100644 --- a/app/proprietary/src/main/resources/static/js/audit/dashboard.js +++ b/app/proprietary/src/main/resources/static/js/audit/dashboard.js @@ -218,7 +218,7 @@ function loadAuditData(targetPage, realPageSize) { showLoading('table-loading'); // Always request page 0 from server, but with increased page size if needed - let url = `/audit/data?page=${requestedPage}&size=${realPageSize}`; + let url = `/api/v1/audit/data?page=${requestedPage}&size=${realPageSize}`; if (typeFilter) url += `&type=${encodeURIComponent(typeFilter)}`; if (principalFilter) url += `&principal=${encodeURIComponent(principalFilter)}`; @@ -302,7 +302,7 @@ function loadStats(days) { showLoading('user-chart-loading'); showLoading('time-chart-loading'); - fetchWithCsrf(`/audit/stats?days=${days}`) + fetchWithCsrf(`/api/v1/audit/stats?days=${days}`) .then(response => response.json()) .then(data => { document.getElementById('total-events').textContent = data.totalEvents; @@ -328,7 +328,7 @@ function exportAuditData(format) { const startDate = exportStartDateFilter.value; const endDate = exportEndDateFilter.value; - let url = format === 'json' ? '/audit/export/json?' : '/audit/export?'; + let url = format === 'json' ? '/api/v1/audit/export/json?' : '/api/v1/audit/export/csv?'; if (type) url += `&type=${encodeURIComponent(type)}`; if (principal) url += `&principal=${encodeURIComponent(principal)}`; @@ -835,7 +835,7 @@ function hideLoading(id) { // Load event types from the server for filter dropdowns function loadEventTypes() { - fetchWithCsrf('/audit/types') + fetchWithCsrf('/api/v1/audit/types') .then(response => response.json()) .then(types => { if (!types || types.length === 0) { From fbee4b99e42adabb854f43116a1bdb7580c93b31 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:33:56 +0100 Subject: [PATCH 15/41] build(deps): bump actions/dependency-review-action from 4.7.1 to 4.7.2 (#4230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/dependency-review-action](https://github.com/actions/dependency-review-action) from 4.7.1 to 4.7.2.
Release notes

Sourced from actions/dependency-review-action's releases.

4.7.2

What's Changed

New Contributors

Full Changelog: https://github.com/actions/dependency-review-action/compare/v4...v4.7.2

Commits
  • bc41886 Cut 4.7.2 version release (#964)
  • 1c73553 Merge pull request #960 from ahpook/ahpook/address-docs-dashes
  • fac3d41 Bump the minor-updates group across 1 directory with 5 updates (#956)
  • d8073c4 Merge pull request #958 from actions/claire153/deprecate-deny-lists
  • 77184c6 Fix tests
  • 5558c35 Address discrepancy between docs and reality
  • e85d57a Remove test code
  • 3eb6279 Re-add test package. Only show warning in summary if option is used. Update c...
  • 7cf33ac Remove test deny list
  • 493bee0 Remove test package
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/dependency-review-action&package-manager=github_actions&previous-version=4.7.1&new-version=4.7.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/dependency-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 8d938011d..d55dbd783 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -24,4 +24,4 @@ jobs: - name: "Checkout Repository" uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: "Dependency Review" - uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1 + uses: actions/dependency-review-action@bc41886e18ea39df68b1b1245f4184881938e050 # v4.7.2 From 12d4e26aa35116920d3b31b046532ef7afce181b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:34:11 +0100 Subject: [PATCH 16/41] build(deps): bump jwtVersion from 0.12.6 to 0.12.7 (#4229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps `jwtVersion` from 0.12.6 to 0.12.7. Updates `io.jsonwebtoken:jjwt-api` from 0.12.6 to 0.12.7
Release notes

Sourced from io.jsonwebtoken:jjwt-api's releases.

0.12.7

This patch release:

  • Adds a new Maven BOM! This is useful for multi-module projects. See Issue 967.

  • Allows the JwtParserBuilder to have empty nested algorithm collections, effectively disabling the parser's associated feature:

    • Emptying the zip() nested collection disables JWT decompression.
    • Emptying the sig() nested collection disables JWS mac/signature verification (i.e. all JWSs will be unsupported/rejected).
    • Emptying either the enc() or key() nested collections disables JWE decryption (i.e. all JWEs will be unsupported/rejected)

    See Issue 996.

  • Fixes bug 961 where JwtParserBuilder nested collection builders were not correctly replacing algorithms with the same id.

  • Ensures a JwkSet's keys collection is no longer entirely secret/redacted by default. This was an overzealous default that was unnecessarily restrictive; the keys collection itself should always be public, and each individual key within should determine which fields should be redacted when printed. See Issue 976.

  • Improves performance slightly by ensuring all jjwt-api utility methods that create *Builder instances (Jwts.builder(), Jwts.parserBuilder(), Jwks.builder(), etc) no longer use reflection.

    Instead,static factories are created via reflection only once during initial jjwt-api classloading, and then *Builders are created via standard instantiation using the new operator thereafter. This also benefits certain environments that may not have ideal ClassLoader implementations (e.g. Tomcat in some cases).

    NOTE: because this changes which classes are loaded via reflection, any environments that must explicitly reference reflective class names (e.g. GraalVM applications) will need to be updated to reflect the new factory class names.

    See Issue 988.

  • Upgrades the Gson dependency to 2.11.0

  • Upgrades the BouncyCastle dependency to 1.78.1

New Contributors

Full Changelog: https://github.com/jwtk/jjwt/compare/0.12.6...0.12.7

Changelog

Sourced from io.jsonwebtoken:jjwt-api's changelog.

0.12.7

This patch release:

  • Adds a new Maven BOM, useful for multi-module projects. See Issue 967.

  • Allows the JwtParserBuilder to have empty nested algorithm collections, effectively disabling the parser's associated feature:

    • Emptying the zip() nested collection disables JWT decompression.
    • Emptying the sig() nested collection disables JWS mac/signature verification (i.e. all JWSs will be unsupported/rejected).
    • Emptying either the enc() or key() nested collections disables JWE decryption (i.e. all JWEs will be unsupported/rejected)

    See Issue 996.

  • Fixes bug 961 where JwtParserBuilder nested collection builders were not correctly replacing algorithms with the same id.

  • Ensures a JwkSet's keys collection is no longer entirely secret/redacted by default. This was an overzealous default that was unnecessarily restrictive; the keys collection itself should always be public, and each individual key within should determine which fields should be redacted when printed. See Issue 976.

  • Improves performance slightly by ensuring all jjwt-api utility methods that create *Builder instances (Jwts.builder(), Jwts.parserBuilder(), Jwks.builder(), etc) no longer use reflection.

    Instead,static factories are created via reflection only once during initial jjwt-api classloading, and then *Builders are created via standard instantiation using the new operator thereafter. This also benefits certain environments that may not have ideal ClassLoader implementations (e.g. Tomcat in some cases).

    NOTE: because this changes which classes are loaded via reflection, any environments that must explicitly reference reflective class names (e.g. GraalVM applications) will need to be updated to reflect the new factory class names.

    See Issue 988.

  • Upgrades the Gson dependency to 2.11.0

  • Upgrades the BouncyCastle dependency to 1.78.1

Commits
  • 77aeda0 [maven-release-plugin] prepare release 0.12.7
  • 47d966f Testing latest sonatype central publishing guidelines
  • 22ca29f [maven-release-plugin] rollback the release of 0.12.7
  • 0487f9b [maven-release-plugin] prepare for next development iteration
  • 4329125 [maven-release-plugin] prepare release 0.12.7
  • 0ddc514 - Ensured JJWT_RELEASE_VERSION placeholders reference 0.12.7
  • efed1cf Updated 0.12.7 change list
  • ca27b12 Resolves #1010 (#1011)
  • 55c7b9a Resolves #771 (#1009)
  • 6e9c6a5 Bump org.bouncycastle:bcpkix-jdk18on from 1.78 to 1.78.1 (#1008)
  • Additional commits viewable in compare view

Updates `io.jsonwebtoken:jjwt-impl` from 0.12.6 to 0.12.7
Release notes

Sourced from io.jsonwebtoken:jjwt-impl's releases.

0.12.7

This patch release:

  • Adds a new Maven BOM! This is useful for multi-module projects. See Issue 967.

  • Allows the JwtParserBuilder to have empty nested algorithm collections, effectively disabling the parser's associated feature:

    • Emptying the zip() nested collection disables JWT decompression.
    • Emptying the sig() nested collection disables JWS mac/signature verification (i.e. all JWSs will be unsupported/rejected).
    • Emptying either the enc() or key() nested collections disables JWE decryption (i.e. all JWEs will be unsupported/rejected)

    See Issue 996.

  • Fixes bug 961 where JwtParserBuilder nested collection builders were not correctly replacing algorithms with the same id.

  • Ensures a JwkSet's keys collection is no longer entirely secret/redacted by default. This was an overzealous default that was unnecessarily restrictive; the keys collection itself should always be public, and each individual key within should determine which fields should be redacted when printed. See Issue 976.

  • Improves performance slightly by ensuring all jjwt-api utility methods that create *Builder instances (Jwts.builder(), Jwts.parserBuilder(), Jwks.builder(), etc) no longer use reflection.

    Instead,static factories are created via reflection only once during initial jjwt-api classloading, and then *Builders are created via standard instantiation using the new operator thereafter. This also benefits certain environments that may not have ideal ClassLoader implementations (e.g. Tomcat in some cases).

    NOTE: because this changes which classes are loaded via reflection, any environments that must explicitly reference reflective class names (e.g. GraalVM applications) will need to be updated to reflect the new factory class names.

    See Issue 988.

  • Upgrades the Gson dependency to 2.11.0

  • Upgrades the BouncyCastle dependency to 1.78.1

New Contributors

Full Changelog: https://github.com/jwtk/jjwt/compare/0.12.6...0.12.7

Changelog

Sourced from io.jsonwebtoken:jjwt-impl's changelog.

0.12.7

This patch release:

  • Adds a new Maven BOM, useful for multi-module projects. See Issue 967.

  • Allows the JwtParserBuilder to have empty nested algorithm collections, effectively disabling the parser's associated feature:

    • Emptying the zip() nested collection disables JWT decompression.
    • Emptying the sig() nested collection disables JWS mac/signature verification (i.e. all JWSs will be unsupported/rejected).
    • Emptying either the enc() or key() nested collections disables JWE decryption (i.e. all JWEs will be unsupported/rejected)

    See Issue 996.

  • Fixes bug 961 where JwtParserBuilder nested collection builders were not correctly replacing algorithms with the same id.

  • Ensures a JwkSet's keys collection is no longer entirely secret/redacted by default. This was an overzealous default that was unnecessarily restrictive; the keys collection itself should always be public, and each individual key within should determine which fields should be redacted when printed. See Issue 976.

  • Improves performance slightly by ensuring all jjwt-api utility methods that create *Builder instances (Jwts.builder(), Jwts.parserBuilder(), Jwks.builder(), etc) no longer use reflection.

    Instead,static factories are created via reflection only once during initial jjwt-api classloading, and then *Builders are created via standard instantiation using the new operator thereafter. This also benefits certain environments that may not have ideal ClassLoader implementations (e.g. Tomcat in some cases).

    NOTE: because this changes which classes are loaded via reflection, any environments that must explicitly reference reflective class names (e.g. GraalVM applications) will need to be updated to reflect the new factory class names.

    See Issue 988.

  • Upgrades the Gson dependency to 2.11.0

  • Upgrades the BouncyCastle dependency to 1.78.1

Commits
  • 77aeda0 [maven-release-plugin] prepare release 0.12.7
  • 47d966f Testing latest sonatype central publishing guidelines
  • 22ca29f [maven-release-plugin] rollback the release of 0.12.7
  • 0487f9b [maven-release-plugin] prepare for next development iteration
  • 4329125 [maven-release-plugin] prepare release 0.12.7
  • 0ddc514 - Ensured JJWT_RELEASE_VERSION placeholders reference 0.12.7
  • efed1cf Updated 0.12.7 change list
  • ca27b12 Resolves #1010 (#1011)
  • 55c7b9a Resolves #771 (#1009)
  • 6e9c6a5 Bump org.bouncycastle:bcpkix-jdk18on from 1.78 to 1.78.1 (#1008)
  • Additional commits viewable in compare view

Updates `io.jsonwebtoken:jjwt-jackson` from 0.12.6 to 0.12.7 Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/proprietary/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index b8862bdd8..0254d63ed 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -3,7 +3,7 @@ repositories { } ext { - jwtVersion = '0.12.6' + jwtVersion = '0.12.7' } bootRun { From 246a59a79436472f05a2dae6a07257f0cd0339e5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:34:26 +0100 Subject: [PATCH 17/41] build(deps): bump github/codeql-action from 3.29.8 to 3.29.10 (#4231) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.29.8 to 3.29.10.
Release notes

Sourced from github/codeql-action's releases.

v3.29.10

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

3.29.10 - 18 Aug 2025

No user facing changes.

See the full CHANGELOG.md for more information.

v3.29.9

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

3.29.9 - 12 Aug 2025

No user facing changes.

See the full CHANGELOG.md for more information.

Changelog

Sourced from github/codeql-action's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

3.29.10 - 18 Aug 2025

No user facing changes.

3.29.9 - 12 Aug 2025

No user facing changes.

3.29.8 - 08 Aug 2025

  • Fix an issue where the Action would autodetect unsupported languages such as HTML. #3015

3.29.7 - 07 Aug 2025

This release rolls back 3.29.6 to address issues with language autodetection. It is identical to 3.29.5.

3.29.6 - 07 Aug 2025

  • The cleanup-level input to the analyze Action is now deprecated. The CodeQL Action has written a limited amount of intermediate results to the database since version 2.2.5, and now automatically manages cleanup. #2999
  • Update default CodeQL bundle version to 2.22.3. #3000

3.29.5 - 29 Jul 2025

  • Update default CodeQL bundle version to 2.22.2. #2986

3.29.4 - 23 Jul 2025

No user facing changes.

3.29.3 - 21 Jul 2025

No user facing changes.

3.29.2 - 30 Jun 2025

  • Experimental: When the quality-queries input for the init action is provided with an argument, separate .quality.sarif files are produced and uploaded for each language with the results of the specified queries. Do not use this in production as it is part of an internal experiment and subject to change at any time. #2935

3.29.1 - 27 Jun 2025

  • Fix bug in PR analysis where user-provided include query filter fails to exclude non-included queries. #2938
  • Update default CodeQL bundle version to 2.22.1. #2950

... (truncated)

Commits
  • 96f518a Merge pull request #3042 from github/update-v3.29.10-6ec994ecb
  • 57a1c6b Update changelog for v3.29.10
  • 6ec994e Merge pull request #3039 from github/mbg/remove-cpp-bmn-check
  • 3f00c7c Remove unused C++ BMN FF
  • 141ee4a Remove C++ BMN FF check that is no longer used
  • 2330521 Merge pull request #3037 from github/henrymercer/failed-upload-logs
  • 3966569 Merge pull request #3035 from github/henrymercer/fix-cleanup-info
  • f7bd70c Merge branch 'main' into henrymercer/failed-upload-logs
  • 75151c2 Merge branch 'main' into henrymercer/fix-cleanup-info
  • 4ff91f1 Merge pull request #3036 from github/mbg/ci/gradle9
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action&package-manager=github_actions&previous-version=3.29.8&new-version=3.29.10)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/scorecards.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index a3a355845..53ad28c84 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -74,6 +74,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.5 + uses: github/codeql-action/upload-sarif@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.5 with: sarif_file: results.sarif From c10474fd3035fcde5e9d911e6c8a9eb4be015398 Mon Sep 17 00:00:00 2001 From: Ludy Date: Wed, 20 Aug 2025 16:35:24 +0200 Subject: [PATCH 18/41] fix(h2): refine SQL condition check for custom database flag (#4216) # Description of Changes - Refactored `H2SQLCondition.matches` to use `env.getProperty` with proper default values and types. - Adjusted logic to only return `false` when a custom database is enabled and datasource type is not `h2`. - Simplified environment variable handling for better readability and robustness. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../proprietary/security/database/H2SQLCondition.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/H2SQLCondition.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/H2SQLCondition.java index 4e259e49b..6cb5d2bce 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/H2SQLCondition.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/H2SQLCondition.java @@ -8,16 +8,15 @@ public class H2SQLCondition implements Condition { @Override public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { + var env = context.getEnvironment(); boolean enableCustomDatabase = - Boolean.parseBoolean( - context.getEnvironment() - .getProperty("system.datasource.enableCustomDatabase")); + env.getProperty("system.datasource.enableCustomDatabase", Boolean.class, false); - if (!enableCustomDatabase) { + if (enableCustomDatabase) { return false; } - String dataSourceType = context.getEnvironment().getProperty("system.datasource.type"); + String dataSourceType = env.getProperty("system.datasource.type", String.class, ""); return "h2".equalsIgnoreCase(dataSourceType); } } From ab7cef5a97446997750271838704b25e73963215 Mon Sep 17 00:00:00 2001 From: Ludy Date: Wed, 20 Aug 2025 16:36:39 +0200 Subject: [PATCH 19/41] feat(common,core,proprietary): remove unused injections, enhance type safety, and improve test mocks (#4213) # Description of Changes This PR introduces several refactorings and minor enhancements across the `common`, `core`, and `proprietary` modules: - **Dependency Injection Cleanup** - Removed unused constructor-injected dependencies (e.g., `FileOrUploadService`, `ApplicationProperties`, redundant `@Autowired` annotations). - Simplified constructors to only require actively used dependencies. - **Model Enhancements** - Added `@NoArgsConstructor` to `FileInfo`, `PdfMetadata`, and `SignatureFile` to improve serialization/deserialization support. - **Service Improvements** - Improved `JobExecutorService` content type retrieval by assigning `MediaType` to a variable before conversion. - Enhanced `KeyPersistenceService` with type-safe `.filter(JwtVerificationKey.class::isInstance)`. - Annotated `decodePublicKey` in `KeyPersistenceService` with `@Override` for clarity. - **Controller & API Changes** - Updated `AdminSettingsController` to use `TypeReference>` for safer conversion. - Improved long log and description strings with consistent formatting. - **Testing Updates** - Replaced `.lenient()` mock settings with `.defaultAnswer(RETURNS_DEFAULTS)` for `FileToPdf` static mocks. - Used `ArgumentMatchers.>>any()` in `EditTableOfContentsControllerTest` for type safety. - Updated `UserServiceTest` default `AuthenticationType` from `SSO` to `OAUTH2`. - **Formatting** - Broke up long log/debug lines for better readability. - Removed redundant `@SuppressWarnings` where type safety was ensured. These changes aim to make the codebase leaner, more type-safe, and maintainable, while improving test reliability. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../software/common/aop/AutoJobAspect.java | 8 ++--- .../software/common/model/FileInfo.java | 4 ++- .../software/common/model/PdfMetadata.java | 4 +++ .../common/service/JobExecutorService.java | 6 ++-- .../common/util/CustomHtmlSanitizer.java | 2 -- .../AutoJobPostMappingIntegrationTest.java | 6 +--- .../software/common/util/EmlToPdfTest.java | 15 +++++++-- .../software/SPDF/SPDFApplication.java | 4 --- .../SPDF/config/EndpointInspector.java | 15 ++++----- .../software/SPDF/model/SignatureFile.java | 2 ++ .../service/MetricsAggregatorService.java | 10 +++--- .../EditTableOfContentsControllerTest.java | 19 ++++++++--- .../api/AdminSettingsController.java | 33 ++++++++++++------- .../filter/UserAuthenticationFilter.java | 18 +++++----- .../security/service/JwtService.java | 5 ++- .../service/KeyPairCleanupService.java | 2 -- .../service/KeyPersistenceService.java | 7 ++-- .../security/service/UserServiceTest.java | 2 +- 18 files changed, 91 insertions(+), 71 deletions(-) diff --git a/app/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java b/app/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java index 2ee10ebcd..ac36cd0d7 100644 --- a/app/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java +++ b/app/common/src/main/java/stirling/software/common/aop/AutoJobAspect.java @@ -19,7 +19,6 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.model.api.PDFFile; -import stirling.software.common.service.FileOrUploadService; import stirling.software.common.service.FileStorage; import stirling.software.common.service.JobExecutorService; @@ -34,7 +33,6 @@ public class AutoJobAspect { private final JobExecutorService jobExecutorService; private final HttpServletRequest request; - private final FileOrUploadService fileOrUploadService; private final FileStorage fileStorage; @Around("@annotation(autoJobPostMapping)") @@ -53,7 +51,8 @@ public class AutoJobAspect { boolean trackProgress = autoJobPostMapping.trackProgress(); log.debug( - "AutoJobPostMapping execution with async={}, timeout={}, retryCount={}, trackProgress={}", + "AutoJobPostMapping execution with async={}, timeout={}, retryCount={}," + + " trackProgress={}", async, timeout > 0 ? timeout : "default", retryCount, @@ -148,7 +147,8 @@ public class AutoJobAspect { } catch (Throwable ex) { lastException = ex; log.error( - "AutoJobAspect caught exception during job execution (attempt {}/{}): {}", + "AutoJobAspect caught exception during job execution (attempt" + + " {}/{}): {}", currentAttempt, maxRetries, ex.getMessage(), diff --git a/app/common/src/main/java/stirling/software/common/model/FileInfo.java b/app/common/src/main/java/stirling/software/common/model/FileInfo.java index 41a3a4717..2e3e59e83 100644 --- a/app/common/src/main/java/stirling/software/common/model/FileInfo.java +++ b/app/common/src/main/java/stirling/software/common/model/FileInfo.java @@ -8,9 +8,11 @@ import java.util.Locale; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; -@AllArgsConstructor @Data +@NoArgsConstructor +@AllArgsConstructor public class FileInfo { private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); diff --git a/app/common/src/main/java/stirling/software/common/model/PdfMetadata.java b/app/common/src/main/java/stirling/software/common/model/PdfMetadata.java index ef8684788..be588ec86 100644 --- a/app/common/src/main/java/stirling/software/common/model/PdfMetadata.java +++ b/app/common/src/main/java/stirling/software/common/model/PdfMetadata.java @@ -2,11 +2,15 @@ package stirling.software.common.model; import java.util.Calendar; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; @Data @Builder +@NoArgsConstructor +@AllArgsConstructor public class PdfMetadata { private String author; private String producer; diff --git a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java index 73afa22a0..ac0833995 100644 --- a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java +++ b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java @@ -252,8 +252,10 @@ public class JobExecutorService { } } - if (response.getHeaders().getContentType() != null) { - contentType = response.getHeaders().getContentType().toString(); + MediaType mediaType = response.getHeaders().getContentType(); + + if (mediaType != null) { + contentType = mediaType.toString(); } // Store byte array directly to disk diff --git a/app/common/src/main/java/stirling/software/common/util/CustomHtmlSanitizer.java b/app/common/src/main/java/stirling/software/common/util/CustomHtmlSanitizer.java index 05d9b73a6..60bf9ab83 100644 --- a/app/common/src/main/java/stirling/software/common/util/CustomHtmlSanitizer.java +++ b/app/common/src/main/java/stirling/software/common/util/CustomHtmlSanitizer.java @@ -4,7 +4,6 @@ import org.owasp.html.AttributePolicy; import org.owasp.html.HtmlPolicyBuilder; import org.owasp.html.PolicyFactory; import org.owasp.html.Sanitizers; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import stirling.software.common.model.ApplicationProperties; @@ -16,7 +15,6 @@ public class CustomHtmlSanitizer { private final SsrfProtectionService ssrfProtectionService; private final ApplicationProperties applicationProperties; - @Autowired public CustomHtmlSanitizer( SsrfProtectionService ssrfProtectionService, ApplicationProperties applicationProperties) { diff --git a/app/common/src/test/java/stirling/software/common/annotations/AutoJobPostMappingIntegrationTest.java b/app/common/src/test/java/stirling/software/common/annotations/AutoJobPostMappingIntegrationTest.java index 2c4546ac0..dd7a0f79b 100644 --- a/app/common/src/test/java/stirling/software/common/annotations/AutoJobPostMappingIntegrationTest.java +++ b/app/common/src/test/java/stirling/software/common/annotations/AutoJobPostMappingIntegrationTest.java @@ -29,7 +29,6 @@ import jakarta.servlet.http.HttpServletRequest; import stirling.software.common.aop.AutoJobAspect; import stirling.software.common.model.api.PDFFile; -import stirling.software.common.service.FileOrUploadService; import stirling.software.common.service.FileStorage; import stirling.software.common.service.JobExecutorService; import stirling.software.common.service.JobQueue; @@ -44,8 +43,6 @@ class AutoJobPostMappingIntegrationTest { @Mock private HttpServletRequest request; - @Mock private FileOrUploadService fileOrUploadService; - @Mock private FileStorage fileStorage; @Mock private ResourceMonitor resourceMonitor; @@ -54,8 +51,7 @@ class AutoJobPostMappingIntegrationTest { @BeforeEach void setUp() { - autoJobAspect = - new AutoJobAspect(jobExecutorService, request, fileOrUploadService, fileStorage); + autoJobAspect = new AutoJobAspect(jobExecutorService, request, fileStorage); } @Mock private ProceedingJoinPoint joinPoint; diff --git a/app/common/src/test/java/stirling/software/common/util/EmlToPdfTest.java b/app/common/src/test/java/stirling/software/common/util/EmlToPdfTest.java index 952bc692a..9385d260c 100644 --- a/app/common/src/test/java/stirling/software/common/util/EmlToPdfTest.java +++ b/app/common/src/test/java/stirling/software/common/util/EmlToPdfTest.java @@ -586,7 +586,10 @@ class EmlToPdfTest { when(mockPdDocument.getNumberOfPages()).thenReturn(1); try (MockedStatic fileToPdf = - mockStatic(FileToPdf.class, org.mockito.Mockito.withSettings().lenient())) { + mockStatic( + FileToPdf.class, + org.mockito.Mockito.withSettings() + .defaultAnswer(org.mockito.Answers.RETURNS_DEFAULTS))) { fileToPdf .when( () -> @@ -657,7 +660,10 @@ class EmlToPdfTest { when(mockPdDocument.getNumberOfPages()).thenReturn(1); try (MockedStatic fileToPdf = - mockStatic(FileToPdf.class, org.mockito.Mockito.withSettings().lenient())) { + mockStatic( + FileToPdf.class, + org.mockito.Mockito.withSettings() + .defaultAnswer(org.mockito.Answers.RETURNS_DEFAULTS))) { fileToPdf .when( () -> @@ -724,7 +730,10 @@ class EmlToPdfTest { String errorMessage = "Conversion failed"; try (MockedStatic fileToPdf = - mockStatic(FileToPdf.class, org.mockito.Mockito.withSettings().lenient())) { + mockStatic( + FileToPdf.class, + org.mockito.Mockito.withSettings() + .defaultAnswer(org.mockito.Answers.RETURNS_DEFAULTS))) { fileToPdf .when( () -> diff --git a/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java b/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java index 2131b4239..9322cea23 100644 --- a/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java +++ b/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java @@ -27,7 +27,6 @@ import stirling.software.SPDF.UI.WebBrowser; import stirling.software.common.configuration.AppConfig; import stirling.software.common.configuration.ConfigInitializer; import stirling.software.common.configuration.InstallationPathConfig; -import stirling.software.common.model.ApplicationProperties; import stirling.software.common.util.UrlUtils; @Slf4j @@ -46,17 +45,14 @@ public class SPDFApplication { private final AppConfig appConfig; private final Environment env; - private final ApplicationProperties applicationProperties; private final WebBrowser webBrowser; public SPDFApplication( AppConfig appConfig, Environment env, - ApplicationProperties applicationProperties, @Autowired(required = false) WebBrowser webBrowser) { this.appConfig = appConfig; this.env = env; - this.applicationProperties = applicationProperties; this.webBrowser = webBrowser; } diff --git a/app/core/src/main/java/stirling/software/SPDF/config/EndpointInspector.java b/app/core/src/main/java/stirling/software/SPDF/config/EndpointInspector.java index d9ceb0f9d..457213412 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/EndpointInspector.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/EndpointInspector.java @@ -6,8 +6,6 @@ import java.util.Map; import java.util.Set; import java.util.TreeSet; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationListener; import org.springframework.context.event.ContextRefreshedEvent; @@ -18,11 +16,12 @@ import org.springframework.web.servlet.mvc.method.RequestMappingInfo; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; @Component @RequiredArgsConstructor +@Slf4j public class EndpointInspector implements ApplicationListener { - private static final Logger logger = LoggerFactory.getLogger(EndpointInspector.class); private final ApplicationContext applicationContext; private final Set validGetEndpoints = new HashSet<>(); @@ -71,13 +70,13 @@ public class EndpointInspector implements ApplicationListener sortedEndpoints = new TreeSet<>(validGetEndpoints); - logger.info("=== BEGIN: All discovered GET endpoints ==="); + log.info("=== BEGIN: All discovered GET endpoints ==="); for (String endpoint : sortedEndpoints) { - logger.info("Endpoint: {}", endpoint); + log.info("Endpoint: {}", endpoint); } - logger.info("=== END: All discovered GET endpoints ==="); + log.info("=== END: All discovered GET endpoints ==="); } } diff --git a/app/core/src/main/java/stirling/software/SPDF/model/SignatureFile.java b/app/core/src/main/java/stirling/software/SPDF/model/SignatureFile.java index 7d82ebf0f..89ea49644 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/SignatureFile.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/SignatureFile.java @@ -2,8 +2,10 @@ package stirling.software.SPDF.model; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; @Data +@NoArgsConstructor @AllArgsConstructor public class SignatureFile { private String fileName; diff --git a/app/core/src/main/java/stirling/software/SPDF/service/MetricsAggregatorService.java b/app/core/src/main/java/stirling/software/SPDF/service/MetricsAggregatorService.java index 181757a04..3b12c9d5e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/MetricsAggregatorService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/MetricsAggregatorService.java @@ -4,8 +4,6 @@ import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -13,15 +11,15 @@ import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.search.Search; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.EndpointInspector; import stirling.software.common.service.PostHogService; @Service @RequiredArgsConstructor +@Slf4j public class MetricsAggregatorService { - private static final Logger logger = LoggerFactory.getLogger(MetricsAggregatorService.class); - private final MeterRegistry meterRegistry; private final PostHogService postHogService; private final EndpointInspector endpointInspector; @@ -66,7 +64,7 @@ public class MetricsAggregatorService { if ("GET".equals(method) && validateGetEndpoints && !endpointInspector.isValidGetEndpoint(uri)) { - logger.debug("Skipping invalid GET endpoint: {}", uri); + log.debug("Skipping invalid GET endpoint: {}", uri); return; } @@ -77,7 +75,7 @@ public class MetricsAggregatorService { double lastCount = lastSentMetrics.getOrDefault(key, 0.0); double difference = currentCount - lastCount; if (difference > 0) { - logger.debug("{}, {}", key, difference); + log.debug("{}, {}", key, difference); metrics.put(key, difference); lastSentMetrics.put(key, currentCount); } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/EditTableOfContentsControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/EditTableOfContentsControllerTest.java index fc8783f73..b829531c9 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/EditTableOfContentsControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/EditTableOfContentsControllerTest.java @@ -20,6 +20,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -202,7 +203,9 @@ class EditTableOfContentsControllerTest { bookmarks.add(bookmark); when(pdfDocumentFactory.load(mockFile)).thenReturn(mockDocument); - when(objectMapper.readValue(eq(request.getBookmarkData()), any(TypeReference.class))) + when(objectMapper.readValue( + eq(request.getBookmarkData()), + ArgumentMatchers.>>any())) .thenReturn(bookmarks); when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog); when(mockDocument.getNumberOfPages()).thenReturn(5); @@ -242,7 +245,8 @@ class EditTableOfContentsControllerTest { request.setFileInput(mockFile); String bookmarkJson = - "[{\"title\":\"Chapter 1\",\"pageNumber\":1,\"children\":[{\"title\":\"Section 1.1\",\"pageNumber\":2,\"children\":[]}]}]"; + "[{\"title\":\"Chapter 1\",\"pageNumber\":1,\"children\":[{\"title\":\"Section" + + " 1.1\",\"pageNumber\":2,\"children\":[]}]}]"; request.setBookmarkData(bookmarkJson); List bookmarks = new ArrayList<>(); @@ -261,7 +265,9 @@ class EditTableOfContentsControllerTest { bookmarks.add(parentBookmark); when(pdfDocumentFactory.load(mockFile)).thenReturn(mockDocument); - when(objectMapper.readValue(eq(bookmarkJson), any(TypeReference.class))) + when(objectMapper.readValue( + eq(bookmarkJson), + ArgumentMatchers.>>any())) .thenReturn(bookmarks); when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog); when(mockDocument.getNumberOfPages()).thenReturn(5); @@ -292,7 +298,8 @@ class EditTableOfContentsControllerTest { EditTableOfContentsRequest request = new EditTableOfContentsRequest(); request.setFileInput(mockFile); request.setBookmarkData( - "[{\"title\":\"Chapter 1\",\"pageNumber\":-5,\"children\":[]},{\"title\":\"Chapter 2\",\"pageNumber\":100,\"children\":[]}]"); + "[{\"title\":\"Chapter 1\",\"pageNumber\":-5,\"children\":[]},{\"title\":\"Chapter" + + " 2\",\"pageNumber\":100,\"children\":[]}]"); List bookmarks = new ArrayList<>(); @@ -310,7 +317,9 @@ class EditTableOfContentsControllerTest { bookmarks.add(bookmark2); when(pdfDocumentFactory.load(mockFile)).thenReturn(mockDocument); - when(objectMapper.readValue(eq(request.getBookmarkData()), any(TypeReference.class))) + when(objectMapper.readValue( + eq(request.getBookmarkData()), + ArgumentMatchers.>>any())) .thenReturn(bookmarks); when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog); when(mockDocument.getNumberOfPages()).thenReturn(5); 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 ebe856b00..650ccbb1f 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 @@ -21,6 +21,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.util.HtmlUtils; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Operation; @@ -81,7 +82,8 @@ public class AdminSettingsController { @Operation( summary = "Get all application settings", description = - "Retrieve all current application settings. Use includePending=true to include settings that will take effect after restart. Admin access required.") + "Retrieve all current application settings. Use includePending=true to include" + + " settings that will take effect after restart. Admin access required.") @ApiResponses( value = { @ApiResponse(responseCode = "200", description = "Settings retrieved successfully"), @@ -95,7 +97,9 @@ public class AdminSettingsController { log.debug("Admin requested all application settings (includePending={})", includePending); // Convert ApplicationProperties to Map - Map settings = objectMapper.convertValue(applicationProperties, Map.class); + Map settings = + objectMapper.convertValue( + applicationProperties, new TypeReference>() {}); if (includePending && !pendingChanges.isEmpty()) { // Merge pending changes into the settings map @@ -112,7 +116,8 @@ public class AdminSettingsController { @Operation( summary = "Get pending settings changes", description = - "Retrieve settings that have been modified but not yet applied (require restart). Admin access required.") + "Retrieve settings that have been modified but not yet applied (require" + + " restart). Admin access required.") @ApiResponses( value = { @ApiResponse( @@ -137,7 +142,8 @@ public class AdminSettingsController { @Operation( summary = "Update application settings (delta updates)", description = - "Update specific application settings using dot notation keys. Only sends changed values. Changes take effect on restart. Admin access required.") + "Update specific application settings using dot notation keys. Only sends" + + " changed values. Changes take effect on restart. Admin access required.") @ApiResponses( value = { @ApiResponse(responseCode = "200", description = "Settings updated successfully"), @@ -178,7 +184,8 @@ public class AdminSettingsController { return ResponseEntity.ok( String.format( - "Successfully updated %d setting(s). Changes will take effect on application restart.", + "Successfully updated %d setting(s). Changes will take effect on" + + " application restart.", updatedCount)); } catch (IOException e) { @@ -199,7 +206,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). Admin" + + " access required.") @ApiResponses( value = { @ApiResponse( @@ -288,7 +296,8 @@ public class AdminSettingsController { String escapedSectionName = HtmlUtils.htmlEscape(sectionName); return ResponseEntity.ok( String.format( - "Successfully updated %d setting(s) in section '%s'. Changes will take effect on application restart.", + "Successfully updated %d setting(s) in section '%s'. Changes will take" + + " effect on application restart.", updatedCount, escapedSectionName)); } catch (IOException e) { @@ -308,7 +317,8 @@ public class AdminSettingsController { @Operation( summary = "Get specific setting value", description = - "Retrieve value for a specific setting key using dot notation. Admin access required.") + "Retrieve value for a specific setting key using dot notation. Admin access" + + " required.") @ApiResponses( value = { @ApiResponse( @@ -348,7 +358,8 @@ public class AdminSettingsController { @Operation( summary = "Update specific setting value", description = - "Update value for a specific setting key using dot notation. Admin access required.") + "Update value for a specific setting key using dot notation. Admin access" + + " required.") @ApiResponses( value = { @ApiResponse(responseCode = "200", description = "Setting updated successfully"), @@ -376,7 +387,8 @@ public class AdminSettingsController { String escapedKey = HtmlUtils.htmlEscape(key); return ResponseEntity.ok( String.format( - "Successfully updated setting '%s'. Changes will take effect on application restart.", + "Successfully updated setting '%s'. Changes will take effect on" + + " application restart.", escapedKey)); } catch (IOException e) { @@ -532,7 +544,6 @@ public class AdminSettingsController { * Recursively mask sensitive fields in settings map. Sensitive fields are replaced with a * status indicator showing if they're configured. */ - @SuppressWarnings("unchecked") private Map maskSensitiveFields(Map settings) { return maskSensitiveFieldsWithPath(settings, ""); } 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 f51a9d543..bec6f1d04 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 @@ -128,7 +128,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { // Check if the authenticated user is disabled and invalidate their session if so if (authentication != null && authentication.isAuthenticated()) { - LoginMethod loginMethod = LoginMethod.UNKNOWN; + UserLoginType loginMethod = UserLoginType.UNKNOWN; boolean blockRegistration = false; @@ -137,20 +137,20 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { String username = null; if (principal instanceof UserDetails detailsUser) { username = detailsUser.getUsername(); - loginMethod = LoginMethod.USERDETAILS; + loginMethod = UserLoginType.USERDETAILS; } else if (principal instanceof OAuth2User oAuth2User) { username = oAuth2User.getName(); - loginMethod = LoginMethod.OAUTH2USER; + loginMethod = UserLoginType.OAUTH2USER; OAUTH2 oAuth = securityProp.getOauth2(); blockRegistration = oAuth != null && oAuth.getBlockRegistration(); } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { username = saml2User.name(); - loginMethod = LoginMethod.SAML2USER; + loginMethod = UserLoginType.SAML2USER; SAML2 saml2 = securityProp.getSaml2(); blockRegistration = saml2 != null && saml2.getBlockRegistration(); } else if (principal instanceof String stringUser) { username = stringUser; - loginMethod = LoginMethod.STRINGUSER; + loginMethod = UserLoginType.STRINGUSER; } // Retrieve all active sessions for the user @@ -164,8 +164,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { boolean isUserDisabled = userService.isUserDisabled(username); boolean notSsoLogin = - !LoginMethod.OAUTH2USER.equals(loginMethod) - && !LoginMethod.SAML2USER.equals(loginMethod); + !UserLoginType.OAUTH2USER.equals(loginMethod) + && !UserLoginType.SAML2USER.equals(loginMethod); // Block user registration if not allowed by configuration if (blockRegistration && !isUserExists) { @@ -200,7 +200,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { filterChain.doFilter(request, response); } - private enum LoginMethod { + private enum UserLoginType { USERDETAILS("UserDetails"), OAUTH2USER("OAuth2User"), STRINGUSER("StringUser"), @@ -209,7 +209,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { private String method; - LoginMethod(String method) { + UserLoginType(String method) { this.method = method; } 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..af32a183a 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 @@ -12,7 +12,6 @@ import java.util.Map; import java.util.Optional; 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; @@ -53,7 +52,6 @@ public class JwtService implements JwtServiceInterface { private final KeyPersistenceServiceInterface keyPersistenceService; private final boolean v2Enabled; - @Autowired public JwtService( @Qualifier("v2Enabled") boolean v2Enabled, KeyPersistenceServiceInterface keyPersistenceService) { @@ -155,7 +153,8 @@ public class JwtService implements JwtServiceInterface { keyPair = specificKeyPair.get(); } else { log.warn( - "Key ID {} not found in keystore, token may have been signed with an expired key", + "Key ID {} not found in keystore, token may have been signed with an" + + " expired key", keyId); if (keyId.equals(keyPersistenceService.getActiveKey().getKeyId())) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPairCleanupService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPairCleanupService.java index b419f78fe..af7c5f7e2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPairCleanupService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPairCleanupService.java @@ -8,7 +8,6 @@ import java.time.LocalDateTime; import java.util.List; import java.util.concurrent.TimeUnit; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -30,7 +29,6 @@ public class KeyPairCleanupService { private final KeyPersistenceService keyPersistenceService; private final ApplicationProperties.Security.Jwt jwtProperties; - @Autowired public KeyPairCleanupService( KeyPersistenceService keyPersistenceService, ApplicationProperties applicationProperties) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java index 48bcddac0..cc07fbbc7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java @@ -20,7 +20,6 @@ import java.util.List; import java.util.Optional; import java.util.stream.Collectors; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.CacheEvict; @@ -43,16 +42,13 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface { public static final String KEY_SUFFIX = ".key"; private final ApplicationProperties.Security.Jwt jwtProperties; - private final CacheManager cacheManager; private final Cache verifyingKeyCache; private volatile JwtVerificationKey activeKey; - @Autowired public KeyPersistenceService( ApplicationProperties applicationProperties, CacheManager cacheManager) { this.jwtProperties = applicationProperties.getSecurity().getJwt(); - this.cacheManager = cacheManager; this.verifyingKeyCache = cacheManager.getCache("verifyingKeys"); } @@ -159,7 +155,7 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface { nativeCache.asMap().size()); return nativeCache.asMap().values().stream() - .filter(value -> value instanceof JwtVerificationKey) + .filter(JwtVerificationKey.class::isInstance) .map(value -> (JwtVerificationKey) value) .filter( key -> { @@ -233,6 +229,7 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface { return Base64.getEncoder().encodeToString(publicKey.getEncoded()); } + @Override public PublicKey decodePublicKey(String encodedKey) throws NoSuchAlgorithmException, InvalidKeySpecException { byte[] keyBytes = Base64.getDecoder().decode(encodedKey); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java index be087314a..c536cccdb 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java @@ -189,7 +189,7 @@ class UserServiceTest { void testSaveUser_WithValidEmail_Success() throws Exception { // Given String emailUsername = "test@example.com"; - AuthenticationType authType = AuthenticationType.SSO; + AuthenticationType authType = AuthenticationType.OAUTH2; when(teamRepository.findByName("Default")).thenReturn(Optional.of(mockTeam)); when(userRepository.save(any(User.class))).thenReturn(mockUser); From c141a15215e4cb767ffe42ccf9dc27225acd2847 Mon Sep 17 00:00:00 2001 From: Ludy Date: Wed, 20 Aug 2025 16:38:21 +0200 Subject: [PATCH 20/41] refactor(build): centralize security disable condition in shared Gradle property (#4209) # Description of Changes - Introduced `ext.isSecurityDisabled` closure in root `build.gradle` to consolidate logic for determining if security features should be disabled. - Removed duplicated conditional checks from `sourceSets` configurations in both root and `app/core` `build.gradle` files. - Updated dependency inclusion for `:proprietary` module to use the new `isSecurityDisabled()` method for clarity and maintainability. - Simplified build logic by reducing repeated environment and property checks. This change improves maintainability by ensuring that the security disable condition is defined in one place, reducing the risk of inconsistencies across modules. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- app/core/build.gradle | 5 ----- build.gradle | 43 ++++++++----------------------------------- 2 files changed, 8 insertions(+), 40 deletions(-) diff --git a/app/core/build.gradle b/app/core/build.gradle index c9905a308..409e1711d 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -114,11 +114,6 @@ sourceSets { } test { java { - if (System.getenv('DOCKER_ENABLE_SECURITY') == 'false' || System.getenv('DISABLE_ADDITIONAL_FEATURES') == 'true' - || (project.hasProperty('DISABLE_ADDITIONAL_FEATURES') - && System.getProperty('DISABLE_ADDITIONAL_FEATURES') == 'true')) { - exclude 'stirling/software/proprietary/security/**' - } if (System.getenv('STIRLING_PDF_DESKTOP_UI') == 'false') { exclude 'stirling/software/SPDF/UI/impl/**' } diff --git a/build.gradle b/build.gradle index 1cd58b00e..4059d48fe 100644 --- a/build.gradle +++ b/build.gradle @@ -34,6 +34,13 @@ ext { tempJrePath = null } +ext.isSecurityDisabled = { -> + System.getenv('DOCKER_ENABLE_SECURITY') == 'false' || + System.getenv('DISABLE_ADDITIONAL_FEATURES') == 'true' || + (project.hasProperty('DISABLE_ADDITIONAL_FEATURES') && + System.getProperty('DISABLE_ADDITIONAL_FEATURES') == 'true') +} + jar { enabled = false manifest { @@ -222,37 +229,6 @@ licenseReport { outputDir = project.layout.buildDirectory.dir("reports/dependency-license").get().asFile.path } -sourceSets { - main { - java { - if (System.getenv('DOCKER_ENABLE_SECURITY') == 'false' || System.getenv('DISABLE_ADDITIONAL_FEATURES') == 'true' - || (project.hasProperty('DISABLE_ADDITIONAL_FEATURES') - && System.getProperty('DISABLE_ADDITIONAL_FEATURES') == 'true')) { - exclude 'stirling/software/proprietary/security/**' - } - - if (System.getenv('STIRLING_PDF_DESKTOP_UI') == 'false') { - exclude 'stirling/software/SPDF/UI/impl/**' - } - - } - } - - test { - java { - if (System.getenv('DOCKER_ENABLE_SECURITY') == 'false' || System.getenv('DISABLE_ADDITIONAL_FEATURES') == 'true' - || (project.hasProperty('DISABLE_ADDITIONAL_FEATURES') - && System.getProperty('DISABLE_ADDITIONAL_FEATURES') == 'true')) { - exclude 'stirling/software/proprietary/security/**' - } - - if (System.getenv('STIRLING_PDF_DESKTOP_UI') == 'false') { - exclude 'stirling/software/SPDF/UI/impl/**' - } - } - } -} - // Configure the forked spring boot run task to properly delegate to the stirling-pdf module tasks.named('forkedSpringBootRun') { dependsOn ':stirling-pdf:bootRun' @@ -575,9 +551,7 @@ swaggerhubUpload { dependencies { implementation project(':stirling-pdf') implementation project(':common') - if (System.getenv('DISABLE_ADDITIONAL_FEATURES') != 'true' - || (project.hasProperty('DISABLE_ADDITIONAL_FEATURES') - && System.getProperty('DISABLE_ADDITIONAL_FEATURES') != 'true')) { + if (rootProject.ext.isSecurityDisabled()) { implementation project(':proprietary') } @@ -592,7 +566,6 @@ tasks.named("test") { useJUnitPlatform() } - // Make sure all relevant processes depend on writeVersion processResources.dependsOn(writeVersion) From 409cada93a40cfc606f3fab3aff2f91f394e96b5 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 21 Aug 2025 11:31:25 +0200 Subject: [PATCH 21/41] chore(ci): include `testing/**` in file change detection for `docker-compose-tests` workflow (#4206) # Description of Changes - Added `testing/**` to `.github/config/.files.yaml` so that changes in the `testing` directory will trigger the `docker-compose-tests` workflow in `build.yml`. - Updated Python dependencies in `.github/scripts/requirements_pre_commit.txt` and `testing/cucumber/requirements.txt` to newer versions, including `behave`, `pypdf`, `reportlab`, and others. - Introduced new dependencies like `colorama`, `cucumber-expressions`, `cucumber-tag-expressions`, and `tomli` in the testing requirements to support enhanced test execution. - Ensured hash integrity for all dependency updates. This change was made to ensure that modifications in the testing suite automatically trigger relevant CI jobs and that testing dependencies remain up-to-date for compatibility and stability. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .github/config/.files.yaml | 1 + .github/scripts/requirements_pre_commit.txt | 28 ++- testing/cucumber/requirements.txt | 249 +++++++++++--------- 3 files changed, 158 insertions(+), 120 deletions(-) diff --git a/.github/config/.files.yaml b/.github/config/.files.yaml index a5d8410f3..eb1097266 100644 --- a/.github/config/.files.yaml +++ b/.github/config/.files.yaml @@ -29,3 +29,4 @@ project: &project - settings.gradle - frontend/** - docker/** + - testing/** diff --git a/.github/scripts/requirements_pre_commit.txt b/.github/scripts/requirements_pre_commit.txt index 4e2d2c2b6..9a3e4c223 100644 --- a/.github/scripts/requirements_pre_commit.txt +++ b/.github/scripts/requirements_pre_commit.txt @@ -8,17 +8,17 @@ cfgv==3.4.0 \ --hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \ --hash=sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560 # via pre-commit -distlib==0.3.9 \ - --hash=sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87 \ - --hash=sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403 +distlib==0.4.0 \ + --hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \ + --hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d # via virtualenv filelock==3.18.0 \ --hash=sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2 \ --hash=sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de # via virtualenv -identify==2.6.12 \ - --hash=sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2 \ - --hash=sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6 +identify==2.6.13 \ + --hash=sha256:60381139b3ae39447482ecc406944190f690d4a2997f2584062089848361b33b \ + --hash=sha256:da8d6c828e773620e13bfa86ea601c5a5310ba4bcd65edf378198b56a1f9fb32 # via pre-commit nodeenv==1.9.1 \ --hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \ @@ -28,9 +28,9 @@ platformdirs==4.3.8 \ --hash=sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc \ --hash=sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4 # via virtualenv -pre-commit==4.2.0 \ - --hash=sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146 \ - --hash=sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd +pre-commit==4.3.0 \ + --hash=sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8 \ + --hash=sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16 # via -r .github\scripts\requirements_pre_commit.in pyyaml==6.0.2 \ --hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \ @@ -87,7 +87,11 @@ pyyaml==6.0.2 \ --hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \ --hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4 # via pre-commit -virtualenv==20.31.2 \ - --hash=sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11 \ - --hash=sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af +typing-extensions==4.14.1 \ + --hash=sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36 \ + --hash=sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76 + # via virtualenv +virtualenv==20.34.0 \ + --hash=sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026 \ + --hash=sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a # via pre-commit diff --git a/testing/cucumber/requirements.txt b/testing/cucumber/requirements.txt index 534b337a9..8f8cf8ad3 100644 --- a/testing/cucumber/requirements.txt +++ b/testing/cucumber/requirements.txt @@ -4,110 +4,109 @@ # # pip-compile --generate-hashes --output-file='testing\cucumber\requirements.txt' --strip-extras 'testing\cucumber\requirements.in' # -behave==1.2.6 \ - --hash=sha256:b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86 \ - --hash=sha256:ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c +behave==1.3.1 \ + --hash=sha256:2a1f3a2490242132c4daf0732d9b65c99be6fef1f787f97fd028ea5a402025ff \ + --hash=sha256:71b2dc00664de83c3aad61c91e5b3051b7b860aa2053e24db4742edecb800d21 # via -r testing\cucumber\requirements.in -certifi==2025.6.15 \ - --hash=sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057 \ - --hash=sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b +certifi==2025.8.3 \ + --hash=sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407 \ + --hash=sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5 # via requests -charset-normalizer==3.4.2 \ - --hash=sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4 \ - --hash=sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45 \ - --hash=sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7 \ - --hash=sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0 \ - --hash=sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7 \ - --hash=sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d \ - --hash=sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d \ - --hash=sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0 \ - --hash=sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184 \ - --hash=sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db \ - --hash=sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b \ - --hash=sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64 \ - --hash=sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b \ - --hash=sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8 \ - --hash=sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff \ - --hash=sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344 \ - --hash=sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58 \ - --hash=sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e \ - --hash=sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471 \ - --hash=sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148 \ - --hash=sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a \ - --hash=sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836 \ - --hash=sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e \ - --hash=sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63 \ - --hash=sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c \ - --hash=sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1 \ - --hash=sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01 \ - --hash=sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366 \ - --hash=sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58 \ - --hash=sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5 \ - --hash=sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c \ - --hash=sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2 \ - --hash=sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a \ - --hash=sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597 \ - --hash=sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b \ - --hash=sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5 \ - --hash=sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb \ - --hash=sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f \ - --hash=sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0 \ - --hash=sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941 \ - --hash=sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0 \ - --hash=sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86 \ - --hash=sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7 \ - --hash=sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7 \ - --hash=sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455 \ - --hash=sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6 \ - --hash=sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4 \ - --hash=sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0 \ - --hash=sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3 \ - --hash=sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1 \ - --hash=sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6 \ - --hash=sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981 \ - --hash=sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c \ - --hash=sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980 \ - --hash=sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645 \ - --hash=sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7 \ - --hash=sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12 \ - --hash=sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa \ - --hash=sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd \ - --hash=sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef \ - --hash=sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f \ - --hash=sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2 \ - --hash=sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d \ - --hash=sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5 \ - --hash=sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02 \ - --hash=sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3 \ - --hash=sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd \ - --hash=sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e \ - --hash=sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214 \ - --hash=sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd \ - --hash=sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a \ - --hash=sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c \ - --hash=sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681 \ - --hash=sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba \ - --hash=sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f \ - --hash=sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a \ - --hash=sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28 \ - --hash=sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691 \ - --hash=sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82 \ - --hash=sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a \ - --hash=sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027 \ - --hash=sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7 \ - --hash=sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518 \ - --hash=sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf \ - --hash=sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b \ - --hash=sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9 \ - --hash=sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544 \ - --hash=sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da \ - --hash=sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509 \ - --hash=sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f \ - --hash=sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a \ - --hash=sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f +charset-normalizer==3.4.3 \ + --hash=sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91 \ + --hash=sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0 \ + --hash=sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154 \ + --hash=sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601 \ + --hash=sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884 \ + --hash=sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07 \ + --hash=sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c \ + --hash=sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64 \ + --hash=sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe \ + --hash=sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f \ + --hash=sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432 \ + --hash=sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc \ + --hash=sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa \ + --hash=sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9 \ + --hash=sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae \ + --hash=sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19 \ + --hash=sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d \ + --hash=sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e \ + --hash=sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4 \ + --hash=sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7 \ + --hash=sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312 \ + --hash=sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92 \ + --hash=sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31 \ + --hash=sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c \ + --hash=sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f \ + --hash=sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99 \ + --hash=sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b \ + --hash=sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15 \ + --hash=sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392 \ + --hash=sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f \ + --hash=sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8 \ + --hash=sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491 \ + --hash=sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0 \ + --hash=sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc \ + --hash=sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0 \ + --hash=sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f \ + --hash=sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a \ + --hash=sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40 \ + --hash=sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927 \ + --hash=sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849 \ + --hash=sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce \ + --hash=sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14 \ + --hash=sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05 \ + --hash=sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c \ + --hash=sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c \ + --hash=sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a \ + --hash=sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc \ + --hash=sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34 \ + --hash=sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9 \ + --hash=sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096 \ + --hash=sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14 \ + --hash=sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30 \ + --hash=sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b \ + --hash=sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b \ + --hash=sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942 \ + --hash=sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db \ + --hash=sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5 \ + --hash=sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b \ + --hash=sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce \ + --hash=sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669 \ + --hash=sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0 \ + --hash=sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018 \ + --hash=sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93 \ + --hash=sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe \ + --hash=sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049 \ + --hash=sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a \ + --hash=sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef \ + --hash=sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2 \ + --hash=sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca \ + --hash=sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16 \ + --hash=sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f \ + --hash=sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb \ + --hash=sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1 \ + --hash=sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557 \ + --hash=sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37 \ + --hash=sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7 \ + --hash=sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72 \ + --hash=sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c \ + --hash=sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9 # via # reportlab # requests +colorama==0.4.6 \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via behave +cucumber-expressions==18.0.1 \ + --hash=sha256:86230d503cdda7ef35a1f2072a882d7d57c740aa4c163c82b07f039b6bc60c42 \ + --hash=sha256:86ce41bf28ee520408416f38022e5a083d815edf04a0bd1dae46d474ca597c60 + # via behave +cucumber-tag-expressions==6.2.0 \ + --hash=sha256:b60aa2cdbf9ac43e28d9b0e4fd49edf9f09d5d941257d2912f5228f9d166c023 \ + --hash=sha256:f94404b656831c56a3815da5305ac097003884d2ae64fa51f5f4fad82d97e583 + # via behave idna==3.10 \ --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 @@ -118,9 +117,9 @@ parse==1.20.2 \ # via # behave # parse-type -parse-type==0.6.4 \ - --hash=sha256:5e1ec10440b000c3f818006033372939e693a9ec0176f446d9303e4db88489a6 \ - --hash=sha256:83d41144a82d6b8541127bf212dd76c7f01baff680b498ce8a4d052a7a5bce4c +parse-type==0.6.6 \ + --hash=sha256:3ca79bbe71e170dfccc8ec6c341edfd1c2a0fc1e5cfd18330f93af938de2348c \ + --hash=sha256:513a3784104839770d690e04339a8b4d33439fcd5dd99f2e4580f9fc1097bfb2 # via behave pillow==11.3.0 \ --hash=sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2 \ @@ -273,13 +272,13 @@ pycryptodome==3.23.0 \ --hash=sha256:e3f2d0aaf8080bda0587d58fc9fe4766e012441e2eed4269a77de6aea981c8be \ --hash=sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7 # via -r testing\cucumber\requirements.in -pypdf==5.7.0 \ - --hash=sha256:203379453439f5b68b7a1cd43cdf4c5f7a02b84810cefa7f93a47b350aaaba48 \ - --hash=sha256:68c92f2e1aae878bab1150e74447f31ab3848b1c0a6f8becae9f0b1904460b6f +pypdf==6.0.0 \ + --hash=sha256:282a99d2cc94a84a3a3159f0d9358c0af53f85b4d28d76ea38b96e9e5ac2a08d \ + --hash=sha256:56ea60100ce9f11fc3eec4f359da15e9aec3821b036c1f06d2b660d35683abb8 # via -r testing\cucumber\requirements.in -reportlab==4.4.2 \ - --hash=sha256:58e11be387457928707c12153b7e41e52533a5da3f587b15ba8f8fd0805c6ee2 \ - --hash=sha256:fc6283048ddd0781a9db1d671715990e6aa059c8d40ec9baf34294c4bd583a36 +reportlab==4.4.3 \ + --hash=sha256:073b0975dab69536acd3251858e6b0524ed3e087e71f1d0d1895acb50acf9c7b \ + --hash=sha256:df905dc5ec5ddaae91fc9cb3371af863311271d555236410954961c5ee6ee1b5 # via -r testing\cucumber\requirements.in requests==2.32.4 \ --hash=sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c \ @@ -291,6 +290,40 @@ six==1.17.0 \ # via # behave # parse-type +tomli==2.2.1 \ + --hash=sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6 \ + --hash=sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd \ + --hash=sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c \ + --hash=sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b \ + --hash=sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8 \ + --hash=sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6 \ + --hash=sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77 \ + --hash=sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff \ + --hash=sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea \ + --hash=sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192 \ + --hash=sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249 \ + --hash=sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee \ + --hash=sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4 \ + --hash=sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98 \ + --hash=sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8 \ + --hash=sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4 \ + --hash=sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281 \ + --hash=sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744 \ + --hash=sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69 \ + --hash=sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13 \ + --hash=sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140 \ + --hash=sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e \ + --hash=sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e \ + --hash=sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc \ + --hash=sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff \ + --hash=sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec \ + --hash=sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2 \ + --hash=sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222 \ + --hash=sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106 \ + --hash=sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272 \ + --hash=sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a \ + --hash=sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7 + # via behave typing-extensions==4.14.1 \ --hash=sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36 \ --hash=sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76 From 1d89917e885e0d8d5eca9f81a55badb46fa73216 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 Aug 2025 21:03:37 +0100 Subject: [PATCH 22/41] build(deps): bump org.springdoc:springdoc-openapi-starter-webmvc-ui from 2.8.9 to 2.8.11 (#4273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [org.springdoc:springdoc-openapi-starter-webmvc-ui](https://github.com/springdoc/springdoc-openapi) from 2.8.9 to 2.8.11.
Release notes

Sourced from org.springdoc:springdoc-openapi-starter-webmvc-ui's releases.

springdoc-openapi v2.8.11 released!

Added

  • #3065 - javadoc and overall performance optimization

Changed

  • Upgrade spring-boot to v3.5.5

Fixed

  • #3064 -ClassNotFoundException: kotlin.reflect.full.KClasses

New Contributors

Full Changelog: https://github.com/springdoc/springdoc-openapi/compare/v2.8.10...v2.8.11

springdoc-openapi v2.8.10 released!

What's Changed

Added

  • #3046 - Feature Request: Support @​jakarta.annotation.Nonnull.
  • #3042 - Support externalDocs configure on SpecPropertiesCustomizer
  • #3057 - Refactor webhook discovery and scanning mechanism

Changed

  • Upgrade spring-boot to v3.5.4
  • Upgrade swagger-ui to v5.27.1
  • Upgrade swagger-core to 2.2.36

Fixed

  • #3050 - @​RequestPart JSON parameters missing Content-Type in generated curl commands, causing 415 errors.
  • #2978 - Parameter is no longer optional after upgrade to 2.8.8
  • #3022 - NullPointerException thrown in SchemaUtils.
  • #3026 - Fix unexpected merging of media types
  • #3036 - Fixed "desciption"
  • #3039 - Fix: Property resolution for extensions within @​OpenAPIDefinition Info object
  • #3051 - Fixes so that a RequestPart with a Map is added to the RequestBody
  • #3060 - Use adaptFromForwardedHeaders instead of deprecated fromHttpRequest

... (truncated)

Changelog

Sourced from org.springdoc:springdoc-openapi-starter-webmvc-ui's changelog.

[2.8.11] - 2025-08-23

Added

  • #3065 - javadoc and overall performance optimization

Changed

  • Upgrade spring-boot to v3.5.5

Fixed

  • #3064 -ClassNotFoundException: kotlin.reflect.full.KClasses

[2.8.10] - 2025-08-20

Added

  • #3046 - Feature Request: Support @​jakarta.annotation.Nonnull.
  • #3042 - Support externalDocs configure on SpecPropertiesCustomizer
  • #3057 - Refactor webhook discovery and scanning mechanism

Changed

  • Upgrade spring-boot to v3.5.4
  • Upgrade swagger-ui to v5.27.1
  • Upgrade swagger-core to 2.2.36

Fixed

  • #3050 - @​RequestPart JSON parameters missing Content-Type in generated curl commands, causing 415 errors.
  • #2978 - Parameter is no longer optional after upgrade to 2.8.8
  • #3022 - NullPointerException thrown in SchemaUtils.
  • #3026 - Fix unexpected merging of media types
  • #3036 - Fixed "desciption"
  • #3039 - Fix: Property resolution for extensions within @​OpenAPIDefinition Info object
  • #3051 - Fixes so that a RequestPart with a Map is added to the RequestBody
  • #3060 - Use adaptFromForwardedHeaders instead of deprecated fromHttpRequest
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.springdoc:springdoc-openapi-starter-webmvc-ui&package-manager=gradle&previous-version=2.8.9&new-version=2.8.11)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/common/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/common/build.gradle b/app/common/build.gradle index 39dab8ded..6b2d67b32 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -39,7 +39,7 @@ dependencies { api "org.apache.pdfbox:pdfbox:$pdfboxVersion" api 'jakarta.servlet:jakarta.servlet-api:6.1.0' api 'org.snakeyaml:snakeyaml-engine:2.10' - api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9" + api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.11" api 'jakarta.mail:jakarta.mail-api:2.1.3' runtimeOnly 'org.eclipse.angus:angus-mail:2.0.4' } From ae53492751de7d40a798d20faa0c7019f5cf2d6d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 Aug 2025 21:03:53 +0100 Subject: [PATCH 23/41] build(deps): bump org.springframework.boot from 3.5.4 to 3.5.5 (#4272) Bumps [org.springframework.boot](https://github.com/spring-projects/spring-boot) from 3.5.4 to 3.5.5.
Release notes

Sourced from org.springframework.boot's releases.

v3.5.5

:lady_beetle: Bug Fixes

  • Hazelcast health indicator reports the wrong status when Hazelcast has shut down due to an out-of-memory error #46909
  • Performance critical tracing code has high overhead due to the use of the Stream API #46844
  • SpringLiquibaseCustomizer is exposed outside its defined visibility scope #46758
  • Race condition in OutputCapture can result in stale data #46721
  • Auto-configured WebClient no longer uses context's ReactorResourceFactory #46673
  • Default value not detected for a field annoted with @Name #46666
  • Missing metadata when using @Name with a constructor-bound property #46663
  • Missing property for Spring Authorization Server's PAR endpoint #46641
  • Property name is incorrect when reporting a mis-configured OAuth 2 Resource Server JWT public key location #46636
  • Memory not freed on context restart in JpaMetamodel#CACHE with spring.main.lazy-initialization=true #46634
  • Auto-configured MockMvc ignores @FilterRegistration annotation #46605
  • Failure to discover default value for a primitive should not lead to document its default value #46561

:notebook_with_decorative_cover: Documentation

  • Kotlin samples for configuration metadata are in the wrong package #46857
  • Observability examples in the reference guide are missing the Kotlin version #46798
  • Align method descriptions for SslOptions getCiphers and getEnabledProtocols with @returns #46769
  • Tracing samples in the reference guide are missing the Kotlin version #46767
  • Improve Virtual Threads section to mention the changes in Java 24 #46610
  • spring.test.webtestclient.timeout is not documented #46588
  • spring-boot-test-autoconfigure should use the configuration properties annotation processor like other modules #46585
  • Adapt deprecation level for management.health.influxdb.enabled #46580
  • spring.test.mockmvc properties are not documented #46578

:hammer: Dependency Upgrades

  • Upgrade to Angus Mail 2.0.4 #46725
  • Upgrade to AssertJ 3.27.4 #46726
  • Upgrade to Byte Buddy 1.17.7 #46883
  • Upgrade to Couchbase Client 3.8.3 #46794
  • Upgrade to Elasticsearch Client 8.18.5 #46830
  • Upgrade to Hibernate 6.6.26.Final #46884
  • Upgrade to Hibernate Validator 8.0.3.Final #46728
  • Upgrade to HikariCP 6.3.2 #46729
  • Upgrade to Jersey 3.1.11 #46730
  • Upgrade to Jetty 12.0.25 #46831
  • Upgrade to Jetty Reactive HTTPClient 4.0.11 #46885
  • Upgrade to jOOQ 3.19.25 #46808
  • Upgrade to MariaDB 3.5.5 #46779
  • Upgrade to Maven Javadoc Plugin 3.11.3 #46886
  • Upgrade to Micrometer 1.15.3 #46701
  • Upgrade to Micrometer Tracing 1.5.3 #46702
  • Upgrade to MySQL 9.4.0 #46732
  • Upgrade to Netty 4.1.124.Final #46832
  • Upgrade to Pulsar 4.0.6 #46733
  • Upgrade to Reactor Bom 2024.0.9 #46703

... (truncated)

Commits
  • 3537d25 Release v3.5.5
  • a22e28e Merge branch '3.4.x' into 3.5.x
  • 4cb8c8a Next development version (v3.4.10-SNAPSHOT)
  • 9d205e2 Merge branch '3.4.x' into 3.5.x
  • 47b0632 Merge pull request #46927 from izeye
  • 8b71458 Adapt checkstyle rules for 3.4.x
  • fb99bad Remove redundant suppressions from Checkstyle configuration
  • 8af836a Upgrade to Spring RESTDocs 3.0.5
  • ae6c6a5 Merge branch '3.4.x' into 3.5.x
  • b6bae9f Upgrade to Spring RESTDocs 3.0.5
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.springframework.boot&package-manager=gradle&previous-version=3.5.4&new-version=3.5.5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 4059d48fe..9c502577a 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ plugins { id "java" id "jacoco" id "io.spring.dependency-management" version "1.1.7" - id "org.springframework.boot" version "3.5.4" + id "org.springframework.boot" version "3.5.5" id "org.springdoc.openapi-gradle-plugin" version "1.9.0" id "io.swagger.swaggerhub" version "1.3.2" id "edu.sc.seis.launch4j" version "4.0.0" From 0d63bc4a4110396d59becb8231c165d27f70f53a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 Aug 2025 21:04:24 +0100 Subject: [PATCH 24/41] build(deps): bump github/codeql-action from 3.29.10 to 3.29.11 (#4271) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.29.10 to 3.29.11.
Release notes

Sourced from github/codeql-action's releases.

v3.29.11

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

3.29.11 - 21 Aug 2025

  • Update default CodeQL bundle version to 2.22.4. #3044

See the full CHANGELOG.md for more information.

Changelog

Sourced from github/codeql-action's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

3.29.11 - 21 Aug 2025

  • Update default CodeQL bundle version to 2.22.4. #3044

3.29.10 - 18 Aug 2025

No user facing changes.

3.29.9 - 12 Aug 2025

No user facing changes.

3.29.8 - 08 Aug 2025

  • Fix an issue where the Action would autodetect unsupported languages such as HTML. #3015

3.29.7 - 07 Aug 2025

This release rolls back 3.29.6 to address issues with language autodetection. It is identical to 3.29.5.

3.29.6 - 07 Aug 2025

  • The cleanup-level input to the analyze Action is now deprecated. The CodeQL Action has written a limited amount of intermediate results to the database since version 2.2.5, and now automatically manages cleanup. #2999
  • Update default CodeQL bundle version to 2.22.3. #3000

3.29.5 - 29 Jul 2025

  • Update default CodeQL bundle version to 2.22.2. #2986

3.29.4 - 23 Jul 2025

No user facing changes.

3.29.3 - 21 Jul 2025

No user facing changes.

3.29.2 - 30 Jun 2025

  • Experimental: When the quality-queries input for the init action is provided with an argument, separate .quality.sarif files are produced and uploaded for each language with the results of the specified queries. Do not use this in production as it is part of an internal experiment and subject to change at any time. #2935

3.29.1 - 27 Jun 2025

... (truncated)

Commits
  • 3c3833e Merge pull request #3052 from github/update-v3.29.11-14148a433
  • 8c4bfbd Update changelog for v3.29.11
  • 14148a4 Merge pull request #3044 from github/update-bundle/codeql-bundle-v2.22.4
  • 71b2cb3 Add changelog note
  • 2bf7825 Update default bundle to codeql-bundle-v2.22.4
  • db69a51 Merge pull request #3049 from github/update-supported-enterprise-server-versions
  • a68d47b Merge pull request #3050 from github/henrymercer/init-not-called-config-error
  • e496ff9 Make "init not called" a configuration error
  • fd2ea72 Update supported GitHub Enterprise Server versions
  • 6dee5bc Merge pull request #3045 from github/dependabot/npm_and_yarn/npm-5b4171dd16
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action&package-manager=github_actions&previous-version=3.29.10&new-version=3.29.11)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/scorecards.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 53ad28c84..8dc3e94f3 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -74,6 +74,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.5 + uses: github/codeql-action/upload-sarif@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3.29.5 with: sarif_file: results.sarif From 44dbeebd40352a3d306173a00f3e3adbfd8a22e5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 Aug 2025 21:05:37 +0100 Subject: [PATCH 25/41] build(deps): bump org.springframework.boot:spring-boot-dependencies from 3.5.4 to 3.5.5 (#4268) Bumps [org.springframework.boot:spring-boot-dependencies](https://github.com/spring-projects/spring-boot) from 3.5.4 to 3.5.5.
Release notes

Sourced from org.springframework.boot:spring-boot-dependencies's releases.

v3.5.5

:lady_beetle: Bug Fixes

  • Hazelcast health indicator reports the wrong status when Hazelcast has shut down due to an out-of-memory error #46909
  • Performance critical tracing code has high overhead due to the use of the Stream API #46844
  • SpringLiquibaseCustomizer is exposed outside its defined visibility scope #46758
  • Race condition in OutputCapture can result in stale data #46721
  • Auto-configured WebClient no longer uses context's ReactorResourceFactory #46673
  • Default value not detected for a field annoted with @Name #46666
  • Missing metadata when using @Name with a constructor-bound property #46663
  • Missing property for Spring Authorization Server's PAR endpoint #46641
  • Property name is incorrect when reporting a mis-configured OAuth 2 Resource Server JWT public key location #46636
  • Memory not freed on context restart in JpaMetamodel#CACHE with spring.main.lazy-initialization=true #46634
  • Auto-configured MockMvc ignores @FilterRegistration annotation #46605
  • Failure to discover default value for a primitive should not lead to document its default value #46561

:notebook_with_decorative_cover: Documentation

  • Kotlin samples for configuration metadata are in the wrong package #46857
  • Observability examples in the reference guide are missing the Kotlin version #46798
  • Align method descriptions for SslOptions getCiphers and getEnabledProtocols with @returns #46769
  • Tracing samples in the reference guide are missing the Kotlin version #46767
  • Improve Virtual Threads section to mention the changes in Java 24 #46610
  • spring.test.webtestclient.timeout is not documented #46588
  • spring-boot-test-autoconfigure should use the configuration properties annotation processor like other modules #46585
  • Adapt deprecation level for management.health.influxdb.enabled #46580
  • spring.test.mockmvc properties are not documented #46578

:hammer: Dependency Upgrades

  • Upgrade to Angus Mail 2.0.4 #46725
  • Upgrade to AssertJ 3.27.4 #46726
  • Upgrade to Byte Buddy 1.17.7 #46883
  • Upgrade to Couchbase Client 3.8.3 #46794
  • Upgrade to Elasticsearch Client 8.18.5 #46830
  • Upgrade to Hibernate 6.6.26.Final #46884
  • Upgrade to Hibernate Validator 8.0.3.Final #46728
  • Upgrade to HikariCP 6.3.2 #46729
  • Upgrade to Jersey 3.1.11 #46730
  • Upgrade to Jetty 12.0.25 #46831
  • Upgrade to Jetty Reactive HTTPClient 4.0.11 #46885
  • Upgrade to jOOQ 3.19.25 #46808
  • Upgrade to MariaDB 3.5.5 #46779
  • Upgrade to Maven Javadoc Plugin 3.11.3 #46886
  • Upgrade to Micrometer 1.15.3 #46701
  • Upgrade to Micrometer Tracing 1.5.3 #46702
  • Upgrade to MySQL 9.4.0 #46732
  • Upgrade to Netty 4.1.124.Final #46832
  • Upgrade to Pulsar 4.0.6 #46733
  • Upgrade to Reactor Bom 2024.0.9 #46703

... (truncated)

Commits
  • 3537d25 Release v3.5.5
  • a22e28e Merge branch '3.4.x' into 3.5.x
  • 4cb8c8a Next development version (v3.4.10-SNAPSHOT)
  • 9d205e2 Merge branch '3.4.x' into 3.5.x
  • 47b0632 Merge pull request #46927 from izeye
  • 8b71458 Adapt checkstyle rules for 3.4.x
  • fb99bad Remove redundant suppressions from Checkstyle configuration
  • 8af836a Upgrade to Spring RESTDocs 3.0.5
  • ae6c6a5 Merge branch '3.4.x' into 3.5.x
  • b6bae9f Upgrade to Spring RESTDocs 3.0.5
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.springframework.boot:spring-boot-dependencies&package-manager=gradle&previous-version=3.5.4&new-version=3.5.5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 9c502577a..5c6893cdf 100644 --- a/build.gradle +++ b/build.gradle @@ -21,7 +21,7 @@ import java.nio.file.Files import java.time.Year ext { - springBootVersion = "3.5.4" + springBootVersion = "3.5.5" pdfboxVersion = "3.0.5" imageioVersion = "3.12.0" lombokVersion = "1.18.38" From 73d419cb39c0df9f2965ea68d094dce84c298528 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 Aug 2025 21:16:57 +0100 Subject: [PATCH 26/41] build(deps): bump springSecuritySamlVersion from 6.5.2 to 6.5.3 (#4227) Bumps `springSecuritySamlVersion` from 6.5.2 to 6.5.3. Updates `org.springframework.security:spring-security-core` from 6.5.2 to 6.5.3
Release notes

Sourced from org.springframework.security:spring-security-core's releases.

6.5.3

:star: New Features

  • Add META-INF/LICENSE.txt to published jars #17639
  • Update Angular documentation links in csrf.adoc #17653
  • Update Shibboleth Repository URL #17637
  • Use 2004-present Copyright #17634

:beetle: Bug Fixes

  • Add Missing Navigation in Preparing for 7.0 Guide #17731
  • DPoP authentication throws JwtDecoderFactory ClassNotFoundException #17249
  • OpenSamlAssertingPartyDetails Should Be Serializable #17727
  • Use final values in equals and hashCode #17621

:hammer: Dependency Upgrades

  • Bump com.webauthn4j:webauthn4j-core from 0.29.4.RELEASE to 0.29.5.RELEASE #17739
  • Bump com.webauthn4j:webauthn4j-core from 0.29.4.RELEASE to 0.29.5.RELEASE #17690
  • Bump com.webauthn4j:webauthn4j-core from 0.29.4.RELEASE to 0.29.5.RELEASE #17684
  • Bump com.webauthn4j:webauthn4j-core from 0.29.4.RELEASE to 0.29.5.RELEASE #17661
  • Bump io.micrometer:micrometer-observation from 1.14.8 to 1.14.9 #17615
  • Bump io.micrometer:micrometer-observation from 1.14.8 to 1.14.9 #17599
  • Bump io.micrometer:micrometer-observation from 1.14.9 to 1.14.10 #17737
  • Bump io.micrometer:micrometer-observation from 1.14.9 to 1.14.10 #17701
  • Bump io.mockk:mockk from 1.14.4 to 1.14.5 #17614
  • Bump io.spring.develocity.conventions from 0.0.23 to 0.0.24 #17647
  • Bump io.spring.gradle:spring-security-release-plugin from 1.0.10 to 1.0.11 #17733
  • Bump io.spring.gradle:spring-security-release-plugin from 1.0.10 to 1.0.11 #17711
  • Bump io.spring.gradle:spring-security-release-plugin from 1.0.6 to 1.0.10 #17612
  • Bump io.spring.gradle:spring-security-release-plugin from 1.0.6 to 1.0.10 #17598
  • Bump org-eclipse-jetty from 11.0.25 to 11.0.26 #17742
  • Bump org.apache.maven:maven-resolver-provider from 3.9.10 to 3.9.11 #17613
  • Bump org.apache.maven:maven-resolver-provider from 3.9.10 to 3.9.11 #17595
  • Bump org.assertj:assertj-core from 3.27.3 to 3.27.4 #17760
  • Bump org.assertj:assertj-core from 3.27.3 to 3.27.4 #17692
  • Bump org.assertj:assertj-core from 3.27.3 to 3.27.4 #17683
  • Bump org.assertj:assertj-core from 3.27.3 to 3.27.4 #17671
  • Bump org.gretty:gretty from 4.1.6 to 4.1.7 #17616
  • Bump org.gretty:gretty from 4.1.6 to 4.1.7 #17597
  • Bump org.hibernate.orm:hibernate-core from 6.6.20.Final to 6.6.23.Final #17646
  • Bump org.hibernate.orm:hibernate-core from 6.6.23.Final to 6.6.24.Final #17660
  • Bump org.hibernate.orm:hibernate-core from 6.6.23.Final to 6.6.25.Final #17694
  • Bump org.hibernate.orm:hibernate-core from 6.6.23.Final to 6.6.25.Final #17685
  • Bump org.jfrog.buildinfo:build-info-extractor-gradle from 4.34.1 to 4.34.2 #17650
  • Bump org.springframework.data:spring-data-bom from 2024.1.7 to 2024.1.8 #17645
  • Bump org.springframework.ldap:spring-ldap-core from 3.2.13 to 3.2.14 #17757
  • Bump org.springframework:spring-framework-bom from 6.2.8 to 6.2.9 #17651
  • Bump org.springframework:spring-framework-bom from 6.2.8 to 6.2.9 #17596
  • Bump org.springframework:spring-framework-bom from 6.2.9 to 6.2.10 #17735

... (truncated)

Commits
  • 44037c0 Release 6.5.3
  • 9909dc6 Merge branch '6.4.x' into 6.5.x
  • 525601e Fix version 6.4.9-SNAPSHOT
  • 15a4d0d Fix version=6.5.3-SNAPSHOT
  • 80b1a30 Merge branch '6.4.x' into 6.5.x
  • 644f780 Bump org.springframework.ldap:spring-ldap-core from 3.2.13 to 3.2.14
  • a26d6fc Bump org.springframework.data:spring-data-bom from 2024.1.8 to 2024.1.9
  • 74735a1 Bump org.hibernate.orm:hibernate-core from 6.6.23.Final to 6.6.26.Final
  • 82a16d7 Bump org.assertj:assertj-core from 3.27.3 to 3.27.4
  • c1869c1 Bump org.hibernate.orm:hibernate-core from 6.6.23.Final to 6.6.26.Final
  • Additional commits viewable in compare view

Updates `org.springframework.security:spring-security-saml2-service-provider` from 6.5.2 to 6.5.3
Release notes

Sourced from org.springframework.security:spring-security-saml2-service-provider's releases.

6.5.3

:star: New Features

  • Add META-INF/LICENSE.txt to published jars #17639
  • Update Angular documentation links in csrf.adoc #17653
  • Update Shibboleth Repository URL #17637
  • Use 2004-present Copyright #17634

:beetle: Bug Fixes

  • Add Missing Navigation in Preparing for 7.0 Guide #17731
  • DPoP authentication throws JwtDecoderFactory ClassNotFoundException #17249
  • OpenSamlAssertingPartyDetails Should Be Serializable #17727
  • Use final values in equals and hashCode #17621

:hammer: Dependency Upgrades

  • Bump com.webauthn4j:webauthn4j-core from 0.29.4.RELEASE to 0.29.5.RELEASE #17739
  • Bump com.webauthn4j:webauthn4j-core from 0.29.4.RELEASE to 0.29.5.RELEASE #17690
  • Bump com.webauthn4j:webauthn4j-core from 0.29.4.RELEASE to 0.29.5.RELEASE #17684
  • Bump com.webauthn4j:webauthn4j-core from 0.29.4.RELEASE to 0.29.5.RELEASE #17661
  • Bump io.micrometer:micrometer-observation from 1.14.8 to 1.14.9 #17615
  • Bump io.micrometer:micrometer-observation from 1.14.8 to 1.14.9 #17599
  • Bump io.micrometer:micrometer-observation from 1.14.9 to 1.14.10 #17737
  • Bump io.micrometer:micrometer-observation from 1.14.9 to 1.14.10 #17701
  • Bump io.mockk:mockk from 1.14.4 to 1.14.5 #17614
  • Bump io.spring.develocity.conventions from 0.0.23 to 0.0.24 #17647
  • Bump io.spring.gradle:spring-security-release-plugin from 1.0.10 to 1.0.11 #17733
  • Bump io.spring.gradle:spring-security-release-plugin from 1.0.10 to 1.0.11 #17711
  • Bump io.spring.gradle:spring-security-release-plugin from 1.0.6 to 1.0.10 #17612
  • Bump io.spring.gradle:spring-security-release-plugin from 1.0.6 to 1.0.10 #17598
  • Bump org-eclipse-jetty from 11.0.25 to 11.0.26 #17742
  • Bump org.apache.maven:maven-resolver-provider from 3.9.10 to 3.9.11 #17613
  • Bump org.apache.maven:maven-resolver-provider from 3.9.10 to 3.9.11 #17595
  • Bump org.assertj:assertj-core from 3.27.3 to 3.27.4 #17760
  • Bump org.assertj:assertj-core from 3.27.3 to 3.27.4 #17692
  • Bump org.assertj:assertj-core from 3.27.3 to 3.27.4 #17683
  • Bump org.assertj:assertj-core from 3.27.3 to 3.27.4 #17671
  • Bump org.gretty:gretty from 4.1.6 to 4.1.7 #17616
  • Bump org.gretty:gretty from 4.1.6 to 4.1.7 #17597
  • Bump org.hibernate.orm:hibernate-core from 6.6.20.Final to 6.6.23.Final #17646
  • Bump org.hibernate.orm:hibernate-core from 6.6.23.Final to 6.6.24.Final #17660
  • Bump org.hibernate.orm:hibernate-core from 6.6.23.Final to 6.6.25.Final #17694
  • Bump org.hibernate.orm:hibernate-core from 6.6.23.Final to 6.6.25.Final #17685
  • Bump org.jfrog.buildinfo:build-info-extractor-gradle from 4.34.1 to 4.34.2 #17650
  • Bump org.springframework.data:spring-data-bom from 2024.1.7 to 2024.1.8 #17645
  • Bump org.springframework.ldap:spring-ldap-core from 3.2.13 to 3.2.14 #17757
  • Bump org.springframework:spring-framework-bom from 6.2.8 to 6.2.9 #17651
  • Bump org.springframework:spring-framework-bom from 6.2.8 to 6.2.9 #17596
  • Bump org.springframework:spring-framework-bom from 6.2.9 to 6.2.10 #17735

... (truncated)

Commits
  • 44037c0 Release 6.5.3
  • 9909dc6 Merge branch '6.4.x' into 6.5.x
  • 525601e Fix version 6.4.9-SNAPSHOT
  • 15a4d0d Fix version=6.5.3-SNAPSHOT
  • 80b1a30 Merge branch '6.4.x' into 6.5.x
  • 644f780 Bump org.springframework.ldap:spring-ldap-core from 3.2.13 to 3.2.14
  • a26d6fc Bump org.springframework.data:spring-data-bom from 2024.1.8 to 2024.1.9
  • 74735a1 Bump org.hibernate.orm:hibernate-core from 6.6.23.Final to 6.6.26.Final
  • 82a16d7 Bump org.assertj:assertj-core from 3.27.3 to 3.27.4
  • c1869c1 Bump org.hibernate.orm:hibernate-core from 6.6.23.Final to 6.6.26.Final
  • Additional commits viewable in compare view

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 5c6893cdf..4016c2bc6 100644 --- a/build.gradle +++ b/build.gradle @@ -26,7 +26,7 @@ ext { imageioVersion = "3.12.0" lombokVersion = "1.18.38" bouncycastleVersion = "1.81" - springSecuritySamlVersion = "6.5.2" + springSecuritySamlVersion = "6.5.3" openSamlVersion = "4.3.2" commonmarkVersion = "0.25.1" googleJavaFormatVersion = "1.28.0" From 3f004dcad328a887084d1957c483e85d6aa26b48 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 Aug 2025 21:17:10 +0100 Subject: [PATCH 27/41] build(deps): bump io.swagger.core.v3:swagger-core-jakarta from 2.2.35 to 2.2.36 (#4226) Bumps io.swagger.core.v3:swagger-core-jakarta from 2.2.35 to 2.2.36. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=io.swagger.core.v3:swagger-core-jakarta&package-manager=gradle&previous-version=2.2.35&new-version=2.2.36)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/proprietary/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index 0254d63ed..2c9058729 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -49,7 +49,7 @@ dependencies { api 'org.springframework.boot:spring-boot-starter-mail' api 'org.springframework.boot:spring-boot-starter-cache' api 'com.github.ben-manes.caffeine:caffeine' - api 'io.swagger.core.v3:swagger-core-jakarta:2.2.35' + api 'io.swagger.core.v3:swagger-core-jakarta:2.2.36' implementation 'com.bucket4j:bucket4j_jdk17-core:8.14.0' // https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17 From 2baa258e1106e43538d1a4c8a713d7c39ee239b7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 Aug 2025 21:17:32 +0100 Subject: [PATCH 28/41] build(deps): bump io.micrometer:micrometer-core from 1.15.2 to 1.15.3 (#4190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [//]: # (dependabot-start) ⚠️ **Dependabot is rebasing this PR** ⚠️ Rebasing might not happen immediately, so don't worry if this takes some time. Note: if you make any changes to this PR yourself, they will take precedence over the rebase. --- [//]: # (dependabot-end) Bumps [io.micrometer:micrometer-core](https://github.com/micrometer-metrics/micrometer) from 1.15.2 to 1.15.3.
Release notes

Sourced from io.micrometer:micrometer-core's releases.

1.15.3

:lady_beetle: Bug Fixes

  • Catch IllegalArgumentException in VirtualThreadMetrics #6584
  • Handle ArrayIndexOutOfBoundsException from DoubleHistogram in TimeWindowPercentileHistogram.accumulate() defensively #6563
  • Sync OutputCapture from Spring Boot #6608

:hammer: Dependency Upgrades

  • Bump jersey3 from 3.1.10 to 3.1.11 #6607
  • Bump com.netflix.spectator:spectator-reg-atlas from 1.8.16 to 1.8.17 #6600
  • Bump io.netty:netty-bom from 4.1.122.Final to 4.1.123.Final #6537

:heart: Contributors

Thank you to all the contributors who worked on this release:

@​genuss and @​izeye

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=io.micrometer:micrometer-core&package-manager=gradle&previous-version=1.15.2&new-version=1.15.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/core/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/build.gradle b/app/core/build.gradle index 409e1711d..87c80b611 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -58,7 +58,7 @@ dependencies { implementation 'commons-io:commons-io:2.20.0' implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion" implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion" - implementation 'io.micrometer:micrometer-core:1.15.2' + implementation 'io.micrometer:micrometer-core:1.15.3' implementation 'com.google.zxing:core:3.5.3' implementation "org.commonmark:commonmark:$commonmarkVersion" // https://mvnrepository.com/artifact/org.commonmark/commonmark implementation "org.commonmark:commonmark-ext-gfm-tables:$commonmarkVersion" From 9779c75df4655b76bad01d2191fae08b8ed38ea6 Mon Sep 17 00:00:00 2001 From: Ludy Date: Sun, 24 Aug 2025 22:20:28 +0200 Subject: [PATCH 29/41] refactor(tests): move & expand TextFinder/RedactController tests; fix TextFinder empty search-term handling; update token filtering API (#4264) # Description of Changes - **What was changed** - Relocated and refactored unit tests: - `TextFinderTest` and `RedactControllerTest` moved under `app/core/src/test/...` to align with module structure. - Expanded test coverage: whole-word vs. partial matches, complex regexes (emails, SSNs, IPs, currency), international/accented characters, multi-page documents, malformed PDFs, operator preservation, color decoding, and performance assertions. - **API adjustments in redaction flow**: - `createTokensWithoutTargetText(...)` now accepts the `PDDocument` alongside `PDPage` to properly manage resources/streams. - Introduced/used `createPlaceholderWithFont(...)` to maintain text width with explicit font context. - **Bug fix in `TextFinder`**: - Early-return when the (trimmed) search term is empty to prevent unnecessary processing and avoid false positives/errors. - Minor cleanup (removed redundant `super()` call) and improved guard logic around regex/whole-word wrapping. - **Why the change was made** - Improve reliability and determinism of PDF redaction and text finding by exercising real-world patterns and edge cases. - Ensure structural PDF operators (graphics/positioning) are preserved during token filtering. - Prevent crashes or misleading matches when users provide empty/whitespace-only search terms. - Align tests with the current project layout and increase maintainability. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../software/SPDF/pdf/TextFinder.java | 7 +- .../api/security/RedactControllerTest.java | 362 +++++++++++------- .../software/SPDF/pdf/TextFinderTest.java | 284 +++++++++----- 3 files changed, 419 insertions(+), 234 deletions(-) rename {stirling-pdf => app/core}/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerTest.java (78%) rename {stirling-pdf => app/core}/src/test/java/stirling/software/SPDF/pdf/TextFinderTest.java (69%) diff --git a/app/core/src/main/java/stirling/software/SPDF/pdf/TextFinder.java b/app/core/src/main/java/stirling/software/SPDF/pdf/TextFinder.java index 432fad101..c99a2ade7 100644 --- a/app/core/src/main/java/stirling/software/SPDF/pdf/TextFinder.java +++ b/app/core/src/main/java/stirling/software/SPDF/pdf/TextFinder.java @@ -27,7 +27,6 @@ public class TextFinder extends PDFTextStripper { public TextFinder(String searchTerm, boolean useRegex, boolean wholeWordSearch) throws IOException { - super(); this.searchTerm = searchTerm; this.useRegex = useRegex; this.wholeWordSearch = wholeWordSearch; @@ -68,11 +67,15 @@ public class TextFinder extends PDFTextStripper { } String processedSearchTerm = this.searchTerm.trim(); + if (processedSearchTerm.isEmpty()) { + super.endPage(page); + return; + } String regex = this.useRegex ? processedSearchTerm : "\\Q" + processedSearchTerm + "\\E"; if (this.wholeWordSearch) { if (processedSearchTerm.length() == 1 && Character.isDigit(processedSearchTerm.charAt(0))) { - regex = "(? redactionAreas, boolean convertToImage) throws Exception { + private void testManualRedaction(List redactionAreas, boolean convertToImage) + throws Exception { ManualRedactPdfRequest request = createManualRedactPdfRequest(); request.setRedactions(redactionAreas); request.setConvertPDFToImage(convertToImage); @@ -123,18 +128,16 @@ class RedactControllerTest { @BeforeEach void setUp() throws IOException { - mockPdfFile = new MockMultipartFile( - "fileInput", - "test.pdf", - "application/pdf", - createSimplePdfContent() - ); + mockPdfFile = + new MockMultipartFile( + "fileInput", "test.pdf", "application/pdf", createSimplePdfContent()); // Mock PDF document and related objects mockDocument = mock(PDDocument.class); mockPages = mock(PDPageTree.class); mockPage = mock(PDPage.class); - org.apache.pdfbox.pdmodel.PDDocumentCatalog mockCatalog = mock(org.apache.pdfbox.pdmodel.PDDocumentCatalog.class); + org.apache.pdfbox.pdmodel.PDDocumentCatalog mockCatalog = + mock(org.apache.pdfbox.pdmodel.PDDocumentCatalog.class); // Setup document structure properly when(pdfDocumentFactory.load(any(MockMultipartFile.class))).thenReturn(mockDocument); @@ -153,12 +156,14 @@ class RedactControllerTest { when(mockPage.getMediaBox()).thenReturn(pageRect); when(mockPage.getBBox()).thenReturn(pageRect); - InputStream mockInputStream = new ByteArrayInputStream("BT /F1 12 Tf 100 200 Td (test content) Tj ET".getBytes()); + InputStream mockInputStream = + new ByteArrayInputStream("BT /F1 12 Tf 100 200 Td (test content) Tj ET".getBytes()); when(mockPage.getContents()).thenReturn(mockInputStream); when(mockPage.hasContents()).thenReturn(true); - org.apache.pdfbox.cos.COSDocument mockCOSDocument = mock(org.apache.pdfbox.cos.COSDocument.class); + org.apache.pdfbox.cos.COSDocument mockCOSDocument = + mock(org.apache.pdfbox.cos.COSDocument.class); org.apache.pdfbox.cos.COSStream mockCOSStream = mock(org.apache.pdfbox.cos.COSStream.class); when(mockDocument.getDocument()).thenReturn(mockCOSDocument); when(mockCOSDocument.createCOSStream()).thenReturn(mockCOSStream); @@ -167,11 +172,14 @@ class RedactControllerTest { when(mockCOSStream.createOutputStream()).thenReturn(mockOutputStream); when(mockCOSStream.createOutputStream(any())).thenReturn(mockOutputStream); - doAnswer(invocation -> { - ByteArrayOutputStream baos = invocation.getArgument(0); - baos.write("Mock PDF Content".getBytes()); - return null; - }).when(mockDocument).save(any(ByteArrayOutputStream.class)); + doAnswer( + invocation -> { + ByteArrayOutputStream baos = invocation.getArgument(0); + baos.write("Mock PDF Content".getBytes()); + return null; + }) + .when(mockDocument) + .save(any(ByteArrayOutputStream.class)); doNothing().when(mockDocument).close(); // Initialize a real document for unit tests @@ -185,7 +193,8 @@ class RedactControllerTest { // Set up basic page resources PDResources resources = new PDResources(); - resources.put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + resources.put( + COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); realPage.setResources(resources); } @@ -222,7 +231,14 @@ class RedactControllerTest { @Test @DisplayName("Should redact multiple search terms") void redactMultipleSearchTerms() throws Exception { - testAutoRedaction("confidential\nsecret\nprivate\nclassified", false, true, "#FF0000", 2.0f, false, true); + testAutoRedaction( + "confidential\nsecret\nprivate\nclassified", + false, + true, + "#FF0000", + 2.0f, + false, + true); } @Test @@ -250,8 +266,12 @@ class RedactControllerTest { when(page.getBBox()).thenReturn(pageRect); when(page.hasContents()).thenReturn(true); - InputStream mockInputStream = new ByteArrayInputStream( - ("BT /F1 12 Tf 100 200 Td (page " + i + " content with confidential info) Tj ET").getBytes()); + InputStream mockInputStream = + new ByteArrayInputStream( + ("BT /F1 12 Tf 100 200 Td (page " + + i + + " content with confidential info) Tj ET") + .getBytes()); when(page.getContents()).thenReturn(mockInputStream); pageList.add(page); @@ -285,7 +305,8 @@ class RedactControllerTest { when(mockPages.get(0)).thenReturn(mockPage); - org.apache.pdfbox.pdmodel.PDDocumentInformation mockInfo = mock(org.apache.pdfbox.pdmodel.PDDocumentInformation.class); + org.apache.pdfbox.pdmodel.PDDocumentInformation mockInfo = + mock(org.apache.pdfbox.pdmodel.PDDocumentInformation.class); when(mockDocument.getDocumentInformation()).thenReturn(mockInfo); ResponseEntity response = redactController.redactPdf(request); @@ -311,23 +332,27 @@ class RedactControllerTest { @Test @DisplayName("Should handle email pattern redaction") void handleEmailPatternRedaction() throws Exception { - testAutoRedaction("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", true, false, "#0000FF", 1.5f, false, true); + testAutoRedaction( + "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", + true, false, "#0000FF", 1.5f, false, true); } @Test @DisplayName("Should handle phone number patterns") void handlePhoneNumberPatterns() throws Exception { - testAutoRedaction("\\(\\d{3}\\)\\s*\\d{3}-\\d{4}", true, false, "#FF0000", 1.0f, false, true); + testAutoRedaction( + "\\(\\d{3}\\)\\s*\\d{3}-\\d{4}", true, false, "#FF0000", 1.0f, false, true); } @ParameterizedTest - @ValueSource(strings = { - "\\d{3}-\\d{2}-\\d{4}", // SSN pattern - "\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}", // Credit card pattern - "\\b[A-Z]{2,}\\b", // Uppercase words - "\\$\\d+\\.\\d{2}", // Currency pattern - "\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\b" // IP address pattern - }) + @ValueSource( + strings = { + "\\d{3}-\\d{2}-\\d{4}", // SSN pattern + "\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}[\\s-]?\\d{4}", // Credit card pattern + "\\b[A-Z]{2,}\\b", // Uppercase words + "\\$\\d+\\.\\d{2}", // Currency pattern + "\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\b" // IP address pattern + }) @DisplayName("Should handle various regex patterns") void handleVariousRegexPatterns(String regexPattern) throws Exception { testAutoRedaction(regexPattern, true, false, "#000000", 1.0f, false, true); @@ -519,8 +544,10 @@ class RedactControllerTest { when(page.getBBox()).thenReturn(pageRect); when(page.hasContents()).thenReturn(true); - InputStream mockInputStream = new ByteArrayInputStream( - ("BT /F1 12 Tf 100 200 Td (page " + i + " content) Tj ET").getBytes()); + InputStream mockInputStream = + new ByteArrayInputStream( + ("BT /F1 12 Tf 100 200 Td (page " + i + " content) Tj ET") + .getBytes()); when(page.getContents()).thenReturn(mockInputStream); pageList.add(page); @@ -588,36 +615,38 @@ class RedactControllerTest { request.setFileInput(null); request.setListOfText("test"); - assertDoesNotThrow(() -> { - try { - redactController.redactPdf(request); - } catch (Exception e) { - assertNotNull(e); - } - }); + assertDoesNotThrow( + () -> { + try { + redactController.redactPdf(request); + } catch (Exception e) { + assertNotNull(e); + } + }); } @Test @DisplayName("Should handle malformed PDF gracefully") void handleMalformedPdfGracefully() throws Exception { - MockMultipartFile malformedFile = new MockMultipartFile( - "fileInput", - "malformed.pdf", - "application/pdf", - "Not a real PDF content".getBytes() - ); + MockMultipartFile malformedFile = + new MockMultipartFile( + "fileInput", + "malformed.pdf", + "application/pdf", + "Not a real PDF content".getBytes()); RedactPdfRequest request = new RedactPdfRequest(); request.setFileInput(malformedFile); request.setListOfText("test"); - assertDoesNotThrow(() -> { - try { - redactController.redactPdf(request); - } catch (Exception e) { - assertNotNull(e); - } - }); + assertDoesNotThrow( + () -> { + try { + redactController.redactPdf(request); + } catch (Exception e) { + assertNotNull(e); + } + }); } @Test @@ -723,14 +752,24 @@ class RedactControllerTest { } @ParameterizedTest - @ValueSource(strings = {"#FF0000", "#00FF00", "#0000FF", "#FFFFFF", "#000000", "FF0000", "00FF00", "0000FF"}) + @ValueSource( + strings = { + "#FF0000", "#00FF00", "#0000FF", "#FFFFFF", "#000000", "FF0000", "00FF00", + "0000FF" + }) @DisplayName("Should handle various valid color formats") void handleVariousValidColorFormats(String colorInput) throws Exception { Color result = redactController.decodeOrDefault(colorInput); assertNotNull(result); - assertTrue(result.getRed() >= 0 && result.getRed() <= 255, "Red component should be in valid range"); - assertTrue(result.getGreen() >= 0 && result.getGreen() <= 255, "Green component should be in valid range"); - assertTrue(result.getBlue() >= 0 && result.getBlue() <= 255, "Blue component should be in valid range"); + assertTrue( + result.getRed() >= 0 && result.getRed() <= 255, + "Red component should be in valid range"); + assertTrue( + result.getGreen() >= 0 && result.getGreen() <= 255, + "Green component should be in valid range"); + assertTrue( + result.getBlue() >= 0 && result.getBlue() <= 255, + "Blue component should be in valid range"); } @Test @@ -755,16 +794,18 @@ class RedactControllerTest { Set targetWords = Set.of("confidential"); - List tokens = redactController.createTokensWithoutTargetText(realPage, targetWords, false, false); + List tokens = + redactController.createTokensWithoutTargetText( + realDocument, realPage, targetWords, false, false); assertNotNull(tokens); assertFalse(tokens.isEmpty()); String reconstructedText = extractTextFromTokens(tokens); - assertFalse(reconstructedText.contains("confidential"), - "Target text should be replaced with placeholder"); - assertTrue(reconstructedText.contains("document"), - "Non-target text should remain"); + assertFalse( + reconstructedText.contains("confidential"), + "Target text should be replaced with placeholder"); + assertTrue(reconstructedText.contains("document"), "Non-target text should remain"); } @Test @@ -774,7 +815,9 @@ class RedactControllerTest { Set targetWords = Set.of("secret"); - List tokens = redactController.createTokensWithoutTargetText(realPage, targetWords, false, false); + List tokens = + redactController.createTokensWithoutTargetText( + realDocument, realPage, targetWords, false, false); assertNotNull(tokens); @@ -785,7 +828,9 @@ class RedactControllerTest { if (array.getObject(i) instanceof COSString cosString) { String text = cosString.getString(); if (text.contains("secret")) { - fail("Target text 'secret' should have been redacted from TJ array"); + fail( + "Target text 'secret' should have been redacted from TJ" + + " array"); } foundModifiedTJArray = true; } @@ -803,21 +848,33 @@ class RedactControllerTest { Set targetWords = Set.of("redact"); List originalTokens = getOriginalTokens(); - List filteredTokens = redactController.createTokensWithoutTargetText(realPage, targetWords, false, false); + List filteredTokens = + redactController.createTokensWithoutTargetText( + realDocument, realPage, targetWords, false, false); - long originalNonTextCount = originalTokens.stream() - .filter(token -> token instanceof Operator op && !redactController.isTextShowingOperator(op.getName())) - .count(); + long originalNonTextCount = + originalTokens.stream() + .filter( + token -> + token instanceof Operator op + && !redactController.isTextShowingOperator( + op.getName())) + .count(); - long filteredNonTextCount = filteredTokens.stream() - .filter(token -> token instanceof Operator op && !redactController.isTextShowingOperator(op.getName())) - .count(); + long filteredNonTextCount = + filteredTokens.stream() + .filter( + token -> + token instanceof Operator op + && !redactController.isTextShowingOperator( + op.getName())) + .count(); - assertTrue(filteredNonTextCount > 0, - "Non-text operators should be preserved"); + assertTrue(filteredNonTextCount > 0, "Non-text operators should be preserved"); - assertTrue(filteredNonTextCount >= originalNonTextCount / 2, - "A reasonable number of non-text operators should be preserved"); + assertTrue( + filteredNonTextCount >= originalNonTextCount / 2, + "A reasonable number of non-text operators should be preserved"); } @Test @@ -827,7 +884,9 @@ class RedactControllerTest { Set targetWords = Set.of("\\d{3}-\\d{2}-\\d{4}"); // SSN pattern - List tokens = redactController.createTokensWithoutTargetText(realPage, targetWords, true, false); + List tokens = + redactController.createTokensWithoutTargetText( + realDocument, realPage, targetWords, true, false); String reconstructedText = extractTextFromTokens(tokens); assertFalse(reconstructedText.contains("111-22-3333"), "SSN should be redacted"); @@ -841,7 +900,9 @@ class RedactControllerTest { Set targetWords = Set.of("test"); - List tokens = redactController.createTokensWithoutTargetText(realPage, targetWords, false, true); + List tokens = + redactController.createTokensWithoutTargetText( + realDocument, realPage, targetWords, false, true); String reconstructedText = extractTextFromTokens(tokens); assertTrue(reconstructedText.contains("testing"), "Partial matches should remain"); @@ -856,11 +917,14 @@ class RedactControllerTest { Set targetWords = Set.of("sensitive"); - List tokens = redactController.createTokensWithoutTargetText(realPage, targetWords, false, false); + List tokens = + redactController.createTokensWithoutTargetText( + realDocument, realPage, targetWords, false, false); String reconstructedText = extractTextFromTokens(tokens); - assertFalse(reconstructedText.contains("sensitive"), - "Text should be redacted regardless of operator type"); + assertFalse( + reconstructedText.contains("sensitive"), + "Text should be redacted regardless of operator type"); } @Test @@ -884,7 +948,10 @@ class RedactControllerTest { void shouldHandleEmptyTokenList() throws Exception { List emptyTokens = Collections.emptyList(); - assertDoesNotThrow(() -> redactController.writeFilteredContentStream(realDocument, realPage, emptyTokens)); + assertDoesNotThrow( + () -> + redactController.writeFilteredContentStream( + realDocument, realPage, emptyTokens)); assertNotNull(realPage.getContents(), "Page should still have content stream"); } @@ -906,20 +973,27 @@ class RedactControllerTest { @DisplayName("Placeholder creation should maintain text width") void shouldCreateWidthMatchingPlaceholder() throws Exception { String originalText = "confidential"; - String placeholder = redactController.createPlaceholder(originalText); + String placeholder = + redactController.createPlaceholderWithFont( + originalText, new PDType1Font(Standard14Fonts.FontName.HELVETICA)); - assertEquals(originalText.length(), placeholder.length(), - "Placeholder should maintain character count for width preservation"); + assertEquals( + originalText.length(), + placeholder.length(), + "Placeholder should maintain character count for width preservation"); } @Test @DisplayName("Placeholder should handle special characters") void shouldHandleSpecialCharactersInPlaceholder() throws Exception { String originalText = "café naïve"; - String placeholder = redactController.createPlaceholder(originalText); + String placeholder = + redactController.createPlaceholderWithFont( + originalText, new PDType1Font(Standard14Fonts.FontName.HELVETICA)); assertEquals(originalText.length(), placeholder.length()); - assertFalse(placeholder.contains("café"), "Placeholder should not contain original text"); + assertFalse( + placeholder.contains("café"), "Placeholder should not contain original text"); } @Test @@ -929,7 +1003,9 @@ class RedactControllerTest { Set targetWords = Set.of("secret"); - List filteredTokens = redactController.createTokensWithoutTargetText(realPage, targetWords, false, false); + List filteredTokens = + redactController.createTokensWithoutTargetText( + realDocument, realPage, targetWords, false, false); redactController.writeFilteredContentStream(realDocument, realPage, filteredTokens); assertNotNull(realPage.getContents()); @@ -946,15 +1022,21 @@ class RedactControllerTest { Set targetWords = Set.of("confidential"); - List filteredTokens = redactController.createTokensWithoutTargetText(realPage, targetWords, false, false); + List filteredTokens = + redactController.createTokensWithoutTargetText( + realDocument, realPage, targetWords, false, false); - long filteredPositioning = filteredTokens.stream() - .filter(token -> token instanceof Operator op && - (op.getName().equals("Td") || op.getName().equals("TD") || op.getName().equals("Tm"))) - .count(); + long filteredPositioning = + filteredTokens.stream() + .filter( + token -> + token instanceof Operator op + && ("Td".equals(op.getName()) + || "TD".equals(op.getName()) + || "Tm".equals(op.getName()))) + .count(); - assertTrue(filteredPositioning > 0, - "Positioning operators should be preserved"); + assertTrue(filteredPositioning > 0, "Positioning operators should be preserved"); } @Test @@ -966,16 +1048,21 @@ class RedactControllerTest { } realDocument.addPage(realPage); realPage.setResources(new PDResources()); - realPage.getResources().put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + realPage.getResources() + .put( + COSName.getPDFName("F1"), + new PDType1Font(Standard14Fonts.FontName.HELVETICA)); - try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) { + try (PDPageContentStream contentStream = + new PDPageContentStream(realDocument, realPage)) { contentStream.setLineWidth(2); contentStream.moveTo(100, 100); contentStream.lineTo(200, 200); contentStream.stroke(); contentStream.beginText(); - contentStream.setFont(realPage.getResources().getFont(COSName.getPDFName("F1")), 12); + contentStream.setFont( + realPage.getResources().getFont(COSName.getPDFName("F1")), 12); contentStream.newLineAtOffset(50, 750); contentStream.showText("This is a complex document with "); contentStream.setTextRise(5); @@ -990,19 +1077,27 @@ class RedactControllerTest { Set targetWords = Set.of("confidential"); - List tokens = redactController.createTokensWithoutTargetText(realPage, targetWords, false, false); + List tokens = + redactController.createTokensWithoutTargetText( + realDocument, realPage, targetWords, false, false); assertNotNull(tokens); assertFalse(tokens.isEmpty()); String reconstructedText = extractTextFromTokens(tokens); - assertFalse(reconstructedText.contains("confidential"), "Target text should be redacted"); + assertFalse( + reconstructedText.contains("confidential"), "Target text should be redacted"); - boolean hasGraphicsOperators = tokens.stream() - .anyMatch(token -> token instanceof Operator op && - (op.getName().equals("re") || op.getName().equals("f") || - op.getName().equals("m") || op.getName().equals("l") || - op.getName().equals("S"))); + boolean hasGraphicsOperators = + tokens.stream() + .anyMatch( + token -> + token instanceof Operator op + && ("re".equals(op.getName()) + || "f".equals(op.getName()) + || "m".equals(op.getName()) + || "l".equals(op.getName()) + || "S".equals(op.getName()))); assertTrue(hasGraphicsOperators, "Graphics operators should be preserved"); } @@ -1019,10 +1114,12 @@ class RedactControllerTest { // Create resources PDResources resources = new PDResources(); - resources.put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + resources.put( + COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); realPage.setResources(resources); - try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) { + try (PDPageContentStream contentStream = + new PDPageContentStream(realDocument, realPage)) { contentStream.beginText(); contentStream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); contentStream.newLineAtOffset(50, 750); @@ -1180,7 +1277,8 @@ class RedactControllerTest { } realDocument.addPage(realPage); realPage.setResources(new PDResources()); - realPage.getResources().put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + realPage.getResources() + .put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) { contentStream.beginText(); @@ -1198,7 +1296,8 @@ class RedactControllerTest { } realDocument.addPage(realPage); realPage.setResources(new PDResources()); - realPage.getResources().put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + realPage.getResources() + .put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) { contentStream.beginText(); @@ -1221,7 +1320,8 @@ class RedactControllerTest { } realDocument.addPage(realPage); realPage.setResources(new PDResources()); - realPage.getResources().put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + realPage.getResources() + .put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) { contentStream.setLineWidth(2); @@ -1248,7 +1348,8 @@ class RedactControllerTest { } realDocument.addPage(realPage); realPage.setResources(new PDResources()); - realPage.getResources().put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + realPage.getResources() + .put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) { contentStream.beginText(); @@ -1266,28 +1367,29 @@ class RedactControllerTest { // Helper for token creation private List createSampleTokenList() { return List.of( - Operator.getOperator("BT"), - COSName.getPDFName("F1"), - new COSFloat(12), - Operator.getOperator("Tf"), - new COSString("Sample text"), - Operator.getOperator("Tj"), - Operator.getOperator("ET") - ); + Operator.getOperator("BT"), + COSName.getPDFName("F1"), + new COSFloat(12), + Operator.getOperator("Tf"), + new COSString("Sample text"), + Operator.getOperator("Tj"), + Operator.getOperator("ET")); } private List getOriginalTokens() throws Exception { // Create a new page to avoid side effects from other tests PDPage pageForTokenExtraction = new PDPage(PDRectangle.A4); pageForTokenExtraction.setResources(realPage.getResources()); - try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, pageForTokenExtraction)) { - contentStream.beginText(); - contentStream.setFont(realPage.getResources().getFont(COSName.getPDFName("F1")), 12); - contentStream.newLineAtOffset(50, 750); - contentStream.showText("Original content"); - contentStream.endText(); + try (PDPageContentStream contentStream = + new PDPageContentStream(realDocument, pageForTokenExtraction)) { + contentStream.beginText(); + contentStream.setFont(realPage.getResources().getFont(COSName.getPDFName("F1")), 12); + contentStream.newLineAtOffset(50, 750); + contentStream.showText("Original content"); + contentStream.endText(); } - return redactController.createTokensWithoutTargetText(pageForTokenExtraction, Collections.emptySet(), false, false); + return redactController.createTokensWithoutTargetText( + realDocument, pageForTokenExtraction, Collections.emptySet(), false, false); } private String extractTextFromTokens(List tokens) { diff --git a/stirling-pdf/src/test/java/stirling/software/SPDF/pdf/TextFinderTest.java b/app/core/src/test/java/stirling/software/SPDF/pdf/TextFinderTest.java similarity index 69% rename from stirling-pdf/src/test/java/stirling/software/SPDF/pdf/TextFinderTest.java rename to app/core/src/test/java/stirling/software/SPDF/pdf/TextFinderTest.java index ebb5bebf7..3e5092070 100644 --- a/stirling-pdf/src/test/java/stirling/software/SPDF/pdf/TextFinderTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/pdf/TextFinderTest.java @@ -1,5 +1,11 @@ package stirling.software.SPDF.pdf; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + import java.io.IOException; import java.util.List; @@ -10,11 +16,6 @@ import org.apache.pdfbox.pdmodel.common.PDRectangle; import org.apache.pdfbox.pdmodel.font.PDType1Font; import org.apache.pdfbox.pdmodel.font.Standard14Fonts; import org.junit.jupiter.api.AfterEach; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -34,33 +35,44 @@ class TextFinderTest { private PDPage page; // Helpers - private void testTextFinding(String pageContent, String searchTerm, boolean useRegex, boolean wholeWord, - String[] expectedTexts, int expectedCount) throws IOException { + private void testTextFinding( + String pageContent, + String searchTerm, + boolean useRegex, + boolean wholeWord, + String[] expectedTexts, + int expectedCount) + throws IOException { addTextToPage(pageContent); TextFinder textFinder = new TextFinder(searchTerm, useRegex, wholeWord); textFinder.getText(document); List foundTexts = textFinder.getFoundTexts(); - assertEquals(expectedCount, foundTexts.size(), - String.format("Expected %d matches for search term '%s'", expectedCount, searchTerm)); + assertEquals( + expectedCount, + foundTexts.size(), + String.format( + "Expected %d matches for search term '%s'", expectedCount, searchTerm)); if (expectedTexts != null) { for (String expectedText : expectedTexts) { - assertTrue(foundTexts.stream().anyMatch(text -> text.getText().equals(expectedText)), - String.format("Expected to find text: '%s'", expectedText)); + assertTrue( + foundTexts.stream().anyMatch(text -> text.getText().equals(expectedText)), + String.format("Expected to find text: '%s'", expectedText)); } } // Verify basic properties of found texts - foundTexts.forEach(text -> { - assertNotNull(text.getText()); - assertTrue(text.getX1() >= 0); - assertTrue(text.getY1() >= 0); - assertTrue(text.getX2() >= text.getX1()); - assertTrue(text.getY2() >= text.getY1()); - assertEquals(0, text.getPageIndex()); // Single page test - }); + foundTexts.forEach( + text -> { + assertNotNull(text.getText()); + assertTrue(text.getX1() >= 0); + assertTrue(text.getY1() >= 0); + assertTrue(text.getX2() >= text.getX1()); + assertTrue(text.getY2() >= text.getY1()); + assertEquals(0, text.getPageIndex()); // Single page test + }); } @BeforeEach @@ -84,25 +96,37 @@ class TextFinderTest { @Test @DisplayName("Should find simple text correctly") void findSimpleText() throws IOException { - testTextFinding("This is a confidential document with secret information.", - "confidential", false, false, - new String[]{"confidential"}, 1); + testTextFinding( + "This is a confidential document with secret information.", + "confidential", + false, + false, + new String[] {"confidential"}, + 1); } @Test @DisplayName("Should perform case-insensitive search") void performCaseInsensitiveSearch() throws IOException { - testTextFinding("This document contains CONFIDENTIAL information.", - "confidential", false, false, - new String[]{"CONFIDENTIAL"}, 1); + testTextFinding( + "This document contains CONFIDENTIAL information.", + "confidential", + false, + false, + new String[] {"CONFIDENTIAL"}, + 1); } @Test @DisplayName("Should find multiple occurrences of same term") void findMultipleOccurrences() throws IOException { - testTextFinding("The secret code is secret123. Keep this secret safe!", - "secret", false, false, - new String[]{"secret", "secret", "secret"}, 3); + testTextFinding( + "The secret code is secret123. Keep this secret safe!", + "secret", + false, + false, + new String[] {"secret", "secret", "secret"}, + 3); } @Test @@ -131,33 +155,49 @@ class TextFinderTest { @Test @DisplayName("Should find only whole words when enabled") void findOnlyWholeWords() throws IOException { - testTextFinding("This is a test testing document with tested results.", - "test", false, true, - new String[]{"test"}, 1); + testTextFinding( + "This is a test testing document with tested results.", + "test", + false, + true, + new String[] {"test"}, + 1); } @Test @DisplayName("Should find partial matches when whole word search disabled") void findPartialMatches() throws IOException { - testTextFinding("This is a test testing document with tested results.", - "test", false, false, - new String[]{"test", "test", "test"}, 3); + testTextFinding( + "This is a test testing document with tested results.", + "test", + false, + false, + new String[] {"test", "test", "test"}, + 3); } @Test @DisplayName("Should handle punctuation boundaries correctly") void handlePunctuationBoundaries() throws IOException { - testTextFinding("Hello, world! Testing: test-case (test).", - "test", false, true, - new String[]{"test"}, 2); // Both standalone "test" and "test" in "test-case" + testTextFinding( + "Hello, world! Testing: test-case (test).", + "test", + false, + true, + new String[] {"test"}, + 2); // Both standalone "test" and "test" in "test-case" } @Test @DisplayName("Should handle word boundaries with special characters") void handleSpecialCharacterBoundaries() throws IOException { - testTextFinding("Email: test@example.com and test.txt file", - "test", false, true, - new String[]{"test"}, 2); // Both in email and filename should match + testTextFinding( + "Email: test@example.com and test.txt file", + "test", + false, + true, + new String[] {"test"}, + 2); // Both in email and filename should match } } @@ -168,46 +208,64 @@ class TextFinderTest { @Test @DisplayName("Should find text matching regex pattern") void findTextMatchingRegex() throws IOException { - testTextFinding("Contact John at 123-45-6789 or Jane at 987-65-4321 for details.", - "\\d{3}-\\d{2}-\\d{4}", true, false, - new String[]{"123-45-6789", "987-65-4321"}, 2); + testTextFinding( + "Contact John at 123-45-6789 or Jane at 987-65-4321 for details.", + "\\d{3}-\\d{2}-\\d{4}", + true, + false, + new String[] {"123-45-6789", "987-65-4321"}, + 2); } @Test @DisplayName("Should find email addresses with regex") void findEmailAddresses() throws IOException { - testTextFinding("Email: test@example.com and admin@test.org", - "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", true, false, - new String[]{"test@example.com", "admin@test.org"}, 2); + testTextFinding( + "Email: test@example.com and admin@test.org", + "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", + true, + false, + new String[] {"test@example.com", "admin@test.org"}, + 2); } @Test @DisplayName("Should combine regex with whole word search") void combineRegexWithWholeWord() throws IOException { - testTextFinding("Email: test@example.com and admin@test.org", - "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", true, true, - new String[]{"test@example.com", "admin@test.org"}, 2); + testTextFinding( + "Email: test@example.com and admin@test.org", + "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", + true, + true, + new String[] {"test@example.com", "admin@test.org"}, + 2); } @Test @DisplayName("Should find currency patterns") void findCurrencyPatterns() throws IOException { - testTextFinding("Price: $100.50 and €75.25", - "\\$\\d+\\.\\d{2}", true, false, - new String[]{"$100.50"}, 1); + testTextFinding( + "Price: $100.50 and €75.25", + "\\$\\d+\\.\\d{2}", + true, + false, + new String[] {"$100.50"}, + 1); } @ParameterizedTest - @ValueSource(strings = { - "\\d{4}-\\d{2}-\\d{2}", // Date pattern - "\\b[A-Z]{2,}\\b", // Uppercase words - "\\w+@\\w+\\.\\w+", // Simple email pattern - "\\$\\d+", // Simple currency - "\\b\\d{3,4}\\b" // 3-4 digit numbers - }) + @ValueSource( + strings = { + "\\d{4}-\\d{2}-\\d{2}", // Date pattern + "\\b[A-Z]{2,}\\b", // Uppercase words + "\\w+@\\w+\\.\\w+", // Simple email pattern + "\\$\\d+", // Simple currency + "\\b\\d{3,4}\\b" // 3-4 digit numbers + }) @DisplayName("Should handle various regex patterns") void handleVariousRegexPatterns(String regexPattern) throws IOException { - String testContent = "Date: 2023-12-25, Email: test@domain.com, Price: $250, Code: ABC123, Number: 1234"; + String testContent = + "Date: 2023-12-25, Email: test@domain.com, Price: $250, Code: ABC123, Number: 1234"; addTextToPage(testContent); TextFinder textFinder = new TextFinder(regexPattern, true, false); @@ -215,7 +273,9 @@ class TextFinderTest { List foundTexts = textFinder.getFoundTexts(); // Each pattern should find at least one match in our test content - assertFalse(foundTexts.isEmpty(), String.format("Pattern '%s' should find at least one match", regexPattern)); + assertFalse( + foundTexts.isEmpty(), + String.format("Pattern '%s' should find at least one match", regexPattern)); } @Test @@ -230,9 +290,10 @@ class TextFinderTest { assertNotNull(foundTexts); } catch (java.util.regex.PatternSyntaxException e) { assertNotNull(e.getMessage()); - assertTrue(e.getMessage().contains("Unclosed character class") || - e.getMessage().contains("syntax"), - "Exception should indicate regex syntax error"); + assertTrue( + e.getMessage().contains("Unclosed character class") + || e.getMessage().contains("syntax"), + "Exception should indicate regex syntax error"); } catch (RuntimeException | IOException e) { assertNotNull(e.getMessage()); } @@ -246,33 +307,38 @@ class TextFinderTest { @Test @DisplayName("Should handle international characters") void handleInternationalCharacters() throws IOException { - testTextFinding("Hello café naïve résumé", - "café", false, false, - new String[]{"café"}, 1); + testTextFinding( + "Hello café naïve résumé", "café", false, false, new String[] {"café"}, 1); } @Test @DisplayName("Should find text with accented characters") void findAccentedCharacters() throws IOException { - testTextFinding("Café, naïve, résumé, piñata", - "café", false, false, - new String[]{"Café"}, 1); // Case insensitive + testTextFinding( + "Café, naïve, résumé, piñata", + "café", + false, + false, + new String[] {"Café"}, + 1); // Case insensitive } @Test @DisplayName("Should handle special symbols") void handleSpecialSymbols() throws IOException { - testTextFinding("Symbols: © ® ™ ± × ÷ § ¶", - "©", false, false, - new String[]{"©"}, 1); + testTextFinding("Symbols: © ® ™ ± × ÷ § ¶", "©", false, false, new String[] {"©"}, 1); } @Test @DisplayName("Should find currency symbols") void findCurrencySymbols() throws IOException { - testTextFinding("Prices: $100 €75 £50 ¥1000", - "[€£¥]", true, false, - new String[]{"€", "£", "¥"}, 3); + testTextFinding( + "Prices: $100 €75 £50 ¥1000", + "[€£¥]", + true, + false, + new String[] {"€", "£", "¥"}, + 3); } } @@ -330,7 +396,7 @@ class TextFinderTest { String longTerm = "a".repeat(1000); String content = "Short text with " + longTerm + " embedded."; - testTextFinding(content, longTerm, false, false, new String[]{longTerm}, 1); + testTextFinding(content, longTerm, false, false, new String[] {longTerm}, 1); } @Test @@ -350,8 +416,9 @@ class TextFinderTest { long endTime = System.currentTimeMillis(); assertEquals(10, foundTexts.size()); - assertTrue(endTime - startTime < 3000, - "Multi-page search should complete within 3 seconds"); + assertTrue( + endTime - startTime < 3000, + "Multi-page search should complete within 3 seconds"); } } @@ -402,12 +469,13 @@ class TextFinderTest { String complexRegex = "(?=.*\\d)(?=.*[a-z])(?=.*[A-Z])[a-zA-Z\\d]{6}"; - assertDoesNotThrow(() -> { - TextFinder textFinder = new TextFinder(complexRegex, true, false); - textFinder.getText(document); - List foundTexts = textFinder.getFoundTexts(); - assertNotNull(foundTexts); - }); + assertDoesNotThrow( + () -> { + TextFinder textFinder = new TextFinder(complexRegex, true, false); + textFinder.getText(document); + List foundTexts = textFinder.getFoundTexts(); + assertNotNull(foundTexts); + }); } @ParameterizedTest @@ -464,10 +532,11 @@ class TextFinderTest { List foundTexts = textFinder.getFoundTexts(); assertFalse(foundTexts.isEmpty()); - foundTexts.forEach(text -> { - assertNotNull(text.getText()); - assertTrue(text.getX1() >= 0 && text.getY1() >= 0); - }); + foundTexts.forEach( + text -> { + assertNotNull(text.getText()); + assertTrue(text.getX1() >= 0 && text.getY1() >= 0); + }); } } @@ -485,8 +554,10 @@ class TextFinderTest { textFinder.getText(document); List foundTexts = textFinder.getFoundTexts(); - assertEquals(1, foundTexts.size(), - "Should find exactly one standalone '1', not the ones embedded in other numbers/codes"); + assertEquals( + 1, + foundTexts.size(), + "Should find exactly one standalone '1', not the ones embedded in other numbers/codes"); assertEquals("1", foundTexts.get(0).getText()); } @@ -500,14 +571,16 @@ class TextFinderTest { textFinder.getText(document); List foundTexts = textFinder.getFoundTexts(); - assertTrue(foundTexts.size() >= 3, - "Should find multiple instances of '1' including standalone, in '1234', and in 'A1B'"); + assertTrue( + foundTexts.size() >= 3, + "Should find multiple instances of '1' including standalone, in '1234', and in 'A1B'"); } @Test @DisplayName("Should find single characters in various contexts") void findSingleCharacters() throws IOException { - String content = "Grade: A. Section B has item A-1. The letter A appears multiple times."; + String content = + "Grade: A. Section B has item A-1. The letter A appears multiple times."; addTextToPage(content); TextFinder textFinder = new TextFinder("A", false, true); @@ -522,24 +595,29 @@ class TextFinderTest { } @Test - @DisplayName("Should handle digits at word boundaries correctly") + @DisplayName("Digits as strict standalone tokens (exclude decimals and suffixes)") void findDigitsAtWordBoundaries() throws IOException { - String content = "Numbers: 1, 2, 3. Code: 123. Version: 1.0. Item1 and Item2."; + String content = + "Numbers: 1, 2, 3. Code: 123. Version: 1.0. Item1 and Item2. Price: 2,50€"; addTextToPage(content); TextFinder textFinder1 = new TextFinder("1", false, true); textFinder1.getText(document); List foundTexts1 = textFinder1.getFoundTexts(); - assertEquals(1, foundTexts1.size(), - "Should find only the standalone '1' at the beginning"); + assertEquals( + 1, + foundTexts1.size(), + "Should find only the standalone '1'; do not count the '1' in '1.0' or in 'Item1'."); TextFinder textFinder2 = new TextFinder("2", false, true); textFinder2.getText(document); List foundTexts2 = textFinder2.getFoundTexts(); - assertEquals(1, foundTexts2.size(), - "Should find only the standalone '2' in the number list"); + assertEquals( + 1, + foundTexts2.size(), + "Should find only the standalone '2' in the number list"); } @Test @@ -566,8 +644,10 @@ class TextFinderTest { textFinder.getText(document); List foundTexts = textFinder.getFoundTexts(); - assertEquals(2, foundTexts.size(), - "Should find both '1' instances despite spacing variations"); + assertEquals( + 2, + foundTexts.size(), + "Should find both '1' instances despite spacing variations"); } } From 5fb207492e340bf5b94cdd16e1335a70b97002e2 Mon Sep 17 00:00:00 2001 From: "stirlingbot[bot]" <195170888+stirlingbot[bot]@users.noreply.github.com> Date: Sun, 24 Aug 2025 21:26:01 +0100 Subject: [PATCH 30/41] Update 3rd Party Licenses (#4247) Auto-generated by stirlingbot[bot] Signed-off-by: stirlingbot[bot] Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> --- .../resources/static/3rdPartyLicenses.json | 191 +++++++++--------- 1 file changed, 96 insertions(+), 95 deletions(-) diff --git a/app/core/src/main/resources/static/3rdPartyLicenses.json b/app/core/src/main/resources/static/3rdPartyLicenses.json index 062818603..4038f11b8 100644 --- a/app/core/src/main/resources/static/3rdPartyLicenses.json +++ b/app/core/src/main/resources/static/3rdPartyLicenses.json @@ -504,7 +504,7 @@ { "moduleName": "com.zaxxer:HikariCP", "moduleUrl": "https://github.com/brettwooldridge/HikariCP", - "moduleVersion": "6.3.1", + "moduleVersion": "6.3.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -559,56 +559,56 @@ { "moduleName": "io.jsonwebtoken:jjwt-api", "moduleUrl": "https://github.com/jwtk/jjwt", - "moduleVersion": "0.12.6", - "moduleLicense": "Apache License, Version 2.0", + "moduleVersion": "0.12.7", + "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "io.jsonwebtoken:jjwt-impl", "moduleUrl": "https://github.com/jwtk/jjwt", - "moduleVersion": "0.12.6", - "moduleLicense": "Apache License, Version 2.0", + "moduleVersion": "0.12.7", + "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "io.jsonwebtoken:jjwt-jackson", "moduleUrl": "https://github.com/jwtk/jjwt", - "moduleVersion": "0.12.6", - "moduleLicense": "Apache License, Version 2.0", + "moduleVersion": "0.12.7", + "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "io.micrometer:micrometer-commons", "moduleUrl": "https://github.com/micrometer-metrics/micrometer", - "moduleVersion": "1.15.2", + "moduleVersion": "1.15.3", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "io.micrometer:micrometer-core", "moduleUrl": "https://github.com/micrometer-metrics/micrometer", - "moduleVersion": "1.15.2", + "moduleVersion": "1.15.3", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "io.micrometer:micrometer-jakarta9", "moduleUrl": "https://github.com/micrometer-metrics/micrometer", - "moduleVersion": "1.15.2", + "moduleVersion": "1.15.3", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "io.micrometer:micrometer-observation", "moduleUrl": "https://github.com/micrometer-metrics/micrometer", - "moduleVersion": "1.15.2", + "moduleVersion": "1.15.3", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "io.micrometer:micrometer-registry-prometheus", "moduleUrl": "https://github.com/micrometer-metrics/micrometer", - "moduleVersion": "1.15.2", + "moduleVersion": "1.15.3", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -657,21 +657,21 @@ { "moduleName": "io.swagger.core.v3:swagger-annotations-jakarta", "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations", - "moduleVersion": "2.2.35", + "moduleVersion": "2.2.36", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "io.swagger.core.v3:swagger-core-jakarta", "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core", - "moduleVersion": "2.2.35", + "moduleVersion": "2.2.36", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "io.swagger.core.v3:swagger-models-jakarta", "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models", - "moduleVersion": "2.2.35", + "moduleVersion": "2.2.36", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, @@ -803,7 +803,7 @@ }, { "moduleName": "net.bytebuddy:byte-buddy", - "moduleVersion": "1.17.6", + "moduleVersion": "1.17.7", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -946,7 +946,7 @@ { "moduleName": "org.apache.tomcat.embed:tomcat-embed-el", "moduleUrl": "https://tomcat.apache.org/", - "moduleVersion": "10.1.43", + "moduleVersion": "10.1.44", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -1041,196 +1041,196 @@ { "moduleName": "org.eclipse.angus:angus-mail", "moduleUrl": "https://www.eclipse.org", - "moduleVersion": "2.0.3", + "moduleVersion": "2.0.4", "moduleLicense": "GPL2 w/ CPE", "moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html" }, { "moduleName": "org.eclipse.angus:jakarta.mail", "moduleUrl": "https://www.eclipse.org", - "moduleVersion": "2.0.3", + "moduleVersion": "2.0.4", "moduleLicense": "GPL2 w/ CPE", "moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html" }, { "moduleName": "org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jakarta-client", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jakarta-common", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jakarta-server", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jetty-server", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-servlet", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty.ee10:jetty-ee10-annotations", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty.ee10:jetty-ee10-plus", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty.ee10:jetty-ee10-servlet", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty.ee10:jetty-ee10-servlets", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty.ee10:jetty-ee10-webapp", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty.websocket:jetty-websocket-core-client", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty.websocket:jetty-websocket-core-common", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty.websocket:jetty-websocket-core-server", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty.websocket:jetty-websocket-jetty-api", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty.websocket:jetty-websocket-jetty-common", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty:jetty-alpn-client", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty:jetty-client", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty:jetty-ee", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty:jetty-http", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty:jetty-io", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty:jetty-plus", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty:jetty-security", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty:jetty-server", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty:jetty-session", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty:jetty-util", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, { "moduleName": "org.eclipse.jetty:jetty-xml", "moduleUrl": "https://jetty.org/", - "moduleVersion": "12.0.23", + "moduleVersion": "12.0.25", "moduleLicense": "Eclipse Public License - Version 2.0", "moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/" }, @@ -1272,15 +1272,16 @@ { "moduleName": "org.hibernate.orm:hibernate-core", "moduleUrl": "https://www.hibernate.org/orm/6.6", - "moduleVersion": "6.6.22.Final", + "moduleVersion": "6.6.26.Final", "moduleLicense": "GNU Library General Public License v2.1 or later", "moduleLicenseUrl": "https://www.opensource.org/licenses/LGPL-2.1" }, { "moduleName": "org.hibernate.validator:hibernate-validator", - "moduleVersion": "8.0.2.Final", + "moduleUrl": "https://hibernate.org/validator", + "moduleVersion": "8.0.3.Final", "moduleLicense": "Apache License 2.0", - "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "org.jboss.logging:jboss-logging", @@ -1470,320 +1471,320 @@ }, { "moduleName": "org.springdoc:springdoc-openapi-starter-common", - "moduleVersion": "2.8.9", + "moduleVersion": "2.8.11", "moduleLicense": "The Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "org.springdoc:springdoc-openapi-starter-webmvc-api", - "moduleVersion": "2.8.9", + "moduleVersion": "2.8.11", "moduleLicense": "The Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "org.springdoc:springdoc-openapi-starter-webmvc-ui", - "moduleVersion": "2.8.9", + "moduleVersion": "2.8.11", "moduleLicense": "The Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "org.springframework.boot:spring-boot", "moduleUrl": "https://spring.io/projects/spring-boot", - "moduleVersion": "3.5.4", + "moduleVersion": "3.5.5", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.boot:spring-boot-actuator", "moduleUrl": "https://spring.io/projects/spring-boot", - "moduleVersion": "3.5.4", + "moduleVersion": "3.5.5", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.boot:spring-boot-actuator-autoconfigure", "moduleUrl": "https://spring.io/projects/spring-boot", - "moduleVersion": "3.5.4", + "moduleVersion": "3.5.5", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.boot:spring-boot-autoconfigure", "moduleUrl": "https://spring.io/projects/spring-boot", - "moduleVersion": "3.5.4", + "moduleVersion": "3.5.5", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.boot:spring-boot-starter", "moduleUrl": "https://spring.io/projects/spring-boot", - "moduleVersion": "3.5.4", + "moduleVersion": "3.5.5", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.boot:spring-boot-starter-actuator", "moduleUrl": "https://spring.io/projects/spring-boot", - "moduleVersion": "3.5.4", + "moduleVersion": "3.5.5", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.boot:spring-boot-starter-aop", "moduleUrl": "https://spring.io/projects/spring-boot", - "moduleVersion": "3.5.4", + "moduleVersion": "3.5.5", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.boot:spring-boot-starter-cache", "moduleUrl": "https://spring.io/projects/spring-boot", - "moduleVersion": "3.5.4", + "moduleVersion": "3.5.5", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.boot:spring-boot-starter-data-jpa", "moduleUrl": "https://spring.io/projects/spring-boot", - "moduleVersion": "3.5.4", + "moduleVersion": "3.5.5", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.boot:spring-boot-starter-jdbc", "moduleUrl": "https://spring.io/projects/spring-boot", - "moduleVersion": "3.5.4", + "moduleVersion": "3.5.5", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.boot:spring-boot-starter-jetty", "moduleUrl": "https://spring.io/projects/spring-boot", - "moduleVersion": "3.5.4", + "moduleVersion": "3.5.5", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.boot:spring-boot-starter-json", "moduleUrl": "https://spring.io/projects/spring-boot", - "moduleVersion": "3.5.4", + "moduleVersion": "3.5.5", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.boot:spring-boot-starter-logging", "moduleUrl": "https://spring.io/projects/spring-boot", - "moduleVersion": "3.5.4", + "moduleVersion": "3.5.5", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.boot:spring-boot-starter-mail", "moduleUrl": "https://spring.io/projects/spring-boot", - "moduleVersion": "3.5.4", + "moduleVersion": "3.5.5", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.boot:spring-boot-starter-oauth2-client", "moduleUrl": "https://spring.io/projects/spring-boot", - "moduleVersion": "3.5.4", + "moduleVersion": "3.5.5", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.boot:spring-boot-starter-security", "moduleUrl": "https://spring.io/projects/spring-boot", - "moduleVersion": "3.5.4", + "moduleVersion": "3.5.5", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.boot:spring-boot-starter-thymeleaf", "moduleUrl": "https://spring.io/projects/spring-boot", - "moduleVersion": "3.5.4", + "moduleVersion": "3.5.5", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.boot:spring-boot-starter-validation", "moduleUrl": "https://spring.io/projects/spring-boot", - "moduleVersion": "3.5.4", + "moduleVersion": "3.5.5", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.boot:spring-boot-starter-web", "moduleUrl": "https://spring.io/projects/spring-boot", - "moduleVersion": "3.5.4", + "moduleVersion": "3.5.5", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.data:spring-data-commons", "moduleUrl": "https://spring.io/projects/spring-data", - "moduleVersion": "3.5.2", + "moduleVersion": "3.5.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.data:spring-data-jpa", "moduleUrl": "https://projects.spring.io/spring-data-jpa", - "moduleVersion": "3.5.2", + "moduleVersion": "3.5.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.security:spring-security-config", "moduleUrl": "https://spring.io/projects/spring-security", - "moduleVersion": "6.5.2", + "moduleVersion": "6.5.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.security:spring-security-core", "moduleUrl": "https://spring.io/projects/spring-security", - "moduleVersion": "6.5.2", + "moduleVersion": "6.5.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.security:spring-security-crypto", "moduleUrl": "https://spring.io/projects/spring-security", - "moduleVersion": "6.5.2", + "moduleVersion": "6.5.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.security:spring-security-oauth2-client", "moduleUrl": "https://spring.io/projects/spring-security", - "moduleVersion": "6.5.2", + "moduleVersion": "6.5.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.security:spring-security-oauth2-core", "moduleUrl": "https://spring.io/projects/spring-security", - "moduleVersion": "6.5.2", + "moduleVersion": "6.5.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.security:spring-security-oauth2-jose", "moduleUrl": "https://spring.io/projects/spring-security", - "moduleVersion": "6.5.2", + "moduleVersion": "6.5.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.security:spring-security-saml2-service-provider", "moduleUrl": "https://spring.io/projects/spring-security", - "moduleVersion": "6.5.2", + "moduleVersion": "6.5.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.security:spring-security-web", "moduleUrl": "https://spring.io/projects/spring-security", - "moduleVersion": "6.5.2", + "moduleVersion": "6.5.3", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework.session:spring-session-core", "moduleUrl": "https://spring.io/projects/spring-session", - "moduleVersion": "3.5.1", + "moduleVersion": "3.5.2", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework:spring-aop", "moduleUrl": "https://github.com/spring-projects/spring-framework", - "moduleVersion": "6.2.9", + "moduleVersion": "6.2.10", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework:spring-aspects", "moduleUrl": "https://github.com/spring-projects/spring-framework", - "moduleVersion": "6.2.9", + "moduleVersion": "6.2.10", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework:spring-beans", "moduleUrl": "https://github.com/spring-projects/spring-framework", - "moduleVersion": "6.2.9", + "moduleVersion": "6.2.10", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework:spring-context", "moduleUrl": "https://github.com/spring-projects/spring-framework", - "moduleVersion": "6.2.9", + "moduleVersion": "6.2.10", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework:spring-context-support", "moduleUrl": "https://github.com/spring-projects/spring-framework", - "moduleVersion": "6.2.9", + "moduleVersion": "6.2.10", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework:spring-core", "moduleUrl": "https://github.com/spring-projects/spring-framework", - "moduleVersion": "6.2.9", + "moduleVersion": "6.2.10", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework:spring-expression", "moduleUrl": "https://github.com/spring-projects/spring-framework", - "moduleVersion": "6.2.9", + "moduleVersion": "6.2.10", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework:spring-jcl", "moduleUrl": "https://github.com/spring-projects/spring-framework", - "moduleVersion": "6.2.9", + "moduleVersion": "6.2.10", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework:spring-jdbc", "moduleUrl": "https://github.com/spring-projects/spring-framework", - "moduleVersion": "6.2.9", + "moduleVersion": "6.2.10", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework:spring-orm", "moduleUrl": "https://github.com/spring-projects/spring-framework", - "moduleVersion": "6.2.9", + "moduleVersion": "6.2.10", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework:spring-tx", "moduleUrl": "https://github.com/spring-projects/spring-framework", - "moduleVersion": "6.2.9", + "moduleVersion": "6.2.10", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework:spring-web", "moduleUrl": "https://github.com/spring-projects/spring-framework", - "moduleVersion": "6.2.9", + "moduleVersion": "6.2.10", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "org.springframework:spring-webmvc", "moduleUrl": "https://github.com/spring-projects/spring-framework", - "moduleVersion": "6.2.9", + "moduleVersion": "6.2.10", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, @@ -1821,7 +1822,7 @@ { "moduleName": "org.webjars:swagger-ui", "moduleUrl": "https://www.webjars.org", - "moduleVersion": "5.21.0", + "moduleVersion": "5.27.1", "moduleLicense": "Apache-2.0" }, { From 4cd1de410148676ee1b26a52000b465342f6c144 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eray=20T=C3=BCrkay?= <142736624+turkaysoftware@users.noreply.github.com> Date: Sun, 24 Aug 2025 23:31:40 +0300 Subject: [PATCH 31/41] Update messages_tr_TR.properties (#4274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translated 100% specifically into Turkish. No machine translation or artificial intelligence was used. All files have been correctly translated into Turkish together with the file providers. Best regards. - Türkay Software https://www.turkaysoftware.com # Description of Changes --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [x] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [x] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [x] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../main/resources/messages_tr_TR.properties | 646 +++++++++--------- 1 file changed, 323 insertions(+), 323 deletions(-) diff --git a/app/core/src/main/resources/messages_tr_TR.properties b/app/core/src/main/resources/messages_tr_TR.properties index 155b4365d..ca084cc0d 100644 --- a/app/core/src/main/resources/messages_tr_TR.properties +++ b/app/core/src/main/resources/messages_tr_TR.properties @@ -1,138 +1,138 @@ ########### # Generic # ########### -# the direction that the language is written (ltr = left to right, rtl = right to left) +# the direction that the language is written (ltr=left to right, rtl=right to left) language.direction=ltr # Language names for reuse throughout the application -lang.afr=Afrikaans -lang.amh=Amharic -lang.ara=Arabic -lang.asm=Assamese -lang.aze=Azerbaijani -lang.aze_cyrl=Azerbaijani (Cyrillic) -lang.bel=Belarusian -lang.ben=Bengali -lang.bod=Tibetan -lang.bos=Bosnian -lang.bre=Breton -lang.bul=Bulgarian -lang.cat=Catalan -lang.ceb=Cebuano -lang.ces=Czech -lang.chi_sim=Chinese (Simplified) -lang.chi_sim_vert=Chinese (Simplified, Vertical) -lang.chi_tra=Chinese (Traditional) -lang.chi_tra_vert=Chinese (Traditional, Vertical) -lang.chr=Cherokee -lang.cos=Corsican -lang.cym=Welsh -lang.dan=Danish -lang.dan_frak=Danish (Fraktur) -lang.deu=German -lang.deu_frak=German (Fraktur) -lang.div=Divehi -lang.dzo=Dzongkha -lang.ell=Greek -lang.eng=English -lang.enm=English, Middle (1100-1500) -lang.epo=Esperanto -lang.equ=Math / equation detection module -lang.est=Estonian -lang.eus=Basque -lang.fao=Faroese -lang.fas=Persian -lang.fil=Filipino -lang.fin=Finnish -lang.fra=French -lang.frk=Frankish -lang.frm=French, Middle (ca.1400-1600) -lang.fry=Western Frisian -lang.gla=Scottish Gaelic -lang.gle=Irish -lang.glg=Galician -lang.grc=Ancient Greek -lang.guj=Gujarati -lang.hat=Haitian, Haitian Creole -lang.heb=Hebrew -lang.hin=Hindi -lang.hrv=Croatian -lang.hun=Hungarian -lang.hye=Armenian -lang.iku=Inuktitut -lang.ind=Indonesian -lang.isl=Icelandic -lang.ita=Italian -lang.ita_old=Italian (Old) -lang.jav=Javanese -lang.jpn=Japanese -lang.jpn_vert=Japanese (Vertical) -lang.kan=Kannada -lang.kat=Georgian -lang.kat_old=Georgian (Old) -lang.kaz=Kazakh -lang.khm=Central Khmer -lang.kir=Kirghiz, Kyrgyz -lang.kmr=Northern Kurdish -lang.kor=Korean -lang.kor_vert=Korean (Vertical) -lang.lao=Lao -lang.lat=Latin -lang.lav=Latvian -lang.lit=Lithuanian -lang.ltz=Luxembourgish -lang.mal=Malayalam -lang.mar=Marathi -lang.mkd=Macedonian -lang.mlt=Maltese -lang.mon=Mongolian -lang.mri=Maori -lang.msa=Malay -lang.mya=Burmese -lang.nep=Nepali -lang.nld=Dutch; Flemish -lang.nor=Norwegian -lang.oci=Occitan (post 1500) -lang.ori=Oriya -lang.osd=Orientation and script detection module -lang.pan=Panjabi, Punjabi -lang.pol=Polish -lang.por=Portuguese -lang.pus=Pushto, Pashto -lang.que=Quechua -lang.ron=Romanian, Moldavian, Moldovan -lang.rus=Russian -lang.san=Sanskrit -lang.sin=Sinhala, Sinhalese -lang.slk=Slovak -lang.slk_frak=Slovak (Fraktur) -lang.slv=Slovenian -lang.snd=Sindhi -lang.spa=Spanish -lang.spa_old=Spanish (Old) -lang.sqi=Albanian -lang.srp=Serbian -lang.srp_latn=Serbian (Latin) -lang.sun=Sundanese -lang.swa=Swahili -lang.swe=Swedish -lang.syr=Syriac -lang.tam=Tamil -lang.tat=Tatar -lang.tel=Telugu -lang.tgk=Tajik -lang.tgl=Tagalog -lang.tha=Thai -lang.tir=Tigrinya -lang.ton=Tonga (Tonga Islands) -lang.tur=Turkish -lang.uig=Uighur, Uyghur -lang.ukr=Ukrainian -lang.urd=Urdu -lang.uzb=Uzbek -lang.uzb_cyrl=Uzbek (Cyrillic) -lang.vie=Vietnamese -lang.yid=Yiddish +lang.afr=Afrikaanca +lang.amh=Amharca +lang.ara=Arapça +lang.asm=Assamca +lang.aze=Azerice +lang.aze_cyrl=Azerice (Kiril) +lang.bel=Beyaz Rusça (Belarusça) +lang.ben=Bengalce +lang.bod=Tibetçe +lang.bos=Boşnakça +lang.bre=Bretonca +lang.bul=Bulgarca +lang.cat=Katalanca +lang.ceb=Cebuano +lang.ces=Çekçe +lang.chi_sim=Çince (Basitleştirilmiş) +lang.chi_sim_vert=Çince (Basitleştirilmiş, Dikey) +lang.chi_tra=Çince (Geleneksel) +lang.chi_tra_vert=Çince (Geleneksel, Dikey) +lang.chr=Çerokice +lang.cos=Korsikaca +lang.cym=Gallerce (Galce) +lang.dan=Danca +lang.dan_frak=Danca (Fraktur) +lang.deu=Almanca +lang.deu_frak=Almanca (Fraktur) +lang.div=Maldivce (Divehi) +lang.dzo=Dzongkha +lang.ell=Yunanca +lang.eng=İngilizce +lang.enm=İngilizce, Orta Çağ (1100-1500) +lang.epo=Esperanto +lang.equ=Matematik / denklem tanıma modülü +lang.est=Estonca +lang.eus=Baskça +lang.fao=Faroece +lang.fas=Farsça +lang.fil=Filipince +lang.fin=Fince +lang.fra=Fransızca +lang.frk=Frankça +lang.frm=Fransızca, Orta Çağ (yaklaşık 1400-1600) +lang.fry=Batı Frizce +lang.gla=İskoç Galcesi +lang.gle=İrlandaca +lang.glg=Galiçyaca +lang.grc=Antik Yunanca +lang.guj=Gujaratça +lang.hat=Haiti Creole +lang.heb=İbranice +lang.hin=Hintçe +lang.hrv=Hırvatça +lang.hun=Macarca +lang.hye=Ermenice +lang.iku=İnuktitut +lang.ind=Endonezce +lang.isl=İzlandaca +lang.ita=İtalyanca +lang.ita_old=İtalyanca (Eski) +lang.jav=Cava dili +lang.jpn=Japonca +lang.jpn_vert=Japonca (Dikey) +lang.kan=Kannada +lang.kat=Gürcüce +lang.kat_old=Gürcüce (Eski) +lang.kaz=Kazakça +lang.khm=Merkez Khmer dili +lang.kir=Kırgızca +lang.kmr=Kuzey Kürtçesi +lang.kor=Korece +lang.kor_vert=Korece (Dikey) +lang.lao=Laosça +lang.lat=Latince +lang.lav=Letonca +lang.lit=Litvanca +lang.ltz=Lüksemburgca +lang.mal=Malayalamca +lang.mar=Marathi +lang.mkd=Makedonca +lang.mlt=Maltaca +lang.mon=Moğolca +lang.mri=Maorice +lang.msa=Malayca +lang.mya=Birmanca (Burma) +lang.nep=Nepalce +lang.nld=Hollandaca; Flamanca +lang.nor=Norveççe +lang.oci=Oksitanca (1500 sonrası) +lang.ori=Oriya +lang.osd=Yönlendirme ve yazı tipi algılama modülü +lang.pan=Pencapça +lang.pol=Lehçe (Polonyaca) +lang.por=Portekizce +lang.pus=Peştuca +lang.que=Keçuva dili +lang.ron=Rumence, Moldovca +lang.rus=Rusça +lang.san=Sanskritçe +lang.sin=Seylanca (Sinhala) +lang.slk=Slovakça +lang.slk_frak=Slovakça (Fraktur) +lang.slv=Slovence +lang.snd=Sindhice +lang.spa=İspanyolca +lang.spa_old=İspanyolca (Eski) +lang.sqi=Arnavutça +lang.srp=Sırpça +lang.srp_latn=Sırpça (Latin alfabesiyle) +lang.sun=Sundaca +lang.swa=Svahili dili +lang.swe=İsveççe +lang.syr=Süryanice +lang.tam=Tamilce +lang.tat=Tatarca +lang.tel=Telugu +lang.tgk=Tacikçe +lang.tgl=Tagalog +lang.tha=Tayca +lang.tir=Tigrinya +lang.ton=Tonga dili (Tonga Adaları) +lang.tur=Türkçe +lang.uig=Uygurca +lang.ukr=Ukraynaca +lang.urd=Urduca +lang.uzb=Özbekçe +lang.uzb_cyrl=Özbekçe (Kiril) +lang.vie=Vietnamca +lang.yid=Yidiş lang.yor=Yoruba addPageNumbers.fontSize=Font Büyüklüğü @@ -146,8 +146,8 @@ uploadLimit=Maksimum dosya boyutu: uploadLimitExceededSingular=çok büyük. İzin verilen maksimum boyut: uploadLimitExceededPlural=çok büyük. İzin verilen maksimum boyut: processTimeWarning=Uyarı: Bu işlem, dosya boyutuna bağlı olarak bir dakikaya kadar sürebilir. -pageOrderPrompt=Özel Sayfa Sırası (Virgülle ayrılmış sayfa numaraları veya 2n+1 gibi bir fonksiyon girin) : -pageSelectionPrompt=Özel Sayfa Seçimi (1,5,6 sayfa numaralarının virgülle ayrılmış bir listesini veya 2n+1 gibi bir fonksiyon girin) : +pageOrderPrompt=Özel Sayfa Sırası (Virgülle ayrılmış sayfa numaraları veya 2n+1 gibi bir fonksiyon girin): +pageSelectionPrompt=Özel Sayfa Seçimi (1,5,6 sayfa numaralarının virgülle ayrılmış bir listesini veya 2n+1 gibi bir fonksiyon girin): goToPage=Sayfaya Git true=Doğru false=Yanlış @@ -170,67 +170,67 @@ sizes.medium=Orta sizes.large=Büyük sizes.x-large=Çok Büyük error.pdfPassword=PDF belgesi şifreli ve şifre ya sağlanmadı ya da yanlış. -error.pdfCorrupted=PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation. -error.pdfCorruptedMultiple=One or more PDF files appear to be corrupted or damaged. Please try using the 'Repair PDF' feature on each file first before attempting to merge them. -error.pdfCorruptedDuring=Error {0}: PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation. +error.pdfCorrupted=PDF dosyası bozuk veya hasarlı görünüyor. Lütfen bu işlemi gerçekleştirmeden önce dosyayı düzeltmek için 'PDF Onar' özelliğini kullanmayı deneyin. +error.pdfCorruptedMultiple=Bir veya daha fazla PDF dosyası bozuk veya hasarlı görünüyor. Lütfen birleştirmeye çalışmadan önce her dosya için 'PDF Onar' özelliğini kullanmayı deneyin. +error.pdfCorruptedDuring=Hata {0}: PDF dosyası bozuk veya hasarlı görünüyor. Lütfen bu işlemi gerçekleştirmeden önce dosyayı düzeltmek için 'PDF Onar' özelliğini kullanmayı deneyin. # Frontend corruption error messages -error.pdfInvalid=The PDF file "{0}" appears to be corrupted or has an invalid structure. Please try using the 'Repair PDF' feature to fix the file before proceeding. -error.tryRepair=Try using the Repair PDF feature to fix corrupted files. +error.pdfInvalid="{0}" adlı PDF dosyası bozuk görünüyor veya geçersiz bir yapıya sahip. Lütfen işlemi gerçekleştirmeden önce dosyayı düzeltmek için 'PDF Onar' özelliğini kullanmayı deneyin. +error.tryRepair=Bozuk dosyaları düzeltmek için PDF Onar özelliğini kullanmayı deneyin. # Additional error messages -error.pdfEncryption=The PDF appears to have corrupted encryption data. This can happen when the PDF was created with incompatible encryption methods. Please try using the 'Repair PDF' feature first, or contact the document creator for a new copy. -error.fileProcessing=An error occurred while processing the file during {0} operation: {1} +error.pdfEncryption=PDF dosyasının şifreleme verileri bozulmuş görünüyor. Bu, PDF uyumsuz şifreleme yöntemleriyle oluşturulduğunda meydana gelebilir. Lütfen önce 'PDF Onar' özelliğini kullanmayı deneyin veya belgenin oluşturucusuyla iletişime geçerek yeni bir kopya isteyin. +error.fileProcessing={0} işlemi sırasında dosya işlenirken bir hata oluştu: {1} # Generic error message templates -error.toolNotInstalled={0} is not installed -error.toolRequired={0} is required for {1} -error.conversionFailed={0} conversion failed -error.commandFailed={0} command failed -error.algorithmNotAvailable={0} algorithm not available -error.optionsNotSpecified={0} options are not specified -error.fileFormatRequired=File must be in {0} format -error.invalidFormat=Invalid {0} format: {1} -error.endpointDisabled=This endpoint has been disabled by the admin -error.urlNotReachable=URL is not reachable, please provide a valid URL +error.toolNotInstalled={0} yüklü değil +error.toolRequired={1} işlemi için {0} gereklidir +error.conversionFailed={0} dönüştürme işlemi başarısız oldu +error.commandFailed={0} komutu başarısız oldu +error.algorithmNotAvailable={0} algoritması kullanılamıyor +error.optionsNotSpecified={0} seçenekleri belirtilmemiş +error.fileFormatRequired=Dosya {0} formatında olmalıdır +error.invalidFormat=Geçersiz {0} formatı: {1} +error.endpointDisabled=Bu uç nokta yönetici tarafından devre dışı bırakılmıştır +error.urlNotReachable=URL erişilebilir değil, lütfen geçerli bir URL sağlayın # DPI and image rendering messages - used by frontend for dynamic translation # Backend sends: [TRANSLATE:messageKey:arg1,arg2] English message # Frontend parses this and replaces with localized versions using these keys -error.dpiExceedsLimit=DPI value {0} exceeds maximum safe limit of {1}. High DPI values can cause memory issues and crashes. Please use a lower DPI value. -error.pageTooBigForDpi=PDF page {0} is too large to render at {1} DPI. Please try a lower DPI value (recommended: 150 or less). -error.pageTooBigExceedsArray=PDF page {0} is too large to render at {1} DPI. The resulting image would exceed Java's maximum array size. Please try a lower DPI value (recommended: 150 or less). -error.pageTooBigFor300Dpi=PDF page {0} is too large to render at 300 DPI. The resulting image would exceed Java's maximum array size. Please use a lower DPI value for PDF-to-image conversion. +error.dpiExceedsLimit=DPI değeri {0}, maksimum güvenli sınır olan {1} değerini aşıyor. Yüksek DPI değerleri bellek sorunlarına ve çökme hatalarına neden olabilir. Lütfen daha düşük bir DPI değeri kullanın. +error.pageTooBigForDpi=PDF sayfası {0}, {1} DPI değerinde işlenemeyecek kadar büyük. Lütfen daha düşük bir DPI değeri deneyin (önerilen: 150 veya daha az). +error.pageTooBigExceedsArray=PDF sayfası {0}, {1} DPI değerinde işlenemeyecek kadar büyük. Ortaya çıkan görüntü Java'nın maksimum dizi boyutunu aşacaktır. Lütfen daha düşük bir DPI değeri deneyin (önerilen: 150 veya daha az). +error.pageTooBigFor300Dpi=PDF sayfası {0}, 300 DPI değerinde işlenemeyecek kadar büyük. Ortaya çıkan görüntü Java'nın maksimum dizi boyutunu aşacaktır. Lütfen PDF'den görüntüye dönüştürme işlemi için daha düşük bir DPI değeri kullanın. # URL and website conversion messages # System requirements messages # Authentication and security messages -error.apiKeyInvalid=API key is not valid. -error.userNotFound=User not found. -error.passwordRequired=Password must not be null. -error.accountLocked=Your account has been locked due to too many failed login attempts. -error.invalidEmail=Invalid email addresses provided. -error.emailAttachmentRequired=An attachment is required to send the email. -error.signatureNotFound=Signature file not found. +error.apiKeyInvalid=API anahtarı geçerli değil. +error.userNotFound=Kullanıcı bulunamadı. +error.passwordRequired=Parola boş bırakılamaz. +error.accountLocked=Çok fazla başarısız giriş denemesi nedeniyle hesabınız kilitlendi. +error.invalidEmail=Geçersiz e-posta adresleri sağlandı. +error.emailAttachmentRequired=E-posta gönderebilmek için bir ek dosya gereklidir. +error.signatureNotFound=İmza dosyası bulunamadı. # File processing messages -error.fileNotFound=File not found with ID: {0} +error.fileNotFound=Dosya bulunamadı. Dosya kimliği: {0} # Database and configuration messages -error.noBackupScripts=No backup scripts were found. -error.unsupportedProvider={0} is not currently supported. -error.pathTraversalDetected=Path traversal detected for security reasons. +error.noBackupScripts=Yedekleme betikleri bulunamadı. +error.unsupportedProvider={0} şu anda desteklenmiyor. +error.pathTraversalDetected=Güvenlik nedeniyle yol geçişi (path traversal) tespit edildi. # Validation messages -error.invalidArgument=Invalid argument: {0} -error.argumentRequired={0} must not be null -error.operationFailed=Operation failed: {0} -error.angleNotMultipleOf90=Angle must be a multiple of 90 -error.pdfBookmarksNotFound=No PDF bookmarks/outline found in document -error.fontLoadingFailed=Error processing font file -error.fontDirectoryReadFailed=Failed to read font directory +error.invalidArgument=Geçersiz argüman: {0} +error.argumentRequired={0} boş olamaz +error.operationFailed=İşlem başarısız oldu: {0} +error.angleNotMultipleOf90=Açı 90'ın katı olmalıdır +error.pdfBookmarksNotFound=Belgede herhangi bir PDF yer imi / içindekiler bulunamadı +error.fontLoadingFailed=Yazı tipi dosyası işlenirken hata oluştu +error.fontDirectoryReadFailed=Yazı tipi dizini okunamadı delete=Sil username=Kullanıcı Adı password=Parola @@ -260,7 +260,7 @@ disabledCurrentUserMessage=Mevcut kullanıcı devre dışı bırakılamaz downgradeCurrentUserLongMessage=Mevcut kullanıcının rolü düşürülemiyor. Bu nedenle, mevcut kullanıcı gösterilmeyecektir. userAlreadyExistsOAuthMessage=Kullanıcı zaten bir OAuth2 kullanıcısı olarak mevcut. userAlreadyExistsWebMessage=Kullanıcı zaten bir web kullanıcısı olarak mevcut. -invalidRoleMessage=Invalid role. +invalidRoleMessage=Geçersiz rol. error=Hata oops=Tüh! help=Yardım @@ -273,7 +273,7 @@ color=Renk sponsor=Bağış info=Bilgi pro=Pro -proFeatures=Pro Features +proFeatures=Pro Özellikler page=Sayfa pages=Sayfalar loading=Yükleniyor... @@ -281,11 +281,11 @@ addToDoc=Dökümana Ekle reset=Sıfırla apply=Uygula noFileSelected=Hiçbir dosya seçilmedi. Lütfen bir dosya yükleyin. -view=View +view=Görüntüle cancel=İptal -back.toSettings=Ayarlar’a Geri Dön -back.toHome=Ana Sayfa’ya Geri Dön +back.toSettings=Ayarlar'a Geri Dön +back.toHome=Ana Sayfa'ya Geri Dön back.toAdmin=Yönetim Paneline Geri Dön legal.privacy=Gizlilik Politikası @@ -327,15 +327,15 @@ enterpriseEdition.button=Pro Sürümüne Yükselt enterpriseEdition.warning=Bu özellik yalnızca Pro kullanıcılarına sunulmaktadır. enterpriseEdition.yamlAdvert=Stirling PDF Pro, YAML yapılandırma dosyalarını ve diğer SSO özelliklerini destekler. enterpriseEdition.ssoAdvert=Daha fazla kullanıcı yönetimi özelliği mi arıyorsunuz? Stirling PDF Pro'ya göz atın -enterpriseEdition.proTeamFeatureDisabled=Team management features require a Pro licence or higher +enterpriseEdition.proTeamFeatureDisabled=Takım yönetimi özellikleri Pro lisansı veya daha üstü gerektirir ################# # Analytics # ################# -analytics.title=Stirling PDF’i daha iyi hale getirmek ister misiniz? +analytics.title=Stirling PDF'i daha iyi hale getirmek ister misiniz? analytics.paragraph1=Stirling PDF, ürünü geliştirmemize yardımcı olmak için isteğe bağlı analizleri içerir. Kişisel bilgileri veya dosya içeriklerini asla takip etmiyoruz. -analytics.paragraph2=Stirling PDF’in büyümesine destek olmak ve kullanıcılarımızı daha iyi anlayabilmemiz için analizleri etkinleştirmeyi düşünebilirsiniz. +analytics.paragraph2=Stirling PDF'in büyümesine destek olmak ve kullanıcılarımızı daha iyi anlayabilmemiz için analizleri etkinleştirmeyi düşünebilirsiniz. analytics.enable=Analizi Etkinleştir analytics.disable=Analizi Devre Dışı Bırak analytics.settings=Analiz ayarlarını config/settings.yml dosyasından değiştirebilirsiniz @@ -345,7 +345,7 @@ analytics.settings=Analiz ayarlarını config/settings.yml dosyasından değişt # NAVBAR # ############# navbar.favorite=Favoriler -navbar.recent=New and recently updated +navbar.recent=Yeni ve son güncellenenler navbar.darkmode=Karanlık Mod navbar.language=Diller navbar.settings=Ayarlar @@ -368,36 +368,36 @@ settings.update=Güncelleme mevcut settings.updateAvailable={0} mevcut kurulu sürümdür. Yeni bir sürüm ({1}) mevcuttur. # Update modal and notification strings -update.urgentUpdateAvailable=🚨 Update Available -update.updateAvailable=Update Available -update.modalTitle=Update Available -update.current=Current -update.latest=Latest -update.latestStable=Latest Stable -update.priority=Priority -update.recommendedAction=Recommended Action -update.breakingChangesDetected=⚠️ Breaking Changes Detected -update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below. -update.migrationGuides=Migration Guides: -update.viewGuide=View Guide -update.loadingDetailedInfo=Loading detailed version information... -update.close=Close -update.viewAllReleases=View All Releases -update.downloadLatest=Download Latest -update.availableUpdates=Available Updates: -update.unableToLoadDetails=Unable to load detailed version information. -update.version=Version +update.urgentUpdateAvailable=🚨 Güncelleme Mevcut +update.updateAvailable=Güncelleme Mevcut +update.modalTitle=Güncelleme Mevcut +update.current=Mevcut +update.latest=En Yeni +update.latestStable=En Yeni Kararlı +update.priority=Öncelik +update.recommendedAction=Önerilen İşlem +update.breakingChangesDetected=⚠️ Kırıcı Değişiklikler Tespit Edildi +update.breakingChangesMessage=Bu güncelleme kırıcı değişiklikler içeriyor. Lütfen aşağıdaki geçiş kılavuzlarını inceleyin. +update.migrationGuides=Geçiş Kılavuzları: +update.viewGuide=Kılavuzu Görüntüle +update.loadingDetailedInfo=Ayrıntılı sürüm bilgileri yükleniyor... +update.close=Kapat +update.viewAllReleases=Tüm Sürümleri Görüntüle +update.downloadLatest=En Yeniyi İndir +update.availableUpdates=Mevcut Güncellemeler: +update.unableToLoadDetails=Ayrıntılı sürüm bilgileri yüklenemedi. +update.version=Sürüm # Update priority levels -update.priority.urgent=URGENT +update.priority.urgent=ACİL update.priority.normal=NORMAL -update.priority.minor=MINOR -update.priority.low=LOW +update.priority.minor=ÖNEMSİZ +update.priority.low=DÜŞÜK # Breaking changes text -update.breakingChanges=Breaking Changes: -update.breakingChangesDefault=This version contains breaking changes -update.migrationGuide=Migration Guide +update.breakingChanges=Kırıcı Değişiklikler: +update.breakingChangesDefault=Bu sürüm kırıcı değişiklikler içeriyor +update.migrationGuide=Geçiş Kılavuzu settings.appVersion=Uygulama Sürümü: settings.downloadOption.title=İndirme seçeneği seçin (Zip olmayan tek dosya indirmeler için): settings.downloadOption.1=Aynı pencerede aç @@ -472,18 +472,18 @@ adminUserSettings.disabledUsers=Devre Dışı Kullanıcılar: adminUserSettings.totalUsers=Toplam Kullanıcılar: adminUserSettings.lastRequest=Son İstek adminUserSettings.usage=Kullanımı Görüntüle -adminUserSettings.teams=View/Edit Teams -adminUserSettings.team=Team -adminUserSettings.manageTeams=Manage Teams -adminUserSettings.createTeam=Create Team -adminUserSettings.viewTeam=View Team -adminUserSettings.deleteTeam=Delete Team -adminUserSettings.teamName=Team Name -adminUserSettings.teamExists=Team already exists -adminUserSettings.teamCreated=Team created successfully -adminUserSettings.teamChanged=User's team was updated -adminUserSettings.teamHidden=Hidden -adminUserSettings.totalMembers=Total Members +adminUserSettings.teams=Takımları Görüntüle/Düzenle +adminUserSettings.team=Takım +adminUserSettings.manageTeams=Takımları Yönet +adminUserSettings.createTeam=Takım Oluştur +adminUserSettings.viewTeam=Takımı Görüntüle +adminUserSettings.deleteTeam=Takımı Sil +adminUserSettings.teamName=Takım Adı +adminUserSettings.teamExists=Takım zaten mevcut +adminUserSettings.teamCreated=Takım başarıyla oluşturuldu +adminUserSettings.teamChanged=Kullanıcının takımı güncellendi +adminUserSettings.teamHidden=Gizli +adminUserSettings.totalMembers=Toplam Üye adminUserSettings.confirmDeleteTeam=Bu takımı silmek istediğinizden emin misiniz? teamCreated=Takım başarıyla oluşturuldu @@ -538,7 +538,7 @@ endpointStatistics.home=Ana Sayfa endpointStatistics.login=Giriş endpointStatistics.top=En Çok endpointStatistics.numberOfVisits=Ziyaret Sayısı -endpointStatistics.visitsTooltip=Ziyaret: {0} (toplamın %{1}’i) +endpointStatistics.visitsTooltip=Ziyaret: {0} (toplamın %{1}'i) endpointStatistics.retry=Yeniden Dene database.title=Veri Tabanını İçe/Dışa Aktar @@ -617,9 +617,9 @@ home.addImage.title=Resim Ekle home.addImage.desc=PDF'e belirli bir konuma resim ekler addImage.tags=img,jpg,fotoğraf,resim -home.attachments.title=Add Attachments -home.attachments.desc=Add or remove embedded files (attachments) to/from a PDF -attachments.tags=embed,attach,file,attachment,attachments +home.attachments.title=Ekleri Ekle +home.attachments.desc=PDF'ye gömülü dosyalar (ekler) ekle veya kaldır +attachments.tags=gömme,ekle,dosya,ek,ekler home.watermark.title=Filigran Ekle home.watermark.desc=PDF belgenize özel bir filigran ekleyin. @@ -772,21 +772,21 @@ home.HTMLToPDF.desc=Herhangi bir HTML dosyasını veya zip'i PDF'e dönüştür HTMLToPDF.tags=biçimlendirme,web-içeriği,dönüşüm,dönüştür #eml-to-pdf -home.EMLToPDF.title=Email to PDF -home.EMLToPDF.desc=Converts email (EML) files to PDF format including headers, body, and inline images -EMLToPDF.tags=email,conversion,eml,message,transformation,convert,mail +home.EMLToPDF.title=E-postayı PDF'ye Dönüştür +home.EMLToPDF.desc=Başlıklar, gövde ve satır içi resimler dahil olmak üzere e-posta (EML) dosyalarını PDF formatına dönüştürür +EMLToPDF.tags=e-posta, dönüşüm, eml, mesaj, dönüşüm, dönüştür, posta -EMLToPDF.title=Email To PDF -EMLToPDF.header=Email To PDF -EMLToPDF.submit=Convert -EMLToPDF.downloadHtml=Download HTML intermediate file instead of PDF -EMLToPDF.downloadHtmlHelp=This allows you to see the HTML version before PDF conversion and can help debug formatting issues -EMLToPDF.includeAttachments=Include attachments in PDF -EMLToPDF.maxAttachmentSize=Maximum attachment size (MB) -EMLToPDF.help=Converts email (EML) files to PDF format including headers, body, and inline images -EMLToPDF.troubleshootingTip1=Email to HTML is a more reliable process, so with batch-processing it is recommended to save both -EMLToPDF.troubleshootingTip2=With a small number of Emails, if the PDF is malformed, you can download HTML and override some of the problematic HTML/CSS code. -EMLToPDF.troubleshootingTip3=Embeddings, however, do not work with HTMLs +EMLToPDF.title=E-postayı PDF'ye Dönüştür +EMLToPDF.header=E-postayı PDF'ye Dönüştür +EMLToPDF.submit=Dönüştür +EMLToPDF.downloadHtml=PDF yerine HTML ara dosyasını indir +EMLToPDF.downloadHtmlHelp=Bu, PDF dönüşümünden önce HTML sürümünü görmenizi sağlar ve biçimlendirme sorunlarını çözmeye yardımcı olabilir +EMLToPDF.includeAttachments=PDF'ye ekleri dahil et +EMLToPDF.maxAttachmentSize=Maksimum ek boyutu (MB) +EMLToPDF.help=Başlıklar, gövde ve satır içi resimler dahil olmak üzere e-posta (EML) dosyalarını PDF formatına dönüştürür +EMLToPDF.troubleshootingTip1=E-postayı HTML'ye dönüştürmek daha güvenilir bir işlemdir, bu nedenle toplu işleme yaparken her ikisini de kaydetmek önerilir +EMLToPDF.troubleshootingTip2=Az sayıda e-posta için, PDF bozuksa HTML dosyasını indirip bazı sorunlu HTML/CSS kodlarını değiştirebilirsiniz +EMLToPDF.troubleshootingTip3=Ancak, gömülü içerikler HTML ile çalışmaz home.MarkdownToPDF.title=Markdown'dan PDF'e home.MarkdownToPDF.desc=Herhangi bir Markdown dosyasını PDF'e dönüştürür @@ -907,8 +907,8 @@ login.userIsDisabled=Kullanıcı devre dışı bırakıldı, şu anda bu kullan login.alreadyLoggedIn=Zaten şu cihazlarda oturum açılmış: login.alreadyLoggedIn2=Lütfen bu cihazlardan çıkış yaparak tekrar deneyin. login.toManySessions=Çok fazla aktif oturumunuz var -login.logoutMessage=You have been logged out. -login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. +login.logoutMessage=Oturumunuz kapatıldı. +login.invalidInResponseTo=İstenen SAML yanıtı geçersiz veya süresi dolmuş. Lütfen yöneticiyle iletişime geçin. #auto-redact autoRedact.title=Otomatik Karartma @@ -974,28 +974,28 @@ getPdfInfo.title=PDF Hakkında Bilgi Al getPdfInfo.header=PDF Hakkında Bilgi Al getPdfInfo.submit=Bilgi Al getPdfInfo.downloadJson=JSON İndir -getPdfInfo.summary=PDF Summary -getPdfInfo.summary.encrypted=This PDF is encrypted so may face issues with some applications -getPdfInfo.summary.permissions=This PDF has {0} restricted permissions which may limit what you can do with it -getPdfInfo.summary.compliance=This PDF complies with the {0} standard -getPdfInfo.summary.basicInfo=Basic Information -getPdfInfo.summary.docInfo=Document Information -getPdfInfo.summary.encrypted.alert=Encrypted PDF - This document is password protected -getPdfInfo.summary.not.encrypted.alert=Unencrypted PDF - No password protection -getPdfInfo.summary.permissions.alert=Restricted Permissions - {0} actions are not allowed -getPdfInfo.summary.all.permissions.alert=All Permissions Allowed -getPdfInfo.summary.compliance.alert={0} Compliant -getPdfInfo.summary.no.compliance.alert=No Compliance Standards -getPdfInfo.summary.security.section=Security Status -getPdfInfo.section.BasicInfo=Basic Information about the PDF document including file size, page count, and language -getPdfInfo.section.Metadata=Document metadata including title, author, creation date and other document properties -getPdfInfo.section.DocumentInfo=Technical details about the PDF document structure and version -getPdfInfo.section.Compliancy=PDF standards compliance information (PDF/A, PDF/X, etc.) -getPdfInfo.section.Encryption=Security and encryption details of the document -getPdfInfo.section.Permissions=Document permission settings that control what actions can be performed -getPdfInfo.section.Other=Additional document components like bookmarks, layers, and embedded files -getPdfInfo.section.FormFields=Interactive form fields present in the document -getPdfInfo.section.PerPageInfo=Detailed information about each page in the document +getPdfInfo.summary=PDF Özeti +getPdfInfo.summary.encrypted=Bu PDF şifreli olduğu için bazı uygulamalarda sorun yaşanabilir +getPdfInfo.summary.permissions=Bu PDF'de {0} kısıtlanmış izinler var, bu da yapabileceklerinizi sınırlayabilir +getPdfInfo.summary.compliance=Bu PDF {0} standardına uygundur +getPdfInfo.summary.basicInfo=Temel Bilgiler +getPdfInfo.summary.docInfo=Belge Bilgileri +getPdfInfo.summary.encrypted.alert=Şifreli PDF - Bu belge parola korumalıdır +getPdfInfo.summary.not.encrypted.alert=Şifresiz PDF - Parola koruması yok +getPdfInfo.summary.permissions.alert=Kısıtlanmış İzinler - {0} işlem izin verilmemiştir +getPdfInfo.summary.all.permissions.alert=Tüm İzinler Verildi +getPdfInfo.summary.compliance.alert={0} Uygun +getPdfInfo.summary.no.compliance.alert=Uygunluk Standardı Yok +getPdfInfo.summary.security.section=Güvenlik Durumu +getPdfInfo.section.BasicInfo=PDF belgesinin dosya boyutu, sayfa sayısı ve dili dahil temel bilgileri +getPdfInfo.section.Metadata=Başlık, yazar, oluşturulma tarihi ve diğer belge özelliklerini içeren belge meta verisi +getPdfInfo.section.DocumentInfo=PDF belge yapısı ve sürümü hakkında teknik detaylar +getPdfInfo.section.Compliancy=PDF standartlarına uygunluk bilgisi (PDF/A, PDF/X, vb.) +getPdfInfo.section.Encryption=Belgenin güvenlik ve şifreleme detayları +getPdfInfo.section.Permissions=Hangi işlemlerin yapılabileceğini kontrol eden belge izin ayarları +getPdfInfo.section.Other=Yer imleri, katmanlar ve gömülü dosyalar gibi ek belge bileşenleri +getPdfInfo.section.FormFields=Belgede bulunan etkileşimli form alanları +getPdfInfo.section.PerPageInfo=Belgedeki her sayfa hakkında ayrıntılı bilgiler #markdown-to-pdf @@ -1007,9 +1007,9 @@ MarkdownToPDF.credit=WeasyPrint Kullanıyor #pdf-to-markdown -PDFToMarkdown.title=PDF To Markdown -PDFToMarkdown.header=PDF To Markdown -PDFToMarkdown.submit=Convert +PDFToMarkdown.title=PDF'den Markdown'a +PDFToMarkdown.header=PDF'den Markdown'a +PDFToMarkdown.submit=Dönüştür #url-to-pdf @@ -1136,7 +1136,7 @@ pageLayout.submit=Gönder scalePages.title=Sayfa Ölçeğini Ayarla scalePages.header=Sayfa Ölçeğini Ayarla scalePages.pageSize=Belgenin bir sayfa boyutu. -scalePages.keepPageSize=Original Size +scalePages.keepPageSize=Orijinal Boyut scalePages.scaleFactor=Bir sayfanın yakınlaştırma seviyesi (kırpma). scalePages.submit=Gönder @@ -1156,7 +1156,7 @@ certSign.showSig=İmzayı Göster certSign.reason=Neden certSign.location=Konum certSign.name=İsim -certSign.showLogo=Show Logo +certSign.showLogo=Logoyu Göster certSign.submit=PDF'i İmzala @@ -1171,7 +1171,7 @@ removeCertSign.submit=İmzayı Kaldır removeBlanks.title=Boşları Kaldır removeBlanks.header=Boş Sayfaları Kaldır removeBlanks.threshold=Pixel Beyazlık Eşiği: -removeBlanks.thresholdDesc=Bir beyaz pixelin 'Beyaz' olarak sınıflandırılması için ne kadar beyaz olması gerektiğini belirlemek için eşik. 0 = Siyah, 255 saf beyaz. +removeBlanks.thresholdDesc=Bir beyaz pixelin 'Beyaz' olarak sınıflandırılması için ne kadar beyaz olması gerektiğini belirlemek için eşik. 0=Siyah, 255 saf beyaz. removeBlanks.whitePercent=Beyaz Yüzde (%): removeBlanks.whitePercentDesc=Bir sayfanın 'beyaz' pixel olması gereken yüzdesi removeBlanks.submit=Boşları Kaldır @@ -1287,8 +1287,8 @@ compress.title=Sıkıştır compress.header=PDF'i Sıkıştır compress.credit=Bu hizmet PDF Sıkıştırma/Optimizasyonu için qpdf kullanır. compress.grayscale.label=Sıkıştırma için Gri Ton Uygula -compress.selectText.1=Compression Settings -compress.selectText.1.1=1-3 PDF compression,
4-6 lite image compression,
7-9 intense image compression Will dramatically reduce image quality +compress.selectText.1=Sıkıştırma Ayarları +compress.selectText.1.1=1-3 PDF sıkıştırma,
4-6 hafif görüntü sıkıştırma,
7-9 yoğun görüntü sıkıştırma Görüntü kalitesini ciddi şekilde düşürecektir compress.selectText.2=Optimizasyon seviyesi: compress.selectText.4=Otomatik mod - PDF'in tam boyutuna ulaşmak için kaliteyi otomatik ayarlar compress.selectText.5=Beklenen PDF Boyutu (örn. 25MB, 10.8MB, 25KB) @@ -1303,11 +1303,11 @@ addImage.upload=Resim ekle addImage.submit=Resim ekle #attachments -attachments.title=Add Attachments -attachments.header=Add attachments -attachments.description=Allows you to add attachments to the PDF -attachments.descriptionPlaceholder=Enter a description for the attachments... -attachments.addButton=Add Attachments +attachments.title=Ekler Ekle +attachments.header=Ekler Ekle +attachments.description=PDF'ye ekler eklemenizi sağlar +attachments.descriptionPlaceholder=Ekler için bir açıklama girin... +attachments.addButton=Ekleri Ekle #merge merge.title=Birleştir @@ -1315,7 +1315,7 @@ merge.header=Çoklu PDF'leri Birleştir (2+) merge.sortByName=İsme göre sırala merge.sortByDate=Tarihe göre sırala merge.removeCertSign=Birleştirilen dosyadaki dijital imza kaldırılsın mı? -merge.generateToc=Generate table of contents in the merged file? +merge.generateToc=Birleştirilen dosyada içindekiler tablosu oluşturulsun mu? merge.submit=Birleştir @@ -1435,7 +1435,7 @@ pdfToImage.colorType=Renk türü pdfToImage.color=Renk pdfToImage.grey=Gri tonlama pdfToImage.blackwhite=Siyah ve Beyaz (Veri kaybolabilir!) -pdfToImage.dpi=DPI (The server limit is {0} dpi) +pdfToImage.dpi=DPI (Sunucu limiti {0} dpi) pdfToImage.submit=Dönüştür pdfToImage.info=Python kurulu değil. WebP dönüşümü için gereklidir. pdfToImage.placeholder=(örneğin 1,2,8 veya 4,7,12-16 ya da 2n-1) @@ -1652,9 +1652,9 @@ survey.meeting.1=Eğer Stirling PDF'i iş yerinizde kullanıyorsanız, sizinle g survey.meeting.2=Bu fırsat sayesinde: survey.meeting.3=Kurulum, entegrasyonlar veya sorun giderme konularında yardım alabilirsiniz survey.meeting.4=Performans, uç durumlar ve eksik özellikler hakkında doğrudan geri bildirim sağlayabilirsiniz -survey.meeting.5=Stirling PDF’i gerçek dünya kurumsal kullanımı için daha iyi hale getirmemize yardımcı olabilirsiniz +survey.meeting.5=Stirling PDF'i gerçek dünya kurumsal kullanımı için daha iyi hale getirmemize yardımcı olabilirsiniz survey.meeting.6=İlgileniyorsanız, ekibimizden doğrudan zaman ayırabilirsiniz. (Yalnızca İngilizce) -survey.meeting.7=Kullanım senaryolarınızı dinlemeyi ve Stirling PDF’i daha da iyi hale getirmeyi sabırsızlıkla bekliyoruz! +survey.meeting.7=Kullanım senaryolarınızı dinlemeyi ve Stirling PDF'i daha da iyi hale getirmeyi sabırsızlıkla bekliyoruz! survey.meeting.notInterested=Kurumsal kullanıcı değilseniz ve/veya görüşmeye ilgi duymuyorsanız survey.meeting.button=Görüşme Planla @@ -1740,23 +1740,23 @@ validateSignature.cert.keySize=Anahtar Boyutu validateSignature.cert.version=Sürüm validateSignature.cert.keyUsage=Anahtar Kullanımı validateSignature.cert.selfSigned=Kendi Kendine İmzalı -validateSignature.cert.bits=bits +validateSignature.cert.bits=bit # Audit Dashboard -audit.dashboard.title=Audit Dashboard -audit.dashboard.systemStatus=Audit System Status +audit.dashboard.title=Denetim Kontrol Paneli +audit.dashboard.systemStatus=Denetim Sistemi Durumu audit.dashboard.status=Durum audit.dashboard.enabled=Etkin audit.dashboard.disabled=Devre Dışı -audit.dashboard.currentLevel=Current Level -audit.dashboard.retentionPeriod=Retention Period -audit.dashboard.days=days -audit.dashboard.totalEvents=Total Events +audit.dashboard.currentLevel=Mevcut Seviye +audit.dashboard.retentionPeriod=Saklama Süresi +audit.dashboard.days=gün +audit.dashboard.totalEvents=Toplam Olay # Audit Dashboard Tabs -audit.dashboard.tab.dashboard=Dashboard -audit.dashboard.tab.events=Audit Events -audit.dashboard.tab.export=Export +audit.dashboard.tab.dashboard=Gösterge Paneli +audit.dashboard.tab.events=Denetim Olayları +audit.dashboard.tab.export=Dışa Aktar # Dashboard Charts audit.dashboard.eventsByType=Türüne Göre Olaylar audit.dashboard.eventsByUser=Kullanıcıya Göre Olaylar @@ -1766,23 +1766,23 @@ audit.dashboard.period.30days=30 Gün audit.dashboard.period.90days=90 Gün # Events Tab -audit.dashboard.auditEvents=Audit Events -audit.dashboard.filter.eventType=Event Type -audit.dashboard.filter.allEventTypes=All event types -audit.dashboard.filter.user=User -audit.dashboard.filter.userPlaceholder=Filter by user -audit.dashboard.filter.startDate=Start Date -audit.dashboard.filter.endDate=End Date -audit.dashboard.filter.apply=Apply Filters -audit.dashboard.filter.reset=Reset Filters +audit.dashboard.auditEvents=Denetim Olayları +audit.dashboard.filter.eventType=Olay Türü +audit.dashboard.filter.allEventTypes=Tüm olay türleri +audit.dashboard.filter.user=Kullanıcı +audit.dashboard.filter.userPlaceholder=Kullanıcıya göre filtrele +audit.dashboard.filter.startDate=Başlangıç Tarihi +audit.dashboard.filter.endDate=Bitiş Tarihi +audit.dashboard.filter.apply=Filtreleri Uygula +audit.dashboard.filter.reset=Filtreleri Sıfırla # Table Headers audit.dashboard.table.id=ID -audit.dashboard.table.time=Time -audit.dashboard.table.user=User -audit.dashboard.table.type=Type -audit.dashboard.table.details=Details -audit.dashboard.table.viewDetails=View Details +audit.dashboard.table.time=Zaman +audit.dashboard.table.user=Kullanıcı +audit.dashboard.table.type=Tür +audit.dashboard.table.details=Detaylar +audit.dashboard.table.viewDetails=Detayları Görüntüle # Pagination audit.dashboard.pagination.show=Göster @@ -1792,10 +1792,10 @@ audit.dashboard.pagination.pageInfo2=/ audit.dashboard.pagination.totalRecords=Toplam kayıt: # Modal -audit.dashboard.modal.eventDetails=Event Details +audit.dashboard.modal.eventDetails=Olay Detayları audit.dashboard.modal.id=ID audit.dashboard.modal.user=Kullanıcı -audit.dashboard.modal.type=Type +audit.dashboard.modal.type=Tip audit.dashboard.modal.time=Zaman audit.dashboard.modal.data=Veri @@ -1824,8 +1824,8 @@ audit.dashboard.js.loadingPage=Sayfa yükleniyor # Cookie banner # #################### cookieBanner.popUp.title=Çerezleri Nasıl Kullanıyoruz -cookieBanner.popUp.description.1=Stirling PDF’yi sizin için daha iyi çalıştırmak için çerezler ve diğer teknolojileri kullanıyoruz — araçlarımızı geliştirmemize ve seveceğiniz özellikler oluşturmamıza yardımcı oluyorlar. -cookieBanner.popUp.description.2=İstemiyorsanız, ‘Hayır Teşekkürler’ butonuna tıklayarak yalnızca temel, gerekli çerezleri etkinleştirebilirsiniz. +cookieBanner.popUp.description.1=Stirling PDF'yi sizin için daha iyi çalıştırmak için çerezler ve diğer teknolojileri kullanıyoruz — araçlarımızı geliştirmemize ve seveceğiniz özellikler oluşturmamıza yardımcı oluyorlar. +cookieBanner.popUp.description.2=İstemiyorsanız, 'Hayır Teşekkürler' butonuna tıklayarak yalnızca temel, gerekli çerezleri etkinleştirebilirsiniz. cookieBanner.popUp.acceptAllBtn=Tamam cookieBanner.popUp.acceptNecessaryBtn=Hayır Teşekkürler cookieBanner.popUp.showPreferencesBtn=Tercihleri Yönet @@ -1846,20 +1846,20 @@ cookieBanner.preferencesModal.analytics.title=Analitik cookieBanner.preferencesModal.analytics.description=Bu çerezler, araçlarımızın nasıl kullanıldığını anlamamıza yardımcı olur, böylece topluluğumuzun en çok değer verdiği özellikleri geliştirmeye odaklanabiliriz. İçiniz rahat olsun — Stirling PDF, belgelerinizin içeriğini asla takip etmez ve etmeyecektir. #scannerEffect -scannerEffect.title=Scanner Effect -scannerEffect.header=Scanner Effect -scannerEffect.description=Create a PDF that looks like it was scanned -scannerEffect.selectPDF=Select PDF: -scannerEffect.quality=Scan Quality -scannerEffect.quality.low=Low -scannerEffect.quality.medium=Medium -scannerEffect.quality.high=High -scannerEffect.rotation=Rotation Angle -scannerEffect.rotation.none=None -scannerEffect.rotation.slight=Slight -scannerEffect.rotation.moderate=Moderate -scannerEffect.rotation.severe=Severe -scannerEffect.submit=Create Scanner Effect +scannerEffect.title=Tarayıcı Efekti +scannerEffect.header=Tarayıcı Efekti +scannerEffect.description=Taranmış gibi görünen bir PDF oluştur +scannerEffect.selectPDF=PDF Seç: +scannerEffect.quality=Tarama Kalitesi +scannerEffect.quality.low=Düşük +scannerEffect.quality.medium=Orta +scannerEffect.quality.high=Yüksek +scannerEffect.rotation=Döndürme Açısı +scannerEffect.rotation.none=Yok +scannerEffect.rotation.slight=Hafif +scannerEffect.rotation.moderate=Orta +scannerEffect.rotation.severe=Şiddetli +scannerEffect.submit=Tarayıcı Efekti Oluştur #home.scannerEffect home.scannerEffect.title=Sahte Tarama @@ -1893,12 +1893,12 @@ editTableOfContents.replaceExisting=Mevcut yer işaretlerini değiştir (var ola editTableOfContents.editorTitle=Yer İşareti Düzenleyici editTableOfContents.editorDesc=Aşağıdan yer işaretleri ekleyin ve düzenleyin. Alt yer işareti eklemek için + simgesine tıklayın. editTableOfContents.addBookmark=Yeni Yer İşareti Ekle -editTableOfContents.importBookmarksDefault=Import -editTableOfContents.importBookmarksFromJsonFile=Upload JSON file -editTableOfContents.importBookmarksFromClipboard=Paste from clipboard -editTableOfContents.exportBookmarksDefault=Export -editTableOfContents.exportBookmarksAsJson=Download as JSON -editTableOfContents.exportBookmarksAsText=Copy as text +editTableOfContents.importBookmarksDefault=İçe Aktar +editTableOfContents.importBookmarksFromJsonFile=JSON dosyası yükle +editTableOfContents.importBookmarksFromClipboard=Panodan yapıştır +editTableOfContents.exportBookmarksDefault=Dışa Aktar +editTableOfContents.exportBookmarksAsJson=JSON olarak indir +editTableOfContents.exportBookmarksAsText=Metin olarak kopyala editTableOfContents.desc.1=Bu araç, bir PDF belgesine içindekiler tablosu (yer işaretleri) eklemenizi veya mevcut olanları düzenlemenizi sağlar. editTableOfContents.desc.2=Alt yer işaretleri ekleyerek hiyerarşik bir yapı oluşturabilirsiniz. editTableOfContents.desc.3=Her yer işareti bir başlık ve hedef sayfa numarası gerektirir. From cec5d1e1b63151442eba84012241774ae8e7a007 Mon Sep 17 00:00:00 2001 From: Ludy Date: Sun, 24 Aug 2025 22:32:35 +0200 Subject: [PATCH 32/41] ci(workflow): simplify PR deployment by removing redundant repo/ref lookup (#4266) # Description of Changes - Removed the separate step that fetched PR repository and ref (`get-pr-info`). - Simplified checkout by directly using `refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge`. - This reduces workflow complexity and avoids unnecessary API calls while still supporting forked PRs. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../workflows/PR-Demo-Comment-with-react.yml | 28 +------------------ 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index ff653ad15..51949b6cc 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -33,8 +33,6 @@ jobs: ) outputs: pr_number: ${{ steps.get-pr.outputs.pr_number }} - pr_repository: ${{ steps.get-pr-info.outputs.repository }} - pr_ref: ${{ steps.get-pr-info.outputs.ref }} comment_id: ${{ github.event.comment.id }} disable_security: ${{ steps.check-security-flag.outputs.disable_security }} enable_pro: ${{ steps.check-pro-flag.outputs.enable_pro }} @@ -66,29 +64,6 @@ jobs: console.log(`PR Number: ${prNumber}`); core.setOutput('pr_number', prNumber); - - name: Get PR repository and ref - id: get-pr-info - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - script: | - const { owner, repo } = context.repo; - const prNumber = context.payload.issue.number; - - const { data: pr } = await github.rest.pulls.get({ - owner, - repo, - pull_number: prNumber, - }); - - // For forks, use the full repository name, for internal PRs use the current repo - const repository = pr.head.repo.fork ? pr.head.repo.full_name : `${owner}/${repo}`; - - console.log(`PR Repository: ${repository}`); - console.log(`PR Branch: ${pr.head.ref}`); - - core.setOutput('repository', repository); - core.setOutput('ref', pr.head.ref); - - name: Check for security/login flag id: check-security-flag env: @@ -171,8 +146,7 @@ jobs: - name: Checkout PR uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: - repository: ${{ needs.check-comment.outputs.pr_repository }} - ref: ${{ needs.check-comment.outputs.pr_ref }} + ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge token: ${{ steps.setup-bot.outputs.token }} - name: Set up JDK From 4a28c64dee952d21e1ace6ca4896124fb903fc59 Mon Sep 17 00:00:00 2001 From: Ludy Date: Sun, 24 Aug 2025 22:38:07 +0200 Subject: [PATCH 33/41] fix(i18n): standardize `{filename}` placeholder in `addPageNumbers.customNumberDesc` across all translations (#4204) # Description of Changes - Standardized the placeholder for filename in `addPageNumbers.customNumberDesc` to `{filename}` across all affected translation files. - Fixed inconsistent or localized variations (e.g., `{filnavn}`, `{ime datoteke}`, `{nume_fisier}`, `{nome do arquivo}`, `{nama berkas}`) and ensured the placeholder is uniform. - Corrected missing closing braces and quotes in several language files. - Affected locales include: `ar_AR`, `az_AZ`, `bg_BG`, `da_DK`, `el_GR`, `en_GB`, `en_US`, `es_ES`, `ga_IE`, `hr_HR`, `id_ID`, `it_IT`, `nl_NL`, `no_NB`, `pl_PL`, `pt_BR`, `pt_PT`, `ro_RO`, `sk_SK`, `sl_SI`, `sr_LATN_RS`, `sv_SE`, `tr_TR`, `vi_VN`. # @Frooodle please merge after https://github.com/Stirling-Tools/Stirling-PDF/pull/4202 --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- app/core/src/main/resources/messages_ar_AR.properties | 2 +- app/core/src/main/resources/messages_az_AZ.properties | 2 +- app/core/src/main/resources/messages_bg_BG.properties | 2 +- app/core/src/main/resources/messages_da_DK.properties | 2 +- app/core/src/main/resources/messages_el_GR.properties | 2 +- app/core/src/main/resources/messages_en_GB.properties | 2 +- app/core/src/main/resources/messages_en_US.properties | 2 +- app/core/src/main/resources/messages_es_ES.properties | 2 +- app/core/src/main/resources/messages_ga_IE.properties | 2 +- app/core/src/main/resources/messages_hr_HR.properties | 2 +- app/core/src/main/resources/messages_id_ID.properties | 2 +- app/core/src/main/resources/messages_it_IT.properties | 2 +- app/core/src/main/resources/messages_nl_NL.properties | 2 +- app/core/src/main/resources/messages_no_NB.properties | 2 +- app/core/src/main/resources/messages_pl_PL.properties | 2 +- app/core/src/main/resources/messages_pt_BR.properties | 2 +- app/core/src/main/resources/messages_pt_PT.properties | 2 +- app/core/src/main/resources/messages_ro_RO.properties | 2 +- app/core/src/main/resources/messages_sk_SK.properties | 2 +- app/core/src/main/resources/messages_sl_SI.properties | 2 +- app/core/src/main/resources/messages_sr_LATN_RS.properties | 2 +- app/core/src/main/resources/messages_sv_SE.properties | 2 +- app/core/src/main/resources/messages_tr_TR.properties | 2 +- app/core/src/main/resources/messages_vi_VN.properties | 2 +- 24 files changed, 24 insertions(+), 24 deletions(-) diff --git a/app/core/src/main/resources/messages_ar_AR.properties b/app/core/src/main/resources/messages_ar_AR.properties index ed0bc1228..fcea7789c 100644 --- a/app/core/src/main/resources/messages_ar_AR.properties +++ b/app/core/src/main/resources/messages_ar_AR.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=الصفحات المراد ترقيمها addPageNumbers.selectText.6=نص مخصص addPageNumbers.customTextDesc=نص مخصص addPageNumbers.numberPagesDesc=أي الصفحات المراد ترقيمها، الافتراضي 'الكل'، يقبل أيضًا 1-5 أو 2,5,9 إلخ -addPageNumbers.customNumberDesc=الافتراضي هو {n}، يقبل أيضًا 'الصفحة {n} من {total}'، 'نص-{n}'، '{filename}-{n} +addPageNumbers.customNumberDesc=الافتراضي هو {n}، يقبل أيضًا 'الصفحة {n} من {total}'، 'نص-{n}'، '{filename}-{n}' addPageNumbers.submit=إضافة أرقام الصفحات diff --git a/app/core/src/main/resources/messages_az_AZ.properties b/app/core/src/main/resources/messages_az_AZ.properties index f0e3f5ea9..9f9e0122d 100644 --- a/app/core/src/main/resources/messages_az_AZ.properties +++ b/app/core/src/main/resources/messages_az_AZ.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Səhifələrə nömrə əlavə edin addPageNumbers.selectText.6=Fərdi Mətn addPageNumbers.customTextDesc=Fərdi Mətn addPageNumbers.numberPagesDesc=Hansı səhifələrin nömrələnəcəyini seçin, default 'all', və ya 1-5, 2,5,9 kimi yazılış qəbul olunur -addPageNumbers.customNumberDesc=Defolt olaraq {n}, və ya 'Page {n} of {total}', 'Text-{n}', '{filename}-{n} +addPageNumbers.customNumberDesc=Defolt olaraq {n}, və ya 'Page {n} of {total}', 'Text-{n}', '{filename}-{n}' addPageNumbers.submit=Səhifə Nömrələri əlavə edin diff --git a/app/core/src/main/resources/messages_bg_BG.properties b/app/core/src/main/resources/messages_bg_BG.properties index d7964e792..f1004817c 100644 --- a/app/core/src/main/resources/messages_bg_BG.properties +++ b/app/core/src/main/resources/messages_bg_BG.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Страници към номер addPageNumbers.selectText.6=Персонализиран текст addPageNumbers.customTextDesc=Персонализиран текст addPageNumbers.numberPagesDesc=Кои страници да номерирате, по подразбиране 'всички', също приема 1-5 или 2,5,9 и т.н. -addPageNumbers.customNumberDesc=По подразбиране е {n}, също приема 'Страница {n} от {total}', 'Текст-{n}', '{filename}-{n} +addPageNumbers.customNumberDesc=По подразбиране е {n}, също приема 'Страница {n} от {total}', 'Текст-{n}', '{filename}-{n}' addPageNumbers.submit=Добавяне на номера на страници diff --git a/app/core/src/main/resources/messages_da_DK.properties b/app/core/src/main/resources/messages_da_DK.properties index b82f1d761..e67fae325 100644 --- a/app/core/src/main/resources/messages_da_DK.properties +++ b/app/core/src/main/resources/messages_da_DK.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Sider at nummerere addPageNumbers.selectText.6=Brugerdefineret Tekst addPageNumbers.customTextDesc=Brugerdefineret Tekst addPageNumbers.numberPagesDesc=Hvilke sider der skal nummereres, standard 'alle', accepterer også 1-5 eller 2,5,9 osv. -addPageNumbers.customNumberDesc=Standard er {n}, accepterer også 'Side {n} af {total}', 'Tekst-{n}', '{filnavn}-{n} +addPageNumbers.customNumberDesc=Standard er {n}, accepterer også 'Side {n} af {total}', 'Tekst-{n}', '{filename}-{n}' addPageNumbers.submit=Tilføj Sidenumre diff --git a/app/core/src/main/resources/messages_el_GR.properties b/app/core/src/main/resources/messages_el_GR.properties index 7f59f217e..3e52435f3 100644 --- a/app/core/src/main/resources/messages_el_GR.properties +++ b/app/core/src/main/resources/messages_el_GR.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Σελίδες προς αρίθμηση addPageNumbers.selectText.6=Προσαρμοσμένο κείμενο addPageNumbers.customTextDesc=Προσαρμοσμένο κείμενο addPageNumbers.numberPagesDesc=Ποιες σελίδες να αριθμηθούν, προεπιλογή 'all', δέχεται επίσης 1-5 ή 2,5,9 κλπ -addPageNumbers.customNumberDesc=Προεπιλογή σε {n}, δέχεται επίσης 'Σελίδα {n} από {total}', 'Κείμενο-{n}', '{filename}-{n} +addPageNumbers.customNumberDesc=Προεπιλογή σε {n}, δέχεται επίσης 'Σελίδα {n} από {total}', 'Κείμενο-{n}', '{filename}-{n}' addPageNumbers.submit=Προσθήκη αριθμών σελίδων diff --git a/app/core/src/main/resources/messages_en_GB.properties b/app/core/src/main/resources/messages_en_GB.properties index 599dd0989..3414a33f2 100644 --- a/app/core/src/main/resources/messages_en_GB.properties +++ b/app/core/src/main/resources/messages_en_GB.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Pages to Number addPageNumbers.selectText.6=Custom Text addPageNumbers.customTextDesc=Custom Text addPageNumbers.numberPagesDesc=Which pages to number, default 'all', also accepts 1-5 or 2,5,9 etc -addPageNumbers.customNumberDesc=Defaults to {n}, also accepts 'Page {n} of {total}', 'Text-{n}', '{filename}-{n} +addPageNumbers.customNumberDesc=Defaults to {n}, also accepts 'Page {n} of {total}', 'Text-{n}', '{filename}-{n}' addPageNumbers.submit=Add Page Numbers diff --git a/app/core/src/main/resources/messages_en_US.properties b/app/core/src/main/resources/messages_en_US.properties index 8ccbd7c99..66a09ee5b 100644 --- a/app/core/src/main/resources/messages_en_US.properties +++ b/app/core/src/main/resources/messages_en_US.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Pages to Number addPageNumbers.selectText.6=Custom Text addPageNumbers.customTextDesc=Custom Text addPageNumbers.numberPagesDesc=Which pages to number, default 'all', also accepts 1-5 or 2,5,9 etc -addPageNumbers.customNumberDesc=Defaults to {n}, also accepts 'Page {n} of {total}', 'Text-{n}', '{filename}-{n} +addPageNumbers.customNumberDesc=Defaults to {n}, also accepts 'Page {n} of {total}', 'Text-{n}', '{filename}-{n}' addPageNumbers.submit=Add Page Numbers diff --git a/app/core/src/main/resources/messages_es_ES.properties b/app/core/src/main/resources/messages_es_ES.properties index ae63d5107..3615d981c 100644 --- a/app/core/src/main/resources/messages_es_ES.properties +++ b/app/core/src/main/resources/messages_es_ES.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Páginas a numerar addPageNumbers.selectText.6=Texto personalizado addPageNumbers.customTextDesc=Texto personalizado addPageNumbers.numberPagesDesc=Qué páginas numerar, por defecto 'todas', también acepta 1-5 o 2,5,9 etc -addPageNumbers.customNumberDesc=Por defecto a {n}, también acepta 'Página {n} de {total}', 'Texto-{n}', '{filename}-{n} +addPageNumbers.customNumberDesc=Por defecto a {n}, también acepta 'Página {n} de {total}', 'Texto-{n}', '{filename}-{n}' addPageNumbers.submit=Añadir Números de Página diff --git a/app/core/src/main/resources/messages_ga_IE.properties b/app/core/src/main/resources/messages_ga_IE.properties index b0363acb4..c7075c413 100644 --- a/app/core/src/main/resources/messages_ga_IE.properties +++ b/app/core/src/main/resources/messages_ga_IE.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Leathanaigh go hUimhir addPageNumbers.selectText.6=Téacs Saincheaptha addPageNumbers.customTextDesc=Téacs Saincheaptha addPageNumbers.numberPagesDesc=Cé na leathanaigh le huimhriú, réamhshocraithe 'gach duine', a ghlacann freisin 1-5 nó 2,5,9 etc -addPageNumbers.customNumberDesc=Réamhshocrú go {n}, glacann sé freisin le 'Leathanach {n} de {total}', 'Text-{n}', '{filename}-{n} +addPageNumbers.customNumberDesc=Réamhshocrú go {n}, glacann sé freisin le 'Leathanach {n} de {total}', 'Text-{n}', '{filename}-{n}' addPageNumbers.submit=Cuir Uimhreacha Leathanaigh leis diff --git a/app/core/src/main/resources/messages_hr_HR.properties b/app/core/src/main/resources/messages_hr_HR.properties index cb06aba43..dd83f3c43 100644 --- a/app/core/src/main/resources/messages_hr_HR.properties +++ b/app/core/src/main/resources/messages_hr_HR.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Brojanje stranica addPageNumbers.selectText.6=Prilagođeni tekst addPageNumbers.customTextDesc=Prilagođeni tekst addPageNumbers.numberPagesDesc=Koje stranice numerirati, zadano je 'sve', također prihvaća 1-5 ili 2,5,9 itd. -addPageNumbers.customNumberDesc=Zadano je {n}, također prihvaća 'Stranica {n} od {total}', 'Tekst-{n}', '{ime datoteke}-{n}' +addPageNumbers.customNumberDesc=Zadano je {n}, također prihvaća 'Stranica {n} od {total}', 'Tekst-{n}', '{filename}-{n}' addPageNumbers.submit=Dodaj brojeve stranica diff --git a/app/core/src/main/resources/messages_id_ID.properties b/app/core/src/main/resources/messages_id_ID.properties index d06da87ab..70f874971 100644 --- a/app/core/src/main/resources/messages_id_ID.properties +++ b/app/core/src/main/resources/messages_id_ID.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Halaman ke Nomor addPageNumbers.selectText.6=Teks Khusus addPageNumbers.customTextDesc=Teks Khusus addPageNumbers.numberPagesDesc=Halaman mana yang akan diberi nomor, default 'semua', juga menerima 1-5 atau 2,5,9, dll. -addPageNumbers.customNumberDesc=Default untuk {n}, juga menerima 'Halaman {n} dari {total}', 'Teks-{n}', '{nama berkas}-{n}' +addPageNumbers.customNumberDesc=Default untuk {n}, juga menerima 'Halaman {n} dari {total}', 'Teks-{n}', '{filename}-{n}' addPageNumbers.submit=Tambahkan Nomor Halaman diff --git a/app/core/src/main/resources/messages_it_IT.properties b/app/core/src/main/resources/messages_it_IT.properties index 7491624f0..29c3acf86 100644 --- a/app/core/src/main/resources/messages_it_IT.properties +++ b/app/core/src/main/resources/messages_it_IT.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Pagine da numerare addPageNumbers.selectText.6=Testo personalizzato addPageNumbers.customTextDesc=Testo personalizzato addPageNumbers.numberPagesDesc=Quali pagine numerare, impostazione predefinita "tutte", accetta anche 1-5 o 2,5,9 ecc -addPageNumbers.customNumberDesc=Il valore predefinito è {n}, accetta anche 'Pagina {n} di {total}', 'Testo-{n}', '{filename}-{n} +addPageNumbers.customNumberDesc=Il valore predefinito è {n}, accetta anche 'Pagina {n} di {total}', 'Testo-{n}', '{filename}-{n}' addPageNumbers.submit=Aggiungi numeri di pagina diff --git a/app/core/src/main/resources/messages_nl_NL.properties b/app/core/src/main/resources/messages_nl_NL.properties index f7aa1e805..04e26701e 100644 --- a/app/core/src/main/resources/messages_nl_NL.properties +++ b/app/core/src/main/resources/messages_nl_NL.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Pagina's om te nummeren addPageNumbers.selectText.6=Aangepaste tekst addPageNumbers.customTextDesc=Aangepaste tekst addPageNumbers.numberPagesDesc=Welke pagina's genummerd moeten worden, standaard 'all', accepteert ook 1-5 of 2,5,9 etc -addPageNumbers.customNumberDesc=Standaard {n}, accepteert ook 'Pagina {n} van {total}', 'Tekst-{n}', '{filename}-{n} +addPageNumbers.customNumberDesc=Standaard {n}, accepteert ook 'Pagina {n} van {total}', 'Tekst-{n}', '{filename}-{n}' addPageNumbers.submit=Paginanummers toevoegen diff --git a/app/core/src/main/resources/messages_no_NB.properties b/app/core/src/main/resources/messages_no_NB.properties index ae9091cf5..21f25e613 100644 --- a/app/core/src/main/resources/messages_no_NB.properties +++ b/app/core/src/main/resources/messages_no_NB.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Sider å nummerere addPageNumbers.selectText.6=Tilpasset Tekst addPageNumbers.customTextDesc=Tilpasset Tekst addPageNumbers.numberPagesDesc=Hvilke sider som skal nummereres, standard 'alle', aksepterer også 1-5 eller 2,5,9 osv. -addPageNumbers.customNumberDesc=Standard til {n}, aksepterer også 'Side {n} av {total}', 'Tekst-{n}', '{filnavn}-{n} +addPageNumbers.customNumberDesc=Standard til {n}, aksepterer også 'Side {n} av {total}', 'Tekst-{n}', '{filename}-{n}' addPageNumbers.submit=Legg til Sidetall diff --git a/app/core/src/main/resources/messages_pl_PL.properties b/app/core/src/main/resources/messages_pl_PL.properties index 9c5dc670e..5b5ec5291 100644 --- a/app/core/src/main/resources/messages_pl_PL.properties +++ b/app/core/src/main/resources/messages_pl_PL.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Ilość stron do ponumerowania addPageNumbers.selectText.6=Tekst własny addPageNumbers.customTextDesc=Tekst własny addPageNumbers.numberPagesDesc=Strony do numeracji, wszystkie (all), 1-5, 2, 5, 9 -addPageNumbers.customNumberDesc=Domyślnie do {n}, również akceptuje 'Strona {n} z {total},Teskt-{n},'{filename}-{n} +addPageNumbers.customNumberDesc=Domyślnie do {n}, również akceptuje 'Strona {n} z {total}', 'Tekst-{n}', '{filename}-{n}' addPageNumbers.submit=Dodaj numerację stron diff --git a/app/core/src/main/resources/messages_pt_BR.properties b/app/core/src/main/resources/messages_pt_BR.properties index bf2cb6a17..045aaa8bb 100644 --- a/app/core/src/main/resources/messages_pt_BR.properties +++ b/app/core/src/main/resources/messages_pt_BR.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Páginas a numerar: addPageNumbers.selectText.6=Texto personalizado: addPageNumbers.customTextDesc=Texto personalizado: addPageNumbers.numberPagesDesc=Quais páginas numerar, padrão 'todas', também aceita 1-5 ou 2,5,9,etc. -addPageNumbers.customNumberDesc=O padrão é {n}, também aceita 'Página {n} de {total}', 'Texto-{n}', '{nome do arquivo}-{n}' +addPageNumbers.customNumberDesc=O padrão é {n}, também aceita 'Página {n} de {total}', 'Texto-{n}', '{filename}-{n}' addPageNumbers.submit=Adicionar Números de Página diff --git a/app/core/src/main/resources/messages_pt_PT.properties b/app/core/src/main/resources/messages_pt_PT.properties index 7b73092f1..9a2449183 100644 --- a/app/core/src/main/resources/messages_pt_PT.properties +++ b/app/core/src/main/resources/messages_pt_PT.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Páginas a Numerar addPageNumbers.selectText.6=Texto Personalizado addPageNumbers.customTextDesc=Texto Personalizado addPageNumbers.numberPagesDesc=Quais páginas a numerar, predefinição 'todas', também aceita 1-5 ou 2,5,9 etc -addPageNumbers.customNumberDesc=Predefinição {n}, também aceita 'Página {n} de {total}', 'Texto-{n}', '{filename}-{n} +addPageNumbers.customNumberDesc=Predefinição {n}, também aceita 'Página {n} de {total}', 'Texto-{n}', '{filename}-{n}' addPageNumbers.submit=Adicionar Números de Página diff --git a/app/core/src/main/resources/messages_ro_RO.properties b/app/core/src/main/resources/messages_ro_RO.properties index 07fee9b86..a6270c69e 100644 --- a/app/core/src/main/resources/messages_ro_RO.properties +++ b/app/core/src/main/resources/messages_ro_RO.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Pagini de Numerotat addPageNumbers.selectText.6=Text Personalizat addPageNumbers.customTextDesc=Text Personalizat addPageNumbers.numberPagesDesc=Ce pagini să numeroteze, implicit 'toate', acceptă și 1-5 sau 2,5,9 etc -addPageNumbers.customNumberDesc=Implicit la {n}, acceptă și 'Pagina {n} din {total}', 'Text-{n}', '{nume_fisier}-{n} +addPageNumbers.customNumberDesc=Implicit la {n}, acceptă și 'Pagina {n} din {total}', 'Text-{n}', '{filename}-{n}' addPageNumbers.submit=Adaugă Numere de Pagină diff --git a/app/core/src/main/resources/messages_sk_SK.properties b/app/core/src/main/resources/messages_sk_SK.properties index 4b84511f5..60fa06ad3 100644 --- a/app/core/src/main/resources/messages_sk_SK.properties +++ b/app/core/src/main/resources/messages_sk_SK.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Stránky na číslovanie addPageNumbers.selectText.6=Vlastný text addPageNumbers.customTextDesc=Vlastný text addPageNumbers.numberPagesDesc=Ktoré stránky číslovať, predvolené 'všetky', tiež akceptuje 1-5 alebo 2,5,9 atď. -addPageNumbers.customNumberDesc=Predvolené {n}, tiež akceptuje 'Strana {n} z {total}', 'Text-{n}', '{filename}-{n} +addPageNumbers.customNumberDesc=Predvolené {n}, tiež akceptuje 'Strana {n} z {total}', 'Text-{n}', '{filename}-{n}' addPageNumbers.submit=Pridať čísla stránok diff --git a/app/core/src/main/resources/messages_sl_SI.properties b/app/core/src/main/resources/messages_sl_SI.properties index 72987dfcd..820d27a98 100644 --- a/app/core/src/main/resources/messages_sl_SI.properties +++ b/app/core/src/main/resources/messages_sl_SI.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Strani v številko addPageNumbers.selectText.6=Besedilo po meri addPageNumbers.customTextDesc=Besedilo po meri addPageNumbers.numberPagesDesc=Katere strani oštevilčiti, privzeto 'vse', sprejema tudi 1-5 ali 2,5,9 itd. -addPageNumbers.customNumberDesc=Privzeto na {n}, sprejema tudi 'Stran {n} od {total}', 'Besedilo-{n}', '{filename}-{n} +addPageNumbers.customNumberDesc=Privzeto na {n}, sprejema tudi 'Stran {n} od {total}', 'Besedilo-{n}', '{filename}-{n}' addPageNumbers.submit=Dodaj številke strani diff --git a/app/core/src/main/resources/messages_sr_LATN_RS.properties b/app/core/src/main/resources/messages_sr_LATN_RS.properties index 4a6e987ca..9c578910c 100644 --- a/app/core/src/main/resources/messages_sr_LATN_RS.properties +++ b/app/core/src/main/resources/messages_sr_LATN_RS.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Stranice za numerisanje: addPageNumbers.selectText.6=Prilagođeni tekst: addPageNumbers.customTextDesc=Prilagođeni tekst addPageNumbers.numberPagesDesc=Koje stranice brojati, podrazumevano 'sve', takođe prihvata 1-5 ili 2,5,9 itd. -addPageNumbers.customNumberDesc=Podrazumevano je {n}, takođe prihvata 'Stranica {n} od {ukupno}', 'Tekst-{n}', '{ime_fajla}-{n}' +addPageNumbers.customNumberDesc=Podrazumevano je {n}, takođe prihvata 'Stranica {n} od {total}', 'Tekst-{n}', '{filename}-{n}' addPageNumbers.submit=Numeriši diff --git a/app/core/src/main/resources/messages_sv_SE.properties b/app/core/src/main/resources/messages_sv_SE.properties index 0182c8f98..d8ae5299b 100644 --- a/app/core/src/main/resources/messages_sv_SE.properties +++ b/app/core/src/main/resources/messages_sv_SE.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Sidor att numrera addPageNumbers.selectText.6=Anpassad text addPageNumbers.customTextDesc=Anpassad text addPageNumbers.numberPagesDesc=Vilka sidor som ska numreras, standard 'all', accepterar även 1-5 eller 2,5,9 etc -addPageNumbers.customNumberDesc=Standard är {n}, accepterar även 'Sida {n} av {total}', 'Text-{n}', '{filnamn}-{n} +addPageNumbers.customNumberDesc=Standard är {n}, accepterar även 'Sida {n} av {total}', 'Text-{n}', '{filename}-{n}' addPageNumbers.submit=Lägg till sidnummer diff --git a/app/core/src/main/resources/messages_tr_TR.properties b/app/core/src/main/resources/messages_tr_TR.properties index ca084cc0d..b9d1f9439 100644 --- a/app/core/src/main/resources/messages_tr_TR.properties +++ b/app/core/src/main/resources/messages_tr_TR.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Numaralandırılacak Sayfalar addPageNumbers.selectText.6=Özel Metin addPageNumbers.customTextDesc=Özel Metin addPageNumbers.numberPagesDesc=Hangi sayfaların numaralandırılacağını, varsayılan 'all', ayrıca 1-5 veya 2,5,9 vb. kabul eder -addPageNumbers.customNumberDesc=Varsayılan {n}, ayrıca 'Sayfa {n} / {total}', 'Metin-{n}', '{filename}-{n} kabul eder +addPageNumbers.customNumberDesc=Varsayılan {n}, ayrıca 'Sayfa {n} / {total}', 'Metin-{n}', '{filename}-{n}' kabul eder addPageNumbers.submit=Sayfa Numaraları Ekle diff --git a/app/core/src/main/resources/messages_vi_VN.properties b/app/core/src/main/resources/messages_vi_VN.properties index ba7ba416b..1a72402a5 100644 --- a/app/core/src/main/resources/messages_vi_VN.properties +++ b/app/core/src/main/resources/messages_vi_VN.properties @@ -1081,7 +1081,7 @@ addPageNumbers.selectText.5=Trang cần đánh số addPageNumbers.selectText.6=Văn bản tùy chỉnh addPageNumbers.customTextDesc=Văn bản tùy chỉnh addPageNumbers.numberPagesDesc=Những trang cần đánh số, mặc định là 'all', cũng chấp nhận 1-5 hoặc 2,5,9 v.v. -addPageNumbers.customNumberDesc=Mặc định là {n}, cũng chấp nhận 'Trang {n} / {total}', 'Văn bản-{n}', '{filename}-{n} +addPageNumbers.customNumberDesc=Mặc định là {n}, cũng chấp nhận 'Trang {n} / {total}', 'Văn bản-{n}', '{filename}-{n}' addPageNumbers.submit=Thêm số trang From d9a1ed6df15c8f9e00fadc67b0e20a90eec281af Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Mon, 25 Aug 2025 04:41:09 +0800 Subject: [PATCH 34/41] Update and improve the zh-TW Traditional Chinese translation (#4164) # Description of Changes Update and improve the zh-TW Traditional Chinese translation Summary from GitHub Copilot: > This pull request updates the Traditional Chinese localization file `messages_zh_TW.properties` to improve translation accuracy and consistency across the UI. The changes focus on refining update-related strings, clarifying PDF tool descriptions, and localizing bookmark editor actions. > > **Update & Upgrade Experience:** > * Improved wording and localization for update notifications, modal dialogs, update priority levels, and migration guide references to better match native language conventions. > * Refined enterprise edition feature descriptions, specifically clarifying SSO login language for professional features. > * Updated SSO login string for the login screen to use more natural phrasing. > > **PDF Tool Descriptions:** > * Enhanced descriptions for "Auto Split PDF" and "PDF to Single Page/Image" tools, making instructions clearer and terminology more consistent. [[1]](diffhunk://#diff-b982180d8edd5c66aef4cfc826fe1cabeb4d22644359f2f749ce0bec4760b40aL809-R809) [[2]](diffhunk://#diff-b982180d8edd5c66aef4cfc826fe1cabeb4d22644359f2f749ce0bec4760b40aL1111-R1118) [[3]](diffhunk://#diff-b982180d8edd5c66aef4cfc826fe1cabeb4d22644359f2f749ce0bec4760b40aL1431-R1431) > > **Bookmark Editor Localization:** > * Fully localized import/export actions and descriptions in the bookmark editor, replacing English terms with accurate Chinese equivalents. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [x] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../main/resources/messages_zh_TW.properties | 92 +++++++++---------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/app/core/src/main/resources/messages_zh_TW.properties b/app/core/src/main/resources/messages_zh_TW.properties index c2cf4518c..1142aea04 100644 --- a/app/core/src/main/resources/messages_zh_TW.properties +++ b/app/core/src/main/resources/messages_zh_TW.properties @@ -325,7 +325,7 @@ pipelineOptions.validateButton=驗證 ######################## enterpriseEdition.button=升級至專業版 enterpriseEdition.warning=此功能僅提供給專業版使用者使用。 -enterpriseEdition.yamlAdvert=Stirling PDF 專業版支援 YAML 設定檔和其他單一登入 (SSO) 功能。 +enterpriseEdition.yamlAdvert=Stirling PDF 專業版支援 YAML 設定檔和其他 SSO 登入功能。 enterpriseEdition.ssoAdvert=需要更多使用者管理功能嗎?請參考 Stirling PDF 專業版 enterpriseEdition.proTeamFeatureDisabled=團隊管理功能需要專業版或更進階的授權 @@ -364,42 +364,42 @@ navbar.sections.popular=熱門功能 # SETTINGS # ############# settings.title=設定 -settings.update=有更新可用 -settings.updateAvailable=目前安裝的版本是 {0}。有新版本({1})可供使用。 +settings.update=有可用更新 +settings.updateAvailable=目前安裝的版本為 {0},已有新版本({1})可供更新。 # Update modal and notification strings -update.urgentUpdateAvailable=🚨 Update Available -update.updateAvailable=Update Available -update.modalTitle=Update Available -update.current=Current -update.latest=Latest -update.latestStable=Latest Stable -update.priority=Priority -update.recommendedAction=Recommended Action -update.breakingChangesDetected=⚠️ Breaking Changes Detected -update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below. -update.migrationGuides=Migration Guides: -update.viewGuide=View Guide -update.loadingDetailedInfo=Loading detailed version information... -update.close=Close -update.viewAllReleases=View All Releases -update.downloadLatest=Download Latest -update.availableUpdates=Available Updates: -update.unableToLoadDetails=Unable to load detailed version information. -update.version=Version +update.urgentUpdateAvailable=🚨 有緊急更新 +update.updateAvailable=有可用更新 +update.modalTitle=有可用更新 +update.current=目前版本 +update.latest=最新版本 +update.latestStable=最新穩定版本 +update.priority=優先等級 +update.recommendedAction=建議操作 +update.breakingChangesDetected=⚠️ 偵測到重大變更 +update.breakingChangesMessage=此更新包含可能無法向下相容的重大變更,請先參閱下方的遷移指南。 +update.migrationGuides=遷移指南: +update.viewGuide=檢視指南 +update.loadingDetailedInfo=正在載入版本詳細資訊... +update.close=關閉 +update.viewAllReleases=檢視所有版本 +update.downloadLatest=下載最新版 +update.availableUpdates=可用更新: +update.unableToLoadDetails=無法載入版本詳細資訊。 +update.version=版本 # Update priority levels -update.priority.urgent=URGENT -update.priority.normal=NORMAL -update.priority.minor=MINOR -update.priority.low=LOW +update.priority.urgent=緊急更新 +update.priority.normal=一般更新 +update.priority.minor=次要更新 +update.priority.low=低優先度更新 # Breaking changes text -update.breakingChanges=Breaking Changes: -update.breakingChangesDefault=This version contains breaking changes -update.migrationGuide=Migration Guide +update.breakingChanges=重大變更: +update.breakingChangesDefault=此版本包含可能無法向下相容的重大變更 +update.migrationGuide=遷移指南 settings.appVersion=應用程式版本: -settings.downloadOption.title=選擇下載選項(適用於單一檔案非壓縮下載): +settings.downloadOption.title=選擇下載選項(適用於單檔無壓縮下載): settings.downloadOption.1=在同一視窗中開啟 settings.downloadOption.2=在新視窗中開啟 settings.downloadOption.3=下載檔案 @@ -806,7 +806,7 @@ home.extractPage.desc=從 PDF 中提取選定的頁面 extractPage.tags=提取 -home.PdfToSinglePage.title=PDF 轉單一大頁面 +home.PdfToSinglePage.title=PDF 轉單一大型頁面 home.PdfToSinglePage.desc=將所有 PDF 頁面合併為一個大的單一頁面 PdfToSinglePage.tags=單一頁面 @@ -893,7 +893,7 @@ login.rememberme=記住我 login.invalid=使用者名稱或密碼無效。 login.locked=您的帳號已被鎖定。 login.signinTitle=請登入 -login.ssoSignIn=透過 SSO 單一登入 +login.ssoSignIn=透過 SSO 登入 login.oAuth2AutoCreateDisabled=OAuth 2.0 自動建立使用者功能已停用 login.oAuth2AdminBlockedUser=目前不允許未註冊的使用者註冊或登入。請聯絡系統管理員。 login.oauth2RequestNotFound=找不到驗證請求 @@ -1109,14 +1109,14 @@ crop.submit=送出 #autoSplitPDF autoSplitPDF.title=自動分割 PDF autoSplitPDF.header=自動分割 PDF -autoSplitPDF.description=列印,插入,掃描,上傳,讓 Stirling PDF 處理其餘的工作。不需要手動工作排序。 -autoSplitPDF.selectText.1=從下面列印一些分隔紙張(黑白即可)。 -autoSplitPDF.selectText.2=透過在它們之間插入分隔紙張一次掃描所有文件。 -autoSplitPDF.selectText.3=上傳單一大的掃描 PDF 檔案,讓 Stirling PDF 處理其餘的工作。 -autoSplitPDF.selectText.4=自動偵測並移除分隔頁面,確保最終文件整潔。 -autoSplitPDF.formPrompt=送出包含 Stirling-PDF 頁面分隔器的 PDF: +autoSplitPDF.description=列印、插入、掃描、上傳,剩下的就交給 Stirling PDF 自動處理,無需手動排序。 +autoSplitPDF.selectText.1=從下方列印分隔頁(黑白列印即可)。 +autoSplitPDF.selectText.2=將分隔頁夾在文件之間,一次掃描全部文件。 +autoSplitPDF.selectText.3=上傳完整的單一掃描 PDF 檔,剩下的交給 Stirling PDF 自動處理。 +autoSplitPDF.selectText.4=系統會自動偵測並移除分隔頁,確保輸出的文件整齊乾淨。 +autoSplitPDF.formPrompt=送出包含 Stirling PDF 分隔頁的 PDF 檔案: autoSplitPDF.duplexMode=雙面模式(正反面掃描) -autoSplitPDF.dividerDownload2=下載 '自動分割器分隔器(帶說明).pdf' +autoSplitPDF.dividerDownload2=下載《自動分割用分隔頁(含使用說明).pdf》 autoSplitPDF.submit=送出 @@ -1429,7 +1429,7 @@ pdfToImage.title=PDF 轉圖片 pdfToImage.header=PDF 轉圖片 pdfToImage.selectText=影像格式 pdfToImage.singleOrMultiple=頁面到影像的結果類型 -pdfToImage.single=單一大影像結合所有頁面 +pdfToImage.single=單一大型影像結合所有頁面 pdfToImage.multi=多個影像,每頁一個影像 pdfToImage.colorType=顏色類型 pdfToImage.color=顏色 @@ -1893,12 +1893,12 @@ editTableOfContents.replaceExisting=取代現有書籤 (取消勾選以附加到 editTableOfContents.editorTitle=書籤編輯器 editTableOfContents.editorDesc=在下方新增和排列書籤。點選 + 新增子書籤。 editTableOfContents.addBookmark=新增書籤 -editTableOfContents.importBookmarksDefault=Import -editTableOfContents.importBookmarksFromJsonFile=Upload JSON file -editTableOfContents.importBookmarksFromClipboard=Paste from clipboard -editTableOfContents.exportBookmarksDefault=Export -editTableOfContents.exportBookmarksAsJson=Download as JSON -editTableOfContents.exportBookmarksAsText=Copy as text +editTableOfContents.importBookmarksDefault=匯入 +editTableOfContents.importBookmarksFromJsonFile=上傳 JSON 檔案 +editTableOfContents.importBookmarksFromClipboard=從剪貼簿貼上 +editTableOfContents.exportBookmarksDefault=匯出 +editTableOfContents.exportBookmarksAsJson=下載為 JSON +editTableOfContents.exportBookmarksAsText=複製為文字 editTableOfContents.desc.1=此工具可讓您在 PDF 文件中新增或編輯目錄 (書籤)。 editTableOfContents.desc.2=您可以透過將子書籤新增至父書籤來建立階層式結構。 editTableOfContents.desc.3=每個書籤都需要標題和目標頁碼。 From 930fcf01bfcd8d46b2d2a5cbb1a839ca6569000e Mon Sep 17 00:00:00 2001 From: Ludy Date: Sun, 24 Aug 2025 22:42:31 +0200 Subject: [PATCH 35/41] fix(downloader): reset progress bar after completion (#4192) # Description of Changes - Added a `setTimeout` to hide the `.progressBarContainer` 1 second after reaching 100%. - Reset progress bar width to `0%` and `aria-valuenow` to `0` to prepare for future downloads. - This change ensures the UI does not leave a full progress bar displayed after a completed download. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/core/src/main/resources/static/js/downloader.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/core/src/main/resources/static/js/downloader.js b/app/core/src/main/resources/static/js/downloader.js index b5324dd82..47e1b06af 100644 --- a/app/core/src/main/resources/static/js/downloader.js +++ b/app/core/src/main/resources/static/js/downloader.js @@ -482,6 +482,11 @@ } progressBar.css('width', '100%'); progressBar.attr('aria-valuenow', Array.from(files).length); + setTimeout(() => { + progressBar.closest('.progressBarContainer').hide(); + progressBar.css('width', '0%'); + progressBar.attr('aria-valuenow', 0); + }, 1000); } function updateProgressBar(progressBar, files) { From 97132c28a403d567fc82b909cfdaa76012d81303 Mon Sep 17 00:00:00 2001 From: Ludy Date: Sun, 24 Aug 2025 22:44:50 +0200 Subject: [PATCH 36/41] build(gradle): include all subprojects in license report generation (#4170) # Description of Changes - Updated `build.gradle` to ensure the `licenseReport` task processes both the root project and all subprojects. - Introduced `allProjects` variable that merges `subprojects` with the current project into a single set. - This change ensures license reporting covers the full multi-module project rather than only the root module. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .github/workflows/build.yml | 7 +++++-- .github/workflows/licenses-update.yml | 3 +++ build.gradle | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 60085f9c9..19108b186 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -175,10 +175,13 @@ jobs: java-version: "17" distribution: "temurin" - - name: check the licenses for compatibility + - name: Check licenses for compatibility run: ./gradlew clean checkLicense + env: + DISABLE_ADDITIONAL_FEATURES: false + STIRLING_PDF_DESKTOP_UI: true - - name: FAILED - check the licenses for compatibility + - name: FAILED - Check licenses for compatibility if: failure() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: diff --git a/.github/workflows/licenses-update.yml b/.github/workflows/licenses-update.yml index 1f920e2da..5971f7dc9 100644 --- a/.github/workflows/licenses-update.yml +++ b/.github/workflows/licenses-update.yml @@ -58,6 +58,9 @@ jobs: - name: Check licenses for compatibility run: ./gradlew clean checkLicense + env: + DISABLE_ADDITIONAL_FEATURES: false + STIRLING_PDF_DESKTOP_UI: true - name: Upload artifact on failure if: failure() diff --git a/build.gradle b/build.gradle index 4016c2bc6..b722517a2 100644 --- a/build.gradle +++ b/build.gradle @@ -222,11 +222,14 @@ tasks.withType(JavaCompile).configureEach { dependsOn "spotlessApply" } +def allProjects = ((subprojects as Set) + project) as Set + licenseReport { - projects = [project] + projects = allProjects renderers = [new JsonReportRenderer()] allowedLicensesFile = project.layout.projectDirectory.file("app/allowed-licenses.json").asFile outputDir = project.layout.buildDirectory.dir("reports/dependency-license").get().asFile.path + configurations = [ "productionRuntimeClasspath", "runtimeClasspath" ] } // Configure the forked spring boot run task to properly delegate to the stirling-pdf module From f0cfd87a5a71de919d47733f11aa7baee59810d3 Mon Sep 17 00:00:00 2001 From: Ludy Date: Sun, 24 Aug 2025 22:45:26 +0200 Subject: [PATCH 37/41] build(gradle): replace deprecated `outputFile` with `destinationFile` in `writeVersion` task (#4167) # Description of Changes - Replaced the deprecated `outputFile` property with `destinationFile` in the `writeVersion` Gradle task. - Updated the logging statement to use `destinationFile.get().asFile.path` instead of the old `outputFile.path`. - This change ensures compatibility with newer Gradle versions and removes deprecation warnings. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index b722517a2..5f182428f 100644 --- a/build.gradle +++ b/build.gradle @@ -74,8 +74,8 @@ allprojects { } tasks.register('writeVersion', WriteProperties) { - outputFile = layout.projectDirectory.file('app/common/src/main/resources/version.properties') - println "Writing version.properties to ${outputFile.path}" + destinationFile = layout.projectDirectory.file('app/common/src/main/resources/version.properties') + println "Writing version.properties to ${destinationFile.get().asFile.path}" comment = "${new Date()}" property 'version', project.provider { project.version.toString() } } From 73df0ae1a8fe0ea52f2c7d4345d239ed94e65293 Mon Sep 17 00:00:00 2001 From: Ludy Date: Sun, 24 Aug 2025 22:47:09 +0200 Subject: [PATCH 38/41] fix(config): recreate settings.yml if missing or below minimal size threshold (#4166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes - Added logic to verify the `settings.yml` file’s existence **and** ensure it has at least 31 lines (minimum valid config since `v0.13.0`). - If the file exists but is too small, it is moved to a timestamped `.bak` backup before creating a new one from the template. - Added logging to show current line count and backup location for better traceability. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../configuration/ConfigInitializer.java | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/app/common/src/main/java/stirling/software/common/configuration/ConfigInitializer.java b/app/common/src/main/java/stirling/software/common/configuration/ConfigInitializer.java index 50090ee51..54e42504c 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/ConfigInitializer.java +++ b/app/common/src/main/java/stirling/software/common/configuration/ConfigInitializer.java @@ -23,10 +23,30 @@ import stirling.software.common.util.YamlHelper; @Slf4j public class ConfigInitializer { + private static final int MIN_SETTINGS_FILE_LINES = 31; + public void ensureConfigExists() throws IOException, URISyntaxException { // 1) If settings file doesn't exist, create from template Path destPath = Paths.get(InstallationPathConfig.getSettingsPath()); - if (Files.notExists(destPath)) { + + boolean settingsFileExists = Files.exists(destPath); + + long lineCount = settingsFileExists ? Files.readAllLines(destPath).size() : 0; + + log.info("Current settings file line count: {}", lineCount); + + if (!settingsFileExists || lineCount < MIN_SETTINGS_FILE_LINES) { + if (settingsFileExists) { + // move settings.yml to settings.yml.{timestamp}.bak + Path backupPath = + Paths.get( + InstallationPathConfig.getSettingsPath() + + "." + + System.currentTimeMillis() + + ".bak"); + Files.move(destPath, backupPath, StandardCopyOption.REPLACE_EXISTING); + log.info("Moved existing settings file to backup: {}", backupPath); + } Files.createDirectories(destPath.getParent()); try (InputStream in = getClass().getClassLoader().getResourceAsStream("settings.yml.template")) { From f5f011f1e07c188ec26fc3443e7db30303202c31 Mon Sep 17 00:00:00 2001 From: Ludy Date: Sun, 24 Aug 2025 23:03:12 +0200 Subject: [PATCH 39/41] deps: Pin Python dev dependencies and lock hashes to remediate security alert 302 (#4173) ## Description of Changes - **What was changed** - Added `.github/scripts/requirements_dev.in` and an autogenerated, hash-locked `.github/scripts/requirements_dev.txt` to control Python dev dependencies via `pip-compile`. - **Why the change was made** - To remediate a GitHub code scanning alert by removing vulnerable transitive ranges and ensuring reproducible installs with vetted versions and hashes. - **Any challenges encountered** - Reconciling version constraints among image/PDF tooling (e.g., Pillow, pdf2image, OpenCV, WeasyPrint) while keeping wheels available across CI platforms. - Ensuring the generated lockfile remains maintainable and can be refreshed with `pip-compile` when needed. Closes #https://github.com/Stirling-Tools/Stirling-PDF/security/code-scanning/302 --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .github/scripts/requirements_dev.in | 8 + .github/scripts/requirements_dev.txt | 638 +++++++++++++++++++++++++++ Dockerfile.dev | 4 +- 3 files changed, 648 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/requirements_dev.in create mode 100644 .github/scripts/requirements_dev.txt diff --git a/.github/scripts/requirements_dev.in b/.github/scripts/requirements_dev.in new file mode 100644 index 000000000..a8732d927 --- /dev/null +++ b/.github/scripts/requirements_dev.in @@ -0,0 +1,8 @@ +pip +setuptools +WeasyPrint +pdf2image +pillow +unoserver +opencv-python-headless +pre-commit diff --git a/.github/scripts/requirements_dev.txt b/.github/scripts/requirements_dev.txt new file mode 100644 index 000000000..be4e14a70 --- /dev/null +++ b/.github/scripts/requirements_dev.txt @@ -0,0 +1,638 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile --allow-unsafe --generate-hashes --output-file='.github\scripts\requirements_dev.txt' --strip-extras '.github\scripts\requirements_dev.in' +# +brotli==1.1.0 \ + --hash=sha256:03d20af184290887bdea3f0f78c4f737d126c74dc2f3ccadf07e54ceca3bf208 \ + --hash=sha256:0541e747cce78e24ea12d69176f6a7ddb690e62c425e01d31cc065e69ce55b48 \ + --hash=sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354 \ + --hash=sha256:0737ddb3068957cf1b054899b0883830bb1fec522ec76b1098f9b6e0f02d9419 \ + --hash=sha256:0b63b949ff929fbc2d6d3ce0e924c9b93c9785d877a21a1b678877ffbbc4423a \ + --hash=sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128 \ + --hash=sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c \ + --hash=sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088 \ + --hash=sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9 \ + --hash=sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a \ + --hash=sha256:1ae56aca0402a0f9a3431cddda62ad71666ca9d4dc3a10a142b9dce2e3c0cda3 \ + --hash=sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757 \ + --hash=sha256:1e9a65b5736232e7a7f91ff3d02277f11d339bf34099a56cdab6a8b3410a02b2 \ + --hash=sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438 \ + --hash=sha256:22fc2a8549ffe699bfba2256ab2ed0421a7b8fadff114a3d201794e45a9ff578 \ + --hash=sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b \ + --hash=sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b \ + --hash=sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68 \ + --hash=sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0 \ + --hash=sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d \ + --hash=sha256:2feb1d960f760a575dbc5ab3b1c00504b24caaf6986e2dc2b01c09c87866a943 \ + --hash=sha256:30924eb4c57903d5a7526b08ef4a584acc22ab1ffa085faceb521521d2de32dd \ + --hash=sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409 \ + --hash=sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28 \ + --hash=sha256:38025d9f30cf4634f8309c6874ef871b841eb3c347e90b0851f63d1ded5212da \ + --hash=sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50 \ + --hash=sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f \ + --hash=sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0 \ + --hash=sha256:3ee8a80d67a4334482d9712b8e83ca6b1d9bc7e351931252ebef5d8f7335a547 \ + --hash=sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180 \ + --hash=sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0 \ + --hash=sha256:43ce1b9935bfa1ede40028054d7f48b5469cd02733a365eec8a329ffd342915d \ + --hash=sha256:4410f84b33374409552ac9b6903507cdb31cd30d2501fc5ca13d18f73548444a \ + --hash=sha256:494994f807ba0b92092a163a0a283961369a65f6cbe01e8891132b7a320e61eb \ + --hash=sha256:4d4a848d1837973bf0f4b5e54e3bec977d99be36a7895c61abb659301b02c112 \ + --hash=sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc \ + --hash=sha256:4f3607b129417e111e30637af1b56f24f7a49e64763253bbc275c75fa887d4b2 \ + --hash=sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265 \ + --hash=sha256:524f35912131cc2cabb00edfd8d573b07f2d9f21fa824bd3fb19725a9cf06327 \ + --hash=sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95 \ + --hash=sha256:58d4b711689366d4a03ac7957ab8c28890415e267f9b6589969e74b6e42225ec \ + --hash=sha256:5b3cc074004d968722f51e550b41a27be656ec48f8afaeeb45ebf65b561481dd \ + --hash=sha256:5dab0844f2cf82be357a0eb11a9087f70c5430b2c241493fc122bb6f2bb0917c \ + --hash=sha256:5e55da2c8724191e5b557f8e18943b1b4839b8efc3ef60d65985bcf6f587dd38 \ + --hash=sha256:5eeb539606f18a0b232d4ba45adccde4125592f3f636a6182b4a8a436548b914 \ + --hash=sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0 \ + --hash=sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a \ + --hash=sha256:6172447e1b368dcbc458925e5ddaf9113477b0ed542df258d84fa28fc45ceea7 \ + --hash=sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368 \ + --hash=sha256:6974f52a02321b36847cd19d1b8e381bf39939c21efd6ee2fc13a28b0d99348c \ + --hash=sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0 \ + --hash=sha256:6c6e0c425f22c1c719c42670d561ad682f7bfeeef918edea971a79ac5252437f \ + --hash=sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451 \ + --hash=sha256:7905193081db9bfa73b1219140b3d315831cbff0d8941f22da695832f0dd188f \ + --hash=sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8 \ + --hash=sha256:7c4855522edb2e6ae7fdb58e07c3ba9111e7621a8956f481c68d5d979c93032e \ + --hash=sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248 \ + --hash=sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c \ + --hash=sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91 \ + --hash=sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724 \ + --hash=sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7 \ + --hash=sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966 \ + --hash=sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9 \ + --hash=sha256:890b5a14ce214389b2cc36ce82f3093f96f4cc730c1cffdbefff77a7c71f2a97 \ + --hash=sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d \ + --hash=sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5 \ + --hash=sha256:8dadd1314583ec0bf2d1379f7008ad627cd6336625d6679cf2f8e67081b83acf \ + --hash=sha256:901032ff242d479a0efa956d853d16875d42157f98951c0230f69e69f9c09bac \ + --hash=sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b \ + --hash=sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951 \ + --hash=sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74 \ + --hash=sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648 \ + --hash=sha256:929811df5462e182b13920da56c6e0284af407d1de637d8e536c5cd00a7daf60 \ + --hash=sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c \ + --hash=sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1 \ + --hash=sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8 \ + --hash=sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d \ + --hash=sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc \ + --hash=sha256:a469274ad18dc0e4d316eefa616d1d0c2ff9da369af19fa6f3daa4f09671fd61 \ + --hash=sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460 \ + --hash=sha256:a743e5a28af5f70f9c080380a5f908d4d21d40e8f0e0c8901604d15cfa9ba751 \ + --hash=sha256:a77def80806c421b4b0af06f45d65a136e7ac0bdca3c09d9e2ea4e515367c7e9 \ + --hash=sha256:a7e53012d2853a07a4a79c00643832161a910674a893d296c9f1259859a289d2 \ + --hash=sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0 \ + --hash=sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1 \ + --hash=sha256:ae15b066e5ad21366600ebec29a7ccbc86812ed267e4b28e860b8ca16a2bc474 \ + --hash=sha256:aea440a510e14e818e67bfc4027880e2fb500c2ccb20ab21c7a7c8b5b4703d75 \ + --hash=sha256:af6fa6817889314555aede9a919612b23739395ce767fe7fcbea9a80bf140fe5 \ + --hash=sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f \ + --hash=sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2 \ + --hash=sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f \ + --hash=sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb \ + --hash=sha256:c8146669223164fc87a7e3de9f81e9423c67a79d6b3447994dfb9c95da16e2d6 \ + --hash=sha256:c8fd5270e906eef71d4a8d19b7c6a43760c6abcfcc10c9101d14eb2357418de9 \ + --hash=sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111 \ + --hash=sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2 \ + --hash=sha256:cb1dac1770878ade83f2ccdf7d25e494f05c9165f5246b46a621cc849341dc01 \ + --hash=sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467 \ + --hash=sha256:cdbc1fc1bc0bff1cef838eafe581b55bfbffaed4ed0318b724d0b71d4d377619 \ + --hash=sha256:ceb64bbc6eac5a140ca649003756940f8d6a7c444a68af170b3187623b43bebf \ + --hash=sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408 \ + --hash=sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579 \ + --hash=sha256:d192f0f30804e55db0d0e0a35d83a9fead0e9a359a9ed0285dbacea60cc10a84 \ + --hash=sha256:d2b35ca2c7f81d173d2fadc2f4f31e88cc5f7a39ae5b6db5513cf3383b0e0ec7 \ + --hash=sha256:d342778ef319e1026af243ed0a07c97acf3bad33b9f29e7ae6a1f68fd083e90c \ + --hash=sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284 \ + --hash=sha256:d7702622a8b40c49bffb46e1e3ba2e81268d5c04a34f460978c6b5517a34dd52 \ + --hash=sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b \ + --hash=sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59 \ + --hash=sha256:e1140c64812cb9b06c922e77f1c26a75ec5e3f0fb2bf92cc8c58720dec276752 \ + --hash=sha256:e4fe605b917c70283db7dfe5ada75e04561479075761a0b3866c081d035b01c1 \ + --hash=sha256:e6a904cb26bfefc2f0a6f240bdf5233be78cd2488900a2f846f3c3ac8489ab80 \ + --hash=sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839 \ + --hash=sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0 \ + --hash=sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2 \ + --hash=sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3 \ + --hash=sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64 \ + --hash=sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089 \ + --hash=sha256:f296c40e23065d0d6650c4aefe7470d2a25fffda489bcc3eb66083f3ac9f6643 \ + --hash=sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b \ + --hash=sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e \ + --hash=sha256:f733d788519c7e3e71f0855c96618720f5d3d60c3cb829d8bbb722dddce37985 \ + --hash=sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596 \ + --hash=sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2 \ + --hash=sha256:fdc3ff3bfccdc6b9cc7c342c03aa2400683f0cb891d46e94b64a197910dc4064 + # via fonttools +cffi==1.17.1 \ + --hash=sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8 \ + --hash=sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2 \ + --hash=sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1 \ + --hash=sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15 \ + --hash=sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36 \ + --hash=sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824 \ + --hash=sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8 \ + --hash=sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36 \ + --hash=sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17 \ + --hash=sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf \ + --hash=sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc \ + --hash=sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3 \ + --hash=sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed \ + --hash=sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702 \ + --hash=sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1 \ + --hash=sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8 \ + --hash=sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903 \ + --hash=sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6 \ + --hash=sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d \ + --hash=sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b \ + --hash=sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e \ + --hash=sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be \ + --hash=sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c \ + --hash=sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683 \ + --hash=sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9 \ + --hash=sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c \ + --hash=sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8 \ + --hash=sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1 \ + --hash=sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4 \ + --hash=sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655 \ + --hash=sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67 \ + --hash=sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595 \ + --hash=sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0 \ + --hash=sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65 \ + --hash=sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41 \ + --hash=sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6 \ + --hash=sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401 \ + --hash=sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6 \ + --hash=sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3 \ + --hash=sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16 \ + --hash=sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93 \ + --hash=sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e \ + --hash=sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4 \ + --hash=sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964 \ + --hash=sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c \ + --hash=sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576 \ + --hash=sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0 \ + --hash=sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3 \ + --hash=sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662 \ + --hash=sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3 \ + --hash=sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff \ + --hash=sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5 \ + --hash=sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd \ + --hash=sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f \ + --hash=sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5 \ + --hash=sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14 \ + --hash=sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d \ + --hash=sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9 \ + --hash=sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7 \ + --hash=sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382 \ + --hash=sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a \ + --hash=sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e \ + --hash=sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a \ + --hash=sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4 \ + --hash=sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99 \ + --hash=sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87 \ + --hash=sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b + # via weasyprint +cfgv==3.4.0 \ + --hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \ + --hash=sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560 + # via pre-commit +cssselect2==0.8.0 \ + --hash=sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e \ + --hash=sha256:7674ffb954a3b46162392aee2a3a0aedb2e14ecf99fcc28644900f4e6e3e9d3a + # via weasyprint +distlib==0.4.0 \ + --hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \ + --hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d + # via virtualenv +filelock==3.18.0 \ + --hash=sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2 \ + --hash=sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de + # via virtualenv +fonttools==4.59.0 \ + --hash=sha256:052444a5d0151878e87e3e512a1aa1a0ab35ee4c28afde0a778e23b0ace4a7de \ + --hash=sha256:169b99a2553a227f7b5fea8d9ecd673aa258617f466b2abc6091fe4512a0dcd0 \ + --hash=sha256:209b75943d158f610b78320eacb5539aa9e920bee2c775445b2846c65d20e19d \ + --hash=sha256:21e606b2d38fed938dde871c5736822dd6bda7a4631b92e509a1f5cd1b90c5df \ + --hash=sha256:241313683afd3baacb32a6bd124d0bce7404bc5280e12e291bae1b9bba28711d \ + --hash=sha256:26731739daa23b872643f0e4072d5939960237d540c35c14e6a06d47d71ca8fe \ + --hash=sha256:2e7cf8044ce2598bb87e44ba1d2c6e45d7a8decf56055b92906dc53f67c76d64 \ + --hash=sha256:31003b6a10f70742a63126b80863ab48175fb8272a18ca0846c0482968f0588e \ + --hash=sha256:332bfe685d1ac58ca8d62b8d6c71c2e52a6c64bc218dc8f7825c9ea51385aa01 \ + --hash=sha256:37c377f7cb2ab2eca8a0b319c68146d34a339792f9420fca6cd49cf28d370705 \ + --hash=sha256:37e01c6ec0c98599778c2e688350d624fa4770fbd6144551bd5e032f1199171c \ + --hash=sha256:401b1941ce37e78b8fd119b419b617277c65ae9417742a63282257434fd68ea2 \ + --hash=sha256:4536f2695fe5c1ffb528d84a35a7d3967e5558d2af58b4775e7ab1449d65767b \ + --hash=sha256:4c908a7036f0f3677f8afa577bcd973e3e20ddd2f7c42a33208d18bee95cdb6f \ + --hash=sha256:51ab1ff33c19e336c02dee1e9fd1abd974a4ca3d8f7eef2a104d0816a241ce97 \ + --hash=sha256:524133c1be38445c5c0575eacea42dbd44374b310b1ffc4b60ff01d881fabb96 \ + --hash=sha256:57bb7e26928573ee7c6504f54c05860d867fd35e675769f3ce01b52af38d48e2 \ + --hash=sha256:60f6665579e909b618282f3c14fa0b80570fbf1ee0e67678b9a9d43aa5d67a37 \ + --hash=sha256:62224a9bb85b4b66d1b46d45cbe43d71cbf8f527d332b177e3b96191ffbc1e64 \ + --hash=sha256:6770d7da00f358183d8fd5c4615436189e4f683bdb6affb02cad3d221d7bb757 \ + --hash=sha256:6801aeddb6acb2c42eafa45bc1cb98ba236871ae6f33f31e984670b749a8e58e \ + --hash=sha256:70d6b3ceaa9cc5a6ac52884f3b3d9544e8e231e95b23f138bdb78e6d4dc0eae3 \ + --hash=sha256:78813b49d749e1bb4db1c57f2d4d7e6db22c253cb0a86ad819f5dc197710d4b2 \ + --hash=sha256:841b2186adce48903c0fef235421ae21549020eca942c1da773ac380b056ab3c \ + --hash=sha256:84fc186980231a287b28560d3123bd255d3c6b6659828c642b4cf961e2b923d0 \ + --hash=sha256:885bde7d26e5b40e15c47bd5def48b38cbd50830a65f98122a8fb90962af7cd1 \ + --hash=sha256:8b4309a2775e4feee7356e63b163969a215d663399cce1b3d3b65e7ec2d9680e \ + --hash=sha256:8d77f92438daeaddc05682f0f3dac90c5b9829bcac75b57e8ce09cb67786073c \ + --hash=sha256:902425f5afe28572d65d2bf9c33edd5265c612ff82c69e6f83ea13eafc0dcbea \ + --hash=sha256:9bcc1e77fbd1609198966ded6b2a9897bd6c6bcbd2287a2fc7d75f1a254179c5 \ + --hash=sha256:a408c3c51358c89b29cfa5317cf11518b7ce5de1717abb55c5ae2d2921027de6 \ + --hash=sha256:a9bf8adc9e1f3012edc8f09b08336272aec0c55bc677422273e21280db748f7c \ + --hash=sha256:b818db35879d2edf7f46c7e729c700a0bce03b61b9412f5a7118406687cb151d \ + --hash=sha256:b8974b2a266b54c96709bd5e239979cddfd2dbceed331aa567ea1d7c4a2202db \ + --hash=sha256:be392ec3529e2f57faa28709d60723a763904f71a2b63aabe14fee6648fe3b14 \ + --hash=sha256:d3972b13148c1d1fbc092b27678a33b3080d1ac0ca305742b0119b75f9e87e38 \ + --hash=sha256:d40dcf533ca481355aa7b682e9e079f766f35715defa4929aeb5597f9604272e \ + --hash=sha256:e93df708c69a193fc7987192f94df250f83f3851fda49413f02ba5dded639482 \ + --hash=sha256:efd7e6660674e234e29937bc1481dceb7e0336bfae75b856b4fb272b5093c5d4 \ + --hash=sha256:f9b3a78f69dcbd803cf2fb3f972779875b244c1115481dfbdd567b2c22b31f6b \ + --hash=sha256:fa39475eaccb98f9199eccfda4298abaf35ae0caec676ffc25b3a5e224044464 \ + --hash=sha256:fbce6dae41b692a5973d0f2158f782b9ad05babc2c2019a970a1094a23909b1b + # via weasyprint +identify==2.6.13 \ + --hash=sha256:60381139b3ae39447482ecc406944190f690d4a2997f2584062089848361b33b \ + --hash=sha256:da8d6c828e773620e13bfa86ea601c5a5310ba4bcd65edf378198b56a1f9fb32 + # via pre-commit +nodeenv==1.9.1 \ + --hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \ + --hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9 + # via pre-commit +numpy==2.2.6 \ + --hash=sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff \ + --hash=sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47 \ + --hash=sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84 \ + --hash=sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d \ + --hash=sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6 \ + --hash=sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f \ + --hash=sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b \ + --hash=sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49 \ + --hash=sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163 \ + --hash=sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571 \ + --hash=sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42 \ + --hash=sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff \ + --hash=sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491 \ + --hash=sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4 \ + --hash=sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566 \ + --hash=sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf \ + --hash=sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40 \ + --hash=sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd \ + --hash=sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06 \ + --hash=sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282 \ + --hash=sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680 \ + --hash=sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db \ + --hash=sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3 \ + --hash=sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90 \ + --hash=sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1 \ + --hash=sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289 \ + --hash=sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab \ + --hash=sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c \ + --hash=sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d \ + --hash=sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb \ + --hash=sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d \ + --hash=sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a \ + --hash=sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf \ + --hash=sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1 \ + --hash=sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2 \ + --hash=sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a \ + --hash=sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543 \ + --hash=sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00 \ + --hash=sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c \ + --hash=sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f \ + --hash=sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd \ + --hash=sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868 \ + --hash=sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303 \ + --hash=sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83 \ + --hash=sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3 \ + --hash=sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d \ + --hash=sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87 \ + --hash=sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa \ + --hash=sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f \ + --hash=sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae \ + --hash=sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda \ + --hash=sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915 \ + --hash=sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249 \ + --hash=sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de \ + --hash=sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8 + # via opencv-python-headless +opencv-python-headless==4.12.0.88 \ + --hash=sha256:1e58d664809b3350c1123484dd441e1667cd7bed3086db1b9ea1b6f6cb20b50e \ + --hash=sha256:236c8df54a90f4d02076e6f9c1cc763d794542e886c576a6fee46ec8ff75a7a9 \ + --hash=sha256:365bb2e486b50feffc2d07a405b953a8f3e8eaa63865bc650034e5c71e7a5154 \ + --hash=sha256:86b413bdd6c6bf497832e346cd5371995de148e579b9774f8eba686dee3f5528 \ + --hash=sha256:aeb4b13ecb8b4a0beb2668ea07928160ea7c2cd2d9b5ef571bbee6bafe9cc8d0 \ + --hash=sha256:cfdc017ddf2e59b6c2f53bc12d74b6b0be7ded4ec59083ea70763921af2b6c09 \ + --hash=sha256:fde2cf5c51e4def5f2132d78e0c08f9c14783cd67356922182c6845b9af87dbd + # via -r .github\scripts\requirements_dev.in +pdf2image==1.17.0 \ + --hash=sha256:eaa959bc116b420dd7ec415fcae49b98100dda3dd18cd2fdfa86d09f112f6d57 \ + --hash=sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2 + # via -r .github\scripts\requirements_dev.in +pillow==11.3.0 \ + --hash=sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2 \ + --hash=sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214 \ + --hash=sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e \ + --hash=sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59 \ + --hash=sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50 \ + --hash=sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632 \ + --hash=sha256:092c80c76635f5ecb10f3f83d76716165c96f5229addbd1ec2bdbbda7d496e06 \ + --hash=sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a \ + --hash=sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51 \ + --hash=sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced \ + --hash=sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f \ + --hash=sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12 \ + --hash=sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8 \ + --hash=sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6 \ + --hash=sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580 \ + --hash=sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f \ + --hash=sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac \ + --hash=sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860 \ + --hash=sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd \ + --hash=sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722 \ + --hash=sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8 \ + --hash=sha256:23cff760a9049c502721bdb743a7cb3e03365fafcdfc2ef9784610714166e5a4 \ + --hash=sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673 \ + --hash=sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788 \ + --hash=sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542 \ + --hash=sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e \ + --hash=sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd \ + --hash=sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8 \ + --hash=sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523 \ + --hash=sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967 \ + --hash=sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809 \ + --hash=sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477 \ + --hash=sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027 \ + --hash=sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae \ + --hash=sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b \ + --hash=sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c \ + --hash=sha256:48d254f8a4c776de343051023eb61ffe818299eeac478da55227d96e241de53f \ + --hash=sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e \ + --hash=sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b \ + --hash=sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7 \ + --hash=sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27 \ + --hash=sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361 \ + --hash=sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae \ + --hash=sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d \ + --hash=sha256:6359a3bc43f57d5b375d1ad54a0074318a0844d11b76abccf478c37c986d3cfc \ + --hash=sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58 \ + --hash=sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad \ + --hash=sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6 \ + --hash=sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024 \ + --hash=sha256:6a418691000f2a418c9135a7cf0d797c1bb7d9a485e61fe8e7722845b95ef978 \ + --hash=sha256:6abdbfd3aea42be05702a8dd98832329c167ee84400a1d1f61ab11437f1717eb \ + --hash=sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d \ + --hash=sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0 \ + --hash=sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9 \ + --hash=sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f \ + --hash=sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874 \ + --hash=sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa \ + --hash=sha256:7aee118e30a4cf54fdd873bd3a29de51e29105ab11f9aad8c32123f58c8f8081 \ + --hash=sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149 \ + --hash=sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6 \ + --hash=sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d \ + --hash=sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd \ + --hash=sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f \ + --hash=sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c \ + --hash=sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31 \ + --hash=sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e \ + --hash=sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db \ + --hash=sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6 \ + --hash=sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f \ + --hash=sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494 \ + --hash=sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69 \ + --hash=sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94 \ + --hash=sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77 \ + --hash=sha256:97afb3a00b65cc0804d1c7abddbf090a81eaac02768af58cbdcaaa0a931e0b6d \ + --hash=sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7 \ + --hash=sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a \ + --hash=sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438 \ + --hash=sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288 \ + --hash=sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b \ + --hash=sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635 \ + --hash=sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3 \ + --hash=sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d \ + --hash=sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe \ + --hash=sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0 \ + --hash=sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe \ + --hash=sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a \ + --hash=sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805 \ + --hash=sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8 \ + --hash=sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36 \ + --hash=sha256:cadc9e0ea0a2431124cde7e1697106471fc4c1da01530e679b2391c37d3fbb3a \ + --hash=sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b \ + --hash=sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e \ + --hash=sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25 \ + --hash=sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12 \ + --hash=sha256:e5c5858ad8ec655450a7c7df532e9842cf8df7cc349df7225c60d5d348c8aada \ + --hash=sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c \ + --hash=sha256:ea944117a7974ae78059fcc1800e5d3295172bb97035c0c1d9345fca1419da71 \ + --hash=sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d \ + --hash=sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c \ + --hash=sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6 \ + --hash=sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1 \ + --hash=sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50 \ + --hash=sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653 \ + --hash=sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c \ + --hash=sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4 \ + --hash=sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3 + # via + # -r .github\scripts\requirements_dev.in + # pdf2image + # weasyprint +platformdirs==4.3.8 \ + --hash=sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc \ + --hash=sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4 + # via virtualenv +pre-commit==4.3.0 \ + --hash=sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8 \ + --hash=sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16 + # via -r .github\scripts\requirements_dev.in +pycparser==2.22 \ + --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \ + --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc + # via cffi +pydyf==0.11.0 \ + --hash=sha256:0aaf9e2ebbe786ec7a78ec3fbffa4cdcecde53fd6f563221d53c6bc1328848a3 \ + --hash=sha256:394dddf619cca9d0c55715e3c55ea121a9bf9cbc780cdc1201a2427917b86b64 + # via weasyprint +pyphen==0.17.2 \ + --hash=sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd \ + --hash=sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3 + # via weasyprint +pyyaml==6.0.2 \ + --hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \ + --hash=sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48 \ + --hash=sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086 \ + --hash=sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e \ + --hash=sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133 \ + --hash=sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5 \ + --hash=sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484 \ + --hash=sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee \ + --hash=sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5 \ + --hash=sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68 \ + --hash=sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a \ + --hash=sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf \ + --hash=sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99 \ + --hash=sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8 \ + --hash=sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85 \ + --hash=sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19 \ + --hash=sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc \ + --hash=sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a \ + --hash=sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1 \ + --hash=sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317 \ + --hash=sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c \ + --hash=sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631 \ + --hash=sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d \ + --hash=sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652 \ + --hash=sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5 \ + --hash=sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e \ + --hash=sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b \ + --hash=sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8 \ + --hash=sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476 \ + --hash=sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706 \ + --hash=sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563 \ + --hash=sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237 \ + --hash=sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b \ + --hash=sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083 \ + --hash=sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180 \ + --hash=sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425 \ + --hash=sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e \ + --hash=sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f \ + --hash=sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725 \ + --hash=sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183 \ + --hash=sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab \ + --hash=sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774 \ + --hash=sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725 \ + --hash=sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e \ + --hash=sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5 \ + --hash=sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d \ + --hash=sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290 \ + --hash=sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44 \ + --hash=sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed \ + --hash=sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4 \ + --hash=sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba \ + --hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \ + --hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4 + # via pre-commit +tinycss2==1.4.0 \ + --hash=sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7 \ + --hash=sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289 + # via + # cssselect2 + # weasyprint +tinyhtml5==2.0.0 \ + --hash=sha256:086f998833da24c300c414d9fe81d9b368fd04cb9d2596a008421cbc705fcfcc \ + --hash=sha256:13683277c5b176d070f82d099d977194b7a1e26815b016114f581a74bbfbf47e + # via weasyprint +unoserver==3.3.2 \ + --hash=sha256:1eeb7467cf6b56b8eff3b576e2d1b2b2ff4e0eb2052e995ac80a1456de300639 \ + --hash=sha256:87e144f903ee21951b2e06a97549450c13ed7eca5bcebad942d3352d4e882616 + # via -r .github\scripts\requirements_dev.in +virtualenv==20.33.1 \ + --hash=sha256:07c19bc66c11acab6a5958b815cbcee30891cd1c2ccf53785a28651a0d8d8a67 \ + --hash=sha256:1b44478d9e261b3fb8baa5e74a0ca3bc0e05f21aa36167bf9cbf850e542765b8 + # via pre-commit +weasyprint==66.0 \ + --hash=sha256:82b0783b726fcd318e2c977dcdddca76515b30044bc7a830cc4fbe717582a6d0 \ + --hash=sha256:da71dc87dc129ac9cffdc65e5477e90365ab9dbae45c744014ec1d06303dde40 + # via -r .github\scripts\requirements_dev.in +webencodings==0.5.1 \ + --hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \ + --hash=sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923 + # via + # cssselect2 + # tinycss2 + # tinyhtml5 +zopfli==0.2.3.post1 \ + --hash=sha256:0aa5f90d6298bda02a95bc8dc8c3c19004d5a4e44bda00b67ca7431d857b4b54 \ + --hash=sha256:0cc20b02a9531559945324c38302fd4ba763311632d0ec8a1a0aa9c10ea363e6 \ + --hash=sha256:1d8cc06605519e82b16df090e17cb3990d1158861b2872c3117f1168777b81e4 \ + --hash=sha256:1f990634fd5c5c8ced8edddd8bd45fab565123b4194d6841e01811292650acae \ + --hash=sha256:2345e713260a350bea0b01a816a469ea356bc2d63d009a0d777691ecbbcf7493 \ + --hash=sha256:2768c877f76c8a0e7519b1c86c93757f3c01492ddde55751e9988afb7eff64e1 \ + --hash=sha256:29ea74e72ffa6e291b8c6f2504ce6c146b4fe990c724c1450eb8e4c27fd31431 \ + --hash=sha256:34a99592f3d9eb6f737616b5bd74b48a589fdb3cb59a01a50d636ea81d6af272 \ + --hash=sha256:3654bfc927bc478b1c3f3ff5056ed7b20a1a37fa108ca503256d0a699c03bbb1 \ + --hash=sha256:3657e416ffb8f31d9d3424af12122bb251befae109f2e271d87d825c92fc5b7b \ + --hash=sha256:37d011e92f7b9622742c905fdbed9920a1d0361df84142807ea2a528419dea7f \ + --hash=sha256:3827170de28faf144992d3d4dcf8f3998fe3c8a6a6f4a08f1d42c2ec6119d2bb \ + --hash=sha256:39e576f93576c5c223b41d9c780bbb91fd6db4babf3223d2a4fe7bf568e2b5a8 \ + --hash=sha256:3a89277ed5f8c0fb2d0b46d669aa0633123aa7381f1f6118c12f15e0fb48f8ca \ + --hash=sha256:3c163911f8bad94b3e1db0a572e7c28ba681a0c91d0002ea1e4fa9264c21ef17 \ + --hash=sha256:3f0197b6aa6eb3086ae9e66d6dd86c4d502b6c68b0ec490496348ae8c05ecaef \ + --hash=sha256:48dba9251060289101343110ab47c0756f66f809bb4d1ddbb6d5c7e7752115c5 \ + --hash=sha256:4915a41375bdee4db749ecd07d985a0486eb688a6619f713b7bf6fbfd145e960 \ + --hash=sha256:4c1226a7e2c7105ac31503a9bb97454743f55d88164d6d46bc138051b77f609b \ + --hash=sha256:4e50ffac74842c1c1018b9b73875a0d0a877c066ab06bf7cccbaa84af97e754f \ + --hash=sha256:518f1f4ed35dd69ce06b552f84e6d081f07c552b4c661c5312d950a0b764a58a \ + --hash=sha256:5aad740b4d4fcbaaae4887823925166ffd062db3b248b3f432198fc287381d1a \ + --hash=sha256:5f272186e03ad55e7af09ab78055535c201b1a0bcc2944edb1768298d9c483a4 \ + --hash=sha256:5fcfc0dc2761e4fcc15ad5d273b4d58c2e8e059d3214a7390d4d3c8e2aee644e \ + --hash=sha256:60db20f06c3d4c5934b16cfa62a2cc5c3f0686bffe0071ed7804d3c31ab1a04e \ + --hash=sha256:615a8ac9dda265e9cc38b2a76c3142e4a9f30fea4a79c85f670850783bc6feb4 \ + --hash=sha256:6482db9876c68faac2d20a96b566ffbf65ddaadd97b222e4e73641f4f8722fc4 \ + --hash=sha256:6617fb10f9e4393b331941861d73afb119cd847e88e4974bdbe8068ceef3f73f \ + --hash=sha256:676919fba7311125244eb0c4393679ac5fe856e5864a15d122bd815205369fa0 \ + --hash=sha256:6c2d2bc8129707e34c51f9352c4636ca313b52350bbb7e04637c46c1818a2a70 \ + --hash=sha256:71390dbd3fbf6ebea9a5d85ffed8c26ee1453ee09248e9b88486e30e0397b775 \ + --hash=sha256:716cdbfc57bfd3d3e31a58e6246e8190e6849b7dbb7c4ce39ef8bbf0edb8f6d5 \ + --hash=sha256:75a26a2307b10745a83b660c404416e984ee6fca515ec7f0765f69af3ce08072 \ + --hash=sha256:7be5cc6732eb7b4df17305d8a7b293223f934a31783a874a01164703bc1be6cd \ + --hash=sha256:7cce242b5df12b2b172489daf19c32e5577dd2fac659eb4b17f6a6efb446fd5c \ + --hash=sha256:81c341d9bb87a6dbbb0d45d6e272aca80c7c97b4b210f9b6e233bf8b87242f29 \ + --hash=sha256:89899641d4de97dbad8e0cde690040d078b6aea04066dacaab98e0b5a23573f2 \ + --hash=sha256:8d5ab297d660b75c159190ce6d73035502310e40fd35170aed7d1a1aea7ddd65 \ + --hash=sha256:8fbe5bcf10d01aab3513550f284c09fef32f342b36f56bfae2120a9c4d12c130 \ + --hash=sha256:91a2327a4d7e77471fa4fbb26991c6de4a738c6fc6a33e09bb25f56a870a4b7b \ + --hash=sha256:95a260cafd56b8fffa679918937401c80bb38e1681c448b988022e4c3610965d \ + --hash=sha256:96484dc0f48be1c5d7ae9f38ed1ce41e3675fd506b27c11a6607f14b49101e99 \ + --hash=sha256:9a6aec38a989bad7ddd1ef53f1265699e49e294d08231b5313d61293f3cd6237 \ + --hash=sha256:9ba214f4f45bec195ee8559651154d3ac2932470b9d91c5715fc29c013349f8c \ + --hash=sha256:9f4a7ec2770e6af05f5a02733fd3900f30a9cd58e5d6d3727e14c5bcd6e7d587 \ + --hash=sha256:a1cf720896d2ce998bc8e051d4b4ce0d8bec007aab6243102e8e1d22a0b2fb3f \ + --hash=sha256:a241a68581d34d67b40c425cce3d1fd211c092f99d9250947824ccba9f491949 \ + --hash=sha256:a53b18797cdef27e019db595d66c4b077325afe2fd62145953275f53d84ce40c \ + --hash=sha256:a82fc2dbebe6eb908b9c665e71496f8525c1bc4d2e3a7a7722ef2b128b6227c8 \ + --hash=sha256:a86eb88e06bd87e1fff31dac878965c26b0c26db59ddcf78bb0379a954b120de \ + --hash=sha256:aa588b21044f8a74e423d8c8a4c7fc9988501878aacced793467010039c50734 \ + --hash=sha256:b05296e8bc88c92e2b21e0a9bae4740c1551ee613c1d93a51fd28a7a0b2b6fbb \ + --hash=sha256:b0ec13f352ea5ae0fc91f98a48540512eed0767d0ec4f7f3cb92d92797983d18 \ + --hash=sha256:b3df42f52502438ee973042cc551877d24619fa1cd38ef7b7e9ac74200daca8b \ + --hash=sha256:b78008a69300d929ca2efeffec951b64a312e9a811e265ea4a907ab546d79fa6 \ + --hash=sha256:b9026a21b6d41eb0e2e63f5bc1242c3fcc43ecb770963cda99a4307863dac12e \ + --hash=sha256:bbe429fc50686bb2a2608a30843e36fbaa123462a5284f136c7d9e0145220bfd \ + --hash=sha256:bfa1eb759e07d8b7aa7a310a2bc535e127ee70addf90dc8d4b946b593c3e51a8 \ + --hash=sha256:c1e0ed5d84ffa2d677cc9582fc01e61dab2e7ef8b8996e055f0a76167b1b94df \ + --hash=sha256:c4278d1873ce6e803e5d4f8d702fd3026bd67fca744aa98881324d1157ddf748 \ + --hash=sha256:cac2b37ab21c2b36a10b685b1893ebd6b0f83ae26004838ac817680881576567 \ + --hash=sha256:cbe6df25807227519debd1a57ab236f5f6bad441500e85b13903e51f93a43214 \ + --hash=sha256:cd2c002f160502608dcc822ed2441a0f4509c52e86fcfd1a09e937278ed1ca14 \ + --hash=sha256:e0137dd64a493ba6a4be37405cfd6febe650a98cc1e9dca8f6b8c63b1db11b41 \ + --hash=sha256:e63d558847166543c2c9789e6f985400a520b7eacc4b99181668b2c3aeadd352 \ + --hash=sha256:eb45a34f23da4f8bc712b6376ca5396914b0b7c09adbb001dad964eb7f3132f8 \ + --hash=sha256:ecb7572df5372abce8073df078207d9d1749f20b8b136089916a4a0868d56051 \ + --hash=sha256:f12000a6accdd4bf0a3fa6eaa1b1c7a7bc80af0a2edf3f89d770d3dcce1d0e22 \ + --hash=sha256:f7d69c1a7168ad0e9cb864e8663acb232986a0c9c9cb9801f56bf6214f53a54d \ + --hash=sha256:f815fcc2b2a457977724bad97fb4854022980f51ce7b136925e336b530545ae1 \ + --hash=sha256:fc39f5c27f962ec8660d8d20c24762431131b5d8c672b44b0a54cf2b5bcde9b9 + # via fonttools + +# The following packages are considered to be unsafe in a requirements file: +pip==25.2 \ + --hash=sha256:578283f006390f85bb6282dffb876454593d637f5d1be494b5202ce4877e71f2 \ + --hash=sha256:6d67a2b4e7f14d8b31b8b52648866fa717f45a1eb70e83002f4331d07e953717 + # via -r .github\scripts\requirements_dev.in +setuptools==80.9.0 \ + --hash=sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 \ + --hash=sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c + # via -r .github\scripts\requirements_dev.in diff --git a/Dockerfile.dev b/Dockerfile.dev index 48084878d..eba01cf02 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -34,10 +34,10 @@ ENV SETUPTOOLS_USE_DISTUTILS=local \ TMP=/tmp/stirling-pdf # Installation der benötigten Python-Pakete +COPY .github/scripts/requirements_dev.txt /tmp/requirements_dev.txt RUN python3 -m venv --system-site-packages /opt/venv \ && . /opt/venv/bin/activate \ - && pip install --no-cache-dir --upgrade pip setuptools \ - && pip install --no-cache-dir WeasyPrint pdf2image pillow unoserver opencv-python-headless pre-commit + && pip install --no-cache-dir --require-hashes -r /tmp/requirements_dev.txt # Füge den venv-Pfad zur globalen PATH-Variable hinzu, damit die Tools verfügbar sind ENV PATH="/opt/venv/bin:$PATH" From 40cf337b23444a3204b6707a3c423accf1397ffa Mon Sep 17 00:00:00 2001 From: Ludy Date: Sun, 24 Aug 2025 23:08:29 +0200 Subject: [PATCH 40/41] feat(ssrf): enhance private IP detection and IPv6 handling (#4191) # Description of Changes - Refactored `isPrivateAddress` to improve detection of private and local addresses for both IPv4 and IPv6. - Added explicit handling for: - IPv4-mapped IPv6 addresses - IPv6 link-local, site-local, and unique local (fc00::/7) addresses - Additional IPv4 private ranges such as link-local (169.254.0.0/16) - Introduced `normalizeIpv4MappedAddress` to standardize IP checks in cloud metadata detection. - Replaced `switch` statement with modern `switch` expression for cleaner control flow. These changes were made to strengthen SSRF protection by covering more address edge cases, especially in mixed IPv4/IPv6 environments. This also improves detection of cloud metadata endpoints when accessed via IPv4-mapped IPv6 addresses. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../common/service/SsrfProtectionService.java | 94 +++++++++++++++---- 1 file changed, 76 insertions(+), 18 deletions(-) diff --git a/app/common/src/main/java/stirling/software/common/service/SsrfProtectionService.java b/app/common/src/main/java/stirling/software/common/service/SsrfProtectionService.java index 97c2da12e..b58e0d516 100644 --- a/app/common/src/main/java/stirling/software/common/service/SsrfProtectionService.java +++ b/app/common/src/main/java/stirling/software/common/service/SsrfProtectionService.java @@ -1,5 +1,7 @@ package stirling.software.common.service; +import java.net.Inet4Address; +import java.net.Inet6Address; import java.net.InetAddress; import java.net.URI; import java.net.UnknownHostException; @@ -51,16 +53,12 @@ public class SsrfProtectionService { SsrfProtectionLevel level = parseProtectionLevel(config.getLevel()); - switch (level) { - case OFF: - return true; - case MAX: - return isMaxSecurityAllowed(trimmedUrl, config); - case MEDIUM: - return isMediumSecurityAllowed(trimmedUrl, config); - default: - return false; - } + return switch (level) { + case OFF -> true; + case MAX -> isMaxSecurityAllowed(trimmedUrl, config); + case MEDIUM -> isMediumSecurityAllowed(trimmedUrl, config); + default -> false; + }; } private SsrfProtectionLevel parseProtectionLevel(String level) { @@ -172,15 +170,61 @@ public class SsrfProtectionService { } private boolean isPrivateAddress(InetAddress address) { - return address.isSiteLocalAddress() - || address.isAnyLocalAddress() - || isPrivateIPv4Range(address.getHostAddress()); + if (address.isAnyLocalAddress() || address.isLoopbackAddress()) { + return true; + } + + if (address instanceof Inet4Address) { + return isPrivateIPv4Range(address.getHostAddress()); + } + + if (address instanceof Inet6Address addr6) { + if (addr6.isLinkLocalAddress() || addr6.isSiteLocalAddress()) { + return true; + } + + byte[] bytes = addr6.getAddress(); + if (isIpv4MappedAddress(bytes)) { + String ipv4 = + (bytes[12] & 0xff) + + "." + + (bytes[13] & 0xff) + + "." + + (bytes[14] & 0xff) + + "." + + (bytes[15] & 0xff); + return isPrivateIPv4Range(ipv4); + } + + int firstByte = bytes[0] & 0xff; + // Check for IPv6 unique local addresses (fc00::/7) + if ((firstByte & 0xfe) == 0xfc) { + return true; + } + } + + return false; + } + + private boolean isIpv4MappedAddress(byte[] addr) { + if (addr.length != 16) { + return false; + } + for (int i = 0; i < 10; i++) { + if (addr[i] != 0) { + return false; + } + } + // For IPv4-mapped IPv6 addresses, bytes 10 and 11 must be 0xff (i.e., address is ::ffff:w.x.y.z) + return addr[10] == (byte) 0xff && addr[11] == (byte) 0xff; } private boolean isPrivateIPv4Range(String ip) { + // Includes RFC1918, loopback, link-local, and unspecified addresses return ip.startsWith("10.") || ip.startsWith("192.168.") || (ip.startsWith("172.") && isInRange172(ip)) + || ip.startsWith("169.254.") || ip.startsWith("127.") || "0.0.0.0".equals(ip); } @@ -192,17 +236,31 @@ public class SsrfProtectionService { int secondOctet = Integer.parseInt(parts[1]); return secondOctet >= 16 && secondOctet <= 31; } catch (NumberFormatException e) { - return false; } } return false; } private boolean isCloudMetadataAddress(String ip) { + String normalizedIp = normalizeIpv4MappedAddress(ip); // Cloud metadata endpoints for AWS, GCP, Azure, Oracle Cloud, and IBM Cloud - return ip.startsWith("169.254.169.254") // AWS/GCP/Azure - || ip.startsWith("fd00:ec2::254") // AWS IPv6 - || ip.startsWith("169.254.169.253") // Oracle Cloud - || ip.startsWith("169.254.169.250"); // IBM Cloud + return normalizedIp.startsWith("169.254.169.254") // AWS/GCP/Azure + || normalizedIp.startsWith("fd00:ec2::254") // AWS IPv6 + || normalizedIp.startsWith("169.254.169.253") // Oracle Cloud + || normalizedIp.startsWith("169.254.169.250"); // IBM Cloud + } + + private String normalizeIpv4MappedAddress(String ip) { + if (ip == null) { + return ""; + } + if (ip.startsWith("::ffff:")) { + return ip.substring(7); + } + int lastColon = ip.lastIndexOf(':'); + if (lastColon >= 0 && ip.indexOf('.') > lastColon) { + return ip.substring(lastColon + 1); + } + return ip; } } From 3af93f0adb0eec9ef895ba2bb901f7ba812a5f8e Mon Sep 17 00:00:00 2001 From: Ludy Date: Sun, 24 Aug 2025 23:16:55 +0200 Subject: [PATCH 41/41] feat(database,Jwt): relocate backups and Jwt-keys to `config/backup` and add Enterprise cleanup endpoints (#4225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes - **What was changed** - Centralized installation paths: - Introduced `BACKUP_PATH`, `BACKUP_DB_PATH`, and `BACKUP_PRIVATE_KEY_PATH` in `InstallationPathConfig`; `getPrivateKeyPath()` now resolves to `backup/keys` and new `getBackupPath()` returns `backup/db`. - Removed old `PRIVATE_KEY_PATH` and switched all usages to the new locations. - Database service enhancements: - `DatabaseService` now uses `InstallationPathConfig.getBackupPath()` and includes a one-time migration to move existing backups from `config/db/backup` to `config/backup/db` (**@Deprecated(since = "2.0.0", forRemoval = true)**). - Added `deleteAllBackups()` and `deleteLastBackup()` methods and exposed them via a new Enterprise controller. - New Enterprise-only API: - Added `DatabaseControllerEnterprise` with: - `DELETE /api/v1/database/deleteAll` — delete all backup files. - `DELETE /api/v1/database/deleteLast` — delete the most recent backup. - Endpoints gated by `@EnterpriseEndpoint` and `@Conditional(H2SQLCondition.class)`. - Key persistence adjustments: - `KeyPersistenceService` now migrates keys from `config/db/keys` to `config/backup/keys` on startup (**@Deprecated(since = "2.0.0", forRemoval = true)**). - Miscellaneous refactors/fixes: - Switched driver resolution in `DatabaseConfig` to a switch expression. - Corrected HTTP status usage to `HttpStatus.SEE_OTHER`. - Removed constructor `runningEE` flag from `AccountWebController` and replaced EE checks with `@EnterpriseEndpoint`. - Minor test and annotation improvements (e.g., `@Deprecated(since = "0.45.0")`, method references, equals order). - **Why the change was made** - To standardize and future-proof storage locations for both backups and keys under a clear `config/backup` hierarchy. - To give Enterprise admins first-class, safe cleanup endpoints for managing backup retention without manual file operations. - To reduce conditional logic in controllers and rely on declarative EE gating. - To improve maintainability and correctness (status codes, switch expression, null-safety patterns). --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../configuration/InstallationPathConfig.java | 20 +++- .../model/ApplicationPropertiesLogicTest.java | 2 +- .../security/config/AccountWebController.java | 20 ++-- .../configuration/DatabaseConfig.java | 8 +- .../controller/api/DatabaseController.java | 4 +- .../DatabaseControllerEnterprise.java | 101 ++++++++++++++++++ .../security/service/DatabaseService.java | 85 ++++++++++++++- .../service/DatabaseServiceInterface.java | 6 ++ .../service/KeyPersistenceService.java | 31 ++++++ .../proprietary/service/AuditService.java | 4 +- 10 files changed, 252 insertions(+), 29 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/enterprise/DatabaseControllerEnterprise.java diff --git a/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java b/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java index 64fbc41b7..860e02806 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java @@ -14,18 +14,22 @@ public class InstallationPathConfig { private static final String CONFIG_PATH; private static final String CUSTOM_FILES_PATH; private static final String CLIENT_WEBUI_PATH; - private static final String SCRIPTS_PATH; private static final String PIPELINE_PATH; // Config paths private static final String SETTINGS_PATH; private static final String CUSTOM_SETTINGS_PATH; + private static final String SCRIPTS_PATH; + private static final String BACKUP_PATH; + + // Backup paths + private static final String BACKUP_DB_PATH; + private static final String BACKUP_PRIVATE_KEY_PATH; // Custom file paths private static final String STATIC_PATH; private static final String TEMPLATES_PATH; private static final String SIGNATURES_PATH; - private static final String PRIVATE_KEY_PATH; static { BASE_PATH = initializeBasePath(); @@ -41,12 +45,16 @@ public class InstallationPathConfig { SETTINGS_PATH = CONFIG_PATH + "settings.yml"; CUSTOM_SETTINGS_PATH = CONFIG_PATH + "custom_settings.yml"; SCRIPTS_PATH = CONFIG_PATH + "scripts" + File.separator; + BACKUP_PATH = CONFIG_PATH + "backup" + File.separator; + + // Initialize backup paths + BACKUP_DB_PATH = BACKUP_PATH + "db" + File.separator; + BACKUP_PRIVATE_KEY_PATH = BACKUP_PATH + "keys" + File.separator; // Initialize custom file paths STATIC_PATH = CUSTOM_FILES_PATH + "static" + File.separator; TEMPLATES_PATH = CUSTOM_FILES_PATH + "templates" + File.separator; SIGNATURES_PATH = CUSTOM_FILES_PATH + "signatures" + File.separator; - PRIVATE_KEY_PATH = CONFIG_PATH + "db" + File.separator + "keys" + File.separator; } private static String initializeBasePath() { @@ -124,6 +132,10 @@ public class InstallationPathConfig { } public static String getPrivateKeyPath() { - return PRIVATE_KEY_PATH; + return BACKUP_PRIVATE_KEY_PATH; + } + + public static String getBackupPath() { + return BACKUP_DB_PATH; } } 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 da83fd462..c8d877dc9 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 @@ -179,7 +179,7 @@ class ApplicationPropertiesLogicTest { assertEquals(30, t.getOcrMyPdfTimeoutMinutes()); } - @Deprecated + @Deprecated(since = "0.45.0") @Test void enterprise_metadata_defaults() { ApplicationProperties.EnterpriseEdition ee = new ApplicationProperties.EnterpriseEdition(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java index 46d0e7d3d..a61a7b0fa 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java @@ -11,7 +11,6 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; @@ -59,19 +58,16 @@ public class AccountWebController { private final SessionPersistentRegistry sessionPersistentRegistry; // Assuming you have a repository for user operations private final UserRepository userRepository; - private final boolean runningEE; private final TeamRepository teamRepository; public AccountWebController( ApplicationProperties applicationProperties, SessionPersistentRegistry sessionPersistentRegistry, UserRepository userRepository, - TeamRepository teamRepository, - @Qualifier("runningEE") boolean runningEE) { + TeamRepository teamRepository) { this.applicationProperties = applicationProperties; this.sessionPersistentRegistry = sessionPersistentRegistry; this.userRepository = userRepository; - this.runningEE = runningEE; this.teamRepository = teamRepository; } @@ -207,11 +203,9 @@ public class AccountWebController { } @PreAuthorize("hasRole('ROLE_ADMIN')") + @EnterpriseEndpoint @GetMapping("/usage") public String showUsage() { - if (!runningEE) { - return "error"; - } return "usage"; } @@ -243,7 +237,7 @@ public class AccountWebController { // Also check if user is part of the Internal team if (user.getTeam() != null - && user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) { + && TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) { shouldRemove = true; } @@ -362,11 +356,9 @@ public class AccountWebController { teamRepository.findAll().stream() .filter( team -> - !team.getName() - .equals( - stirling.software.proprietary.security - .service.TeamService - .INTERNAL_TEAM_NAME)) + !stirling.software.proprietary.security.service.TeamService + .INTERNAL_TEAM_NAME + .equals(team.getName())) .toList(); model.addAttribute("teams", allTeams); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java index e6afa6e40..625dc041a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java @@ -134,21 +134,21 @@ public class DatabaseConfig { ApplicationProperties.Driver driver = ApplicationProperties.Driver.valueOf(driverName.toUpperCase()); - switch (driver) { + return switch (driver) { case H2 -> { log.debug("H2 driver selected"); - return DatabaseDriver.H2.getDriverClassName(); + yield DatabaseDriver.H2.getDriverClassName(); } case POSTGRESQL -> { log.debug("Postgres driver selected"); - return DatabaseDriver.POSTGRESQL.getDriverClassName(); + yield DatabaseDriver.POSTGRESQL.getDriverClassName(); } default -> { log.warn("{} driver selected", driverName); throw new UnsupportedProviderException( driverName + " is not currently supported"); } - } + }; } catch (IllegalArgumentException e) { log.warn("Unknown driver: {}", driverName); throw new UnsupportedProviderException(driverName + " is not currently supported"); 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 dec64c46f..ca520a20d 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 @@ -7,10 +7,10 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; -import org.eclipse.jetty.http.HttpStatus; import org.springframework.context.annotation.Conditional; import org.springframework.core.io.InputStreamResource; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -145,7 +145,7 @@ public class DatabaseController { .body(resource); } catch (IOException e) { log.error("Error downloading file: {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.SEE_OTHER_303) + return ResponseEntity.status(HttpStatus.SEE_OTHER) .location(URI.create("/database?error=downloadFailed")) .build(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/enterprise/DatabaseControllerEnterprise.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/enterprise/DatabaseControllerEnterprise.java new file mode 100644 index 000000000..b1da460b0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/enterprise/DatabaseControllerEnterprise.java @@ -0,0 +1,101 @@ +package stirling.software.proprietary.security.controller.api.enterprise; + +import java.util.List; + +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.context.annotation.Conditional; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.FileInfo; +import stirling.software.proprietary.security.config.EnterpriseEndpoint; +import stirling.software.proprietary.security.database.H2SQLCondition; +import stirling.software.proprietary.security.service.DatabaseService; + +@Slf4j +@Controller +@RequestMapping("/api/v1/database") +@PreAuthorize("hasRole('ROLE_ADMIN')") +@EnterpriseEndpoint +@Conditional(H2SQLCondition.class) +@Tag(name = "Database", description = "Database APIs for backup, import, and management") +@RequiredArgsConstructor +public class DatabaseControllerEnterprise { + + private final DatabaseService databaseService; + + @Operation( + summary = "Delete the last database backup file", + description = + "Only Enterprise - Deletes the last database backup file from the server.") + @DeleteMapping("/deleteLast") + public ResponseEntity deleteLastFile() { + log.info("Deleting last database backup file..."); + List> results = databaseService.deleteLastBackup(); + return getDeleteAllResults(results); + } + + @Operation( + summary = "Delete all database backup files", + description = "Only Enterprise - Deletes all database backup files from the server.") + @DeleteMapping("/deleteAll") + public ResponseEntity deleteAllFiles() { + log.info("Deleting all database backup files..."); + List> results = databaseService.deleteAllBackups(); + return getDeleteAllResults(results); + } + + private ResponseEntity getDeleteAllResults(List> results) { + if (results.isEmpty()) { + log.info("No backup files found to delete."); + return ResponseEntity.ok(new DeleteAllResult(List.of(), List.of(), "noContent")); + } + + List deleted = + results.stream() + .filter(p -> Boolean.TRUE.equals(p.getRight())) + .map(p -> p.getLeft().getFileName()) + .toList(); + + List failed = + results.stream() + .filter(p -> !Boolean.TRUE.equals(p.getRight())) + .map(p -> p.getLeft().getFileName()) + .toList(); + + log.info("Deleted backup files: {}", deleted); + if (!failed.isEmpty()) { + log.warn("Some backup files could not be deleted: {}", failed); + return ResponseEntity.status(HttpStatus.MULTI_STATUS) // 207 + .body(new DeleteAllResult(deleted, failed, "partialFailure")); + } + DeleteAllResult result = new DeleteAllResult(deleted, failed, "ok"); + log.debug( + "DeleteAllResult: deleted={}, failed={}, status={}", + result.deleted, + result.failed, + result.status); + return ResponseEntity.ok(result); // 200 + } + + private static final class DeleteAllResult { + public final List deleted; + public final List failed; + public final String status; + + public DeleteAllResult(List deleted, List failed, String status) { + this.deleted = deleted; + this.failed = failed; + this.status = status; + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java index 6474ae7ea..1a3f3ee9c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java @@ -5,6 +5,7 @@ import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.nio.file.attribute.BasicFileAttributes; import java.sql.Connection; import java.sql.PreparedStatement; @@ -21,6 +22,7 @@ import java.util.stream.Collectors; import javax.sql.DataSource; +import org.apache.commons.lang3.tuple.Pair; import org.springframework.jdbc.datasource.init.CannotReadScriptException; import org.springframework.jdbc.datasource.init.ScriptException; import org.springframework.stereotype.Service; @@ -45,10 +47,39 @@ public class DatabaseService implements DatabaseServiceInterface { public DatabaseService( ApplicationProperties.Datasource datasourceProps, DataSource dataSource) { - this.BACKUP_DIR = - Paths.get(InstallationPathConfig.getConfigPath(), "db", "backup").normalize(); + this.BACKUP_DIR = Paths.get(InstallationPathConfig.getBackupPath()).normalize(); this.datasourceProps = datasourceProps; this.dataSource = dataSource; + moveBackupFiles(); + } + + /** Move all backup files from db/backup to backup/db */ + @Deprecated(since = "2.0.0", forRemoval = true) + private void moveBackupFiles() { + Path sourceDir = + Paths.get(InstallationPathConfig.getConfigPath(), "db", "backup").normalize(); + + if (!Files.exists(sourceDir)) { + log.info("Source directory does not exist: {}", sourceDir); + return; + } + + try { + Files.createDirectories(BACKUP_DIR); + try (DirectoryStream stream = Files.newDirectoryStream(sourceDir)) { + for (Path entry : stream) { + if (entry.getFileName().toString().startsWith(BACKUP_PREFIX) + && entry.getFileName().toString().endsWith(SQL_SUFFIX)) { + Files.move( + entry, + BACKUP_DIR.resolve(entry.getFileName()), + StandardCopyOption.REPLACE_EXISTING); + } + } + } + } catch (IOException e) { + log.error("Error moving backup files: {}", e.getMessage(), e); + } } /** @@ -198,6 +229,46 @@ public class DatabaseService implements DatabaseServiceInterface { } } + @Override + public List> deleteAllBackups() { + List backupList = this.getBackupList(); + List> deletedFiles = new ArrayList<>(); + + for (FileInfo backup : backupList) { + try { + Files.deleteIfExists(Paths.get(backup.getFilePath())); + deletedFiles.add(Pair.of(backup, true)); + } catch (IOException e) { + log.error("Error deleting backup file: {}", backup.getFileName(), e); + deletedFiles.add(Pair.of(backup, false)); + } + } + return deletedFiles; + } + + @Override + public List> deleteLastBackup() { + + List backupList = this.getBackupList(); + List> deletedFiles = new ArrayList<>(); + if (!backupList.isEmpty()) { + FileInfo lastBackup = backupList.get(backupList.size() - 1); + try { + Files.deleteIfExists(Paths.get(lastBackup.getFilePath())); + deletedFiles.add(Pair.of(lastBackup, true)); + } catch (IOException e) { + log.error("Error deleting last backup file: {}", lastBackup.getFileName(), e); + deletedFiles.add(Pair.of(lastBackup, false)); + } + } + return deletedFiles; + } + + /** + * Deletes the oldest backup file from the specified list. + * + * @param filteredBackupList the list of backup files + */ private static void deleteOldestBackup(List filteredBackupList) { try { filteredBackupList.sort( @@ -237,6 +308,11 @@ public class DatabaseService implements DatabaseServiceInterface { return version; } + /* + * Checks if the current datasource is H2. + * + * @return true if the datasource is H2, false otherwise + */ private boolean isH2Database() { boolean isTypeH2 = datasourceProps.getType().equalsIgnoreCase(ApplicationProperties.Driver.H2.name()); @@ -301,6 +377,11 @@ public class DatabaseService implements DatabaseServiceInterface { return filePath; } + /** + * Executes a database script. + * + * @param scriptPath the path to the script file + */ private void executeDatabaseScript(Path scriptPath) { if (isH2Database()) { String query = "RUNSCRIPT from ?;"; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseServiceInterface.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseServiceInterface.java index 613432f0a..d0ba033d6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseServiceInterface.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseServiceInterface.java @@ -3,6 +3,8 @@ package stirling.software.proprietary.security.service; import java.sql.SQLException; import java.util.List; +import org.apache.commons.lang3.tuple.Pair; + import stirling.software.common.model.FileInfo; import stirling.software.common.model.exception.UnsupportedProviderException; @@ -14,4 +16,8 @@ public interface DatabaseServiceInterface { boolean hasBackup(); List getBackupList(); + + List> deleteAllBackups(); + + List> deleteLastBackup(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java index cc07fbbc7..eb02b6368 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java @@ -1,9 +1,11 @@ package stirling.software.proprietary.security.service; import java.io.IOException; +import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.security.KeyFactory; import java.security.KeyPair; import java.security.KeyPairGenerator; @@ -52,6 +54,34 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface { this.verifyingKeyCache = cacheManager.getCache("verifyingKeys"); } + /** Move all key files from db/keys to backup/keys */ + @Deprecated(since = "2.0.0", forRemoval = true) + private void moveKeysToBackup() { + Path sourceDir = + Paths.get(InstallationPathConfig.getConfigPath(), "db", "keys").normalize(); + + if (!Files.exists(sourceDir)) { + log.info("Source directory does not exist: {}", sourceDir); + return; + } + + Path targetDir = Paths.get(InstallationPathConfig.getPrivateKeyPath()).normalize(); + + try { + Files.createDirectories(targetDir); + try (DirectoryStream stream = Files.newDirectoryStream(sourceDir)) { + for (Path entry : stream) { + Files.move( + entry, + targetDir.resolve(entry.getFileName()), + StandardCopyOption.REPLACE_EXISTING); + } + } + } catch (IOException e) { + log.error("Error moving key files to backup: {}", e.getMessage(), e); + } + } + @PostConstruct public void initializeKeystore() { if (!isKeystoreEnabled()) { @@ -59,6 +89,7 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface { } try { + moveKeysToBackup(); ensurePrivateKeyDirectoryExists(); loadKeyPair(); } catch (Exception e) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java index 73b57286b..57ae13c18 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.service; import java.util.Map; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.actuate.audit.AuditEvent; import org.springframework.boot.actuate.audit.AuditEventRepository; import org.springframework.security.core.Authentication; @@ -29,8 +30,7 @@ public class AuditService { public AuditService( AuditEventRepository repository, AuditConfigurationProperties auditConfig, - @org.springframework.beans.factory.annotation.Qualifier("runningEE") - boolean runningEE) { + @Qualifier("runningEE") boolean runningEE) { this.repository = repository; this.auditConfig = auditConfig; this.runningEE = runningEE;