diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index 2a386d1a5a..795bbeee17 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -58,16 +58,18 @@ jobs: MAVEN_USER: ${{ secrets.MAVEN_USER }} MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} - run: ./gradlew :stirling-pdf:bootJar -PnoSpotless --no-daemon + run: ./gradlew :stirling-pdf:quarkusBuild -PnoSpotless --no-daemon - name: Locate built JAR id: jar run: | - jar=$(find app/core/build/libs -maxdepth 1 -name 'Stirling-PDF*.jar' -o -name 'stirling-pdf*.jar' 2>/dev/null \ - | grep -vE '(-plain|-sources)\.jar$' | head -n 1) + # Quarkus (quarkus.package.jar.type=uber-jar) emits a standalone runnable + # jar at app/core/build/-runner.jar, replacing the Spring Boot bootJar + # that used to land in app/core/build/libs. + jar=$(find app/core/build -maxdepth 1 -name '*-runner.jar' 2>/dev/null | head -n 1) if [[ -z "$jar" ]]; then - echo "::error::No JAR under app/core/build/libs" - ls -lah app/core/build/libs || true + echo "::error::No *-runner.jar under app/core/build" + ls -lah app/core/build || true exit 1 fi # Absolute path - the migration script pushd's into a temp workdir diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2490b4ae6e..33366152e3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: hooks: - id: codespell args: - - --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment + - --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment,vertx - --skip="./.*,*.csv,*.json,*.ambr" - --quiet-level=2 files: \.(html|css|js|py|md)$ 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 9fe4a8bd6b..64554dca47 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 @@ -13,8 +13,7 @@ import java.util.stream.Stream; import org.eclipse.microprofile.config.Config; import org.eclipse.microprofile.config.inject.ConfigProperty; -import io.quarkus.arc.DefaultBean; -import io.quarkus.arc.profile.UnlessBuildProfile; +import io.quarkus.arc.profile.IfBuildProfile; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.Dependent; @@ -318,29 +317,28 @@ public class AppConfig { return applicationProperties.getSystem().getDatasource(); } - // @UnlessBuildProfile("saas"): in the saas flavor SaasLicenseOverride provides these @Named - // beans (every tenant is ENTERPRISE). @DefaultBean alone is not enough - Qute's named-bean - // validation still sees this @DefaultBean producer alongside the saas one and rejects the - // duplicate @Named key, so this default must be vetoed outright under the saas profile. + // @IfBuildProfile("core"): these NORMAL/default license @Named beans apply only to the core + // flavor. In proprietary EEAppConfig provides them (security profile) and in saas + // SaasLicenseOverride does (saas profile); registering this producer alongside those trips + // Qute's named-bean validation ("Duplicate key runningEE"), which does not honour @DefaultBean + // suppression - so gate to core outright. (In core, EEAppConfig/SaasLicenseOverride are not + // even on the classpath.) @Produces - @DefaultBean - @UnlessBuildProfile("saas") + @IfBuildProfile("core") @Named("runningProOrHigher") public boolean runningProOrHigher() { return false; } @Produces - @DefaultBean - @UnlessBuildProfile("saas") + @IfBuildProfile("core") @Named("runningEE") public boolean runningEnterprise() { return false; } @Produces - @DefaultBean - @UnlessBuildProfile("saas") + @IfBuildProfile("core") @Named("license") public String licenseType() { return "NORMAL"; diff --git a/app/common/src/main/java/stirling/software/common/configuration/SettingsYamlConfigSource.java b/app/common/src/main/java/stirling/software/common/configuration/SettingsYamlConfigSource.java new file mode 100644 index 0000000000..f96214d750 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/configuration/SettingsYamlConfigSource.java @@ -0,0 +1,120 @@ +package stirling.software.common.configuration; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.eclipse.microprofile.config.spi.ConfigSource; +import org.snakeyaml.engine.v2.api.Load; +import org.snakeyaml.engine.v2.api.LoadSettings; + +/** + * Exposes {@code settings.yml} (and {@code custom_settings.yml}, with the bundled {@code + * settings.yml.template} as the default fallback) as a MicroProfile/SmallRye {@link ConfigSource}. + * + *

Restores the Spring {@code @ConfigurationProperties} behaviour that bound {@code settings.yml} + * into {@code ApplicationProperties}: without this the YAML was never read under Quarkus, so flags + * like {@code security.enableLogin} fell back to their Java defaults regardless of the file (the + * {@code enableLogin=false}/{@code maxDPI=0}/{@code loginAttemptCount=0} class of bugs). The nested + * YAML is flattened to dotted keys ({@code security.enableLogin -> "true"}); {@link + * ApplicationPropertiesConfigOverlay} and {@code @ConfigProperty} injections then read them. + * + *

Ordinal {@value #ORDINAL} sits above {@code application.properties} (250) but below + * environment variables (300) and system properties (400), matching Spring's precedence - e.g. + * {@code SECURITY_ENABLELOGIN} still overrides the file. + * + *

Registered via {@code META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource}. + */ +public class SettingsYamlConfigSource implements ConfigSource { + + private static final int ORDINAL = 275; + + private final Map properties; + + public SettingsYamlConfigSource() { + this.properties = load(); + } + + private static Map load() { + Map flat = new HashMap<>(); + // 1. Bundled template provides the defaults (e.g. security.enableLogin: true). + try (InputStream in = + SettingsYamlConfigSource.class + .getClassLoader() + .getResourceAsStream("settings.yml.template")) { + if (in != null) { + flatten("", loadYaml(in), flat); + } + } catch (Exception ignored) { + // best effort - fall through to file overrides / Java defaults + } + // 2. The user's settings.yml overrides the template. + mergeFile(InstallationPathConfig.getSettingsPath(), flat); + // 3. custom_settings.yml overrides settings.yml. + mergeFile(InstallationPathConfig.getCustomSettingsPath(), flat); + return flat; + } + + private static void mergeFile(String path, Map flat) { + try { + Path p = Path.of(path); + if (Files.isRegularFile(p)) { + try (InputStream in = Files.newInputStream(p)) { + flatten("", loadYaml(in), flat); + } + } + } catch (Exception ignored) { + // unreadable/invalid file - keep whatever defaults were already loaded + } + } + + private static Object loadYaml(InputStream in) { + return new Load(LoadSettings.builder().build()).loadFromInputStream(in); + } + + private static void flatten(String prefix, Object node, Map out) { + if (node instanceof Map map) { + for (Map.Entry e : map.entrySet()) { + String key = + prefix.isEmpty() ? String.valueOf(e.getKey()) : prefix + "." + e.getKey(); + flatten(key, e.getValue(), out); + } + } else if (node instanceof List) { + // MicroProfile list binding uses indexed keys; the ApplicationProperties overlay reads + // scalar values only, so skip lists rather than emit a malformed "[a, b]" value. + return; + } else if (node != null) { + out.put(prefix, String.valueOf(node)); + } + // null leaves are left unset so the Java default applies. + } + + @Override + public Map getProperties() { + return properties; + } + + @Override + public Set getPropertyNames() { + return properties.keySet(); + } + + @Override + public String getValue(String propertyName) { + return properties.get(propertyName); + } + + @Override + public String getName() { + return "settings.yml"; + } + + @Override + public int getOrdinal() { + return ORDINAL; + } +} diff --git a/app/common/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource b/app/common/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource new file mode 100644 index 0000000000..66d999dc65 --- /dev/null +++ b/app/common/src/main/resources/META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource @@ -0,0 +1 @@ +stirling.software.common.configuration.SettingsYamlConfigSource diff --git a/app/core/build.gradle b/app/core/build.gradle index c89d0c4024..a684937973 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -341,8 +341,20 @@ if (buildWithFrontend) { // DatabaseService/NoOpDatabaseService both stay active (ambiguous). The profile is fixed at build // time by @IfBuildProfile, so this must be set before quarkusBuild runs; doing it at configuration // time (guarded by the flavor) keeps core/proprietary builds on the default profile. +// Map the build flavor to a Quarkus feature profile, replacing Spring's runtime profile +// auto-detection (former getActiveProfile() -> setAdditionalProfiles). @IfBuildProfile/@Unless +// gate beans on these. Set as the profile PARENT (not the profile itself) for security/core so the +// run-mode profile (prod for quarkusBuild, dev for quarkusDev) - and dev services/live reload - +// stay intact; saas keeps using its own "saas" profile. +// core -> "core" (default license beans only) +// proprietary -> "security" (EEAppConfig: security & !saas) +// saas -> "saas" (SaasLicenseOverride) if (gradle.ext.enableSaas) { System.setProperty('quarkus.profile', 'saas') +} else if (!gradle.ext.disableAdditional) { + System.setProperty('quarkus.config.profile.parent', 'security') +} else { + System.setProperty('quarkus.config.profile.parent', 'core') } // The Quarkus build must see the library modules' jars (and their Jandex indexes) on the @@ -404,3 +416,56 @@ tasks.named("quarkusDev") { jvmArgs = runtimeArgs } + +// --------------------------------------------------------------------------- +// Compatibility aliases for the Spring Boot Gradle tasks removed in the Quarkus +// migration. CI workflows, the Taskfile and desktop builds still invoke these +// task names; each delegates to the Quarkus equivalent so those callers keep +// working without a sweeping rename across the build scripts. +// --------------------------------------------------------------------------- + +// bootRun -> quarkusDev: run the application from source (used by the e2e and +// dev tasks to boot a live :8080 server). +tasks.register('bootRun') { + group = 'application' + description = 'Compatibility alias for the removed Spring Boot bootRun; runs quarkusDev.' + dependsOn 'quarkusDev' +} + +// bootJar -> quarkusBuild: produce the runnable uber-jar at +// app/core/build/-runner.jar (quarkus.package.jar.type=uber-jar). +tasks.register('bootJar') { + group = 'build' + description = 'Compatibility alias for the removed Spring Boot bootJar; runs quarkusBuild.' + dependsOn 'quarkusBuild' +} + +// OpenAPI doc generation replacing springdoc's generateOpenApiDocs/copySwaggerDoc. quarkusBuild +// exports the SmallRye schema (quarkus.smallrye-openapi.store-schema-directory) during augmentation; +// copySwaggerDoc publishes that openapi.json to SwaggerDoc.json at the repo root, which the +// check-openapi workflow and the ai-engine tool-models step consume. +tasks.register('copySwaggerDoc') { + group = 'documentation' + description = 'Publish the SmallRye OpenAPI schema to SwaggerDoc.json at the repo root.' + dependsOn 'quarkusBuild' + doLast { + def schema = fileTree(layout.buildDirectory) + .matching { include '**/openapi-schema/openapi.json' } + .files + .find { it != null } + if (schema == null) { + throw new GradleException( + "No openapi-schema/openapi.json found under ${layout.buildDirectory.get()}; " + + "check quarkus.smallrye-openapi.store-schema-directory.") + } + def target = new File(rootProject.projectDir, 'SwaggerDoc.json') + target.text = schema.text + logger.lifecycle("Wrote OpenAPI schema (${schema.length()} bytes) to ${target}") + } +} + +tasks.register('generateOpenApiDocs') { + group = 'documentation' + description = 'Compatibility alias for the removed springdoc generateOpenApiDocs.' + dependsOn 'copySwaggerDoc' +} diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index 939d9af03f..3d98b52b2f 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -95,6 +95,9 @@ quarkus.hibernate-orm.mapping.format.global=ignore quarkus.smallrye-openapi.path=/v1/api-docs quarkus.swagger-ui.path=/swagger-ui.html quarkus.swagger-ui.always-include=true +# Export the schema at build time (during quarkusBuild) so the copySwaggerDoc Gradle task can +# publish it as SwaggerDoc.json - the build-time replacement for springdoc's generateOpenApiDocs. +quarkus.smallrye-openapi.store-schema-directory=build/openapi-schema # ---- Jackson (was spring.jackson.*) ---------------------------------------------------------- # spring.jackson.deserialization.fail-on-null-for-primitives=false 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 deleted file mode 100644 index d8f63e6264..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java +++ /dev/null @@ -1,314 +0,0 @@ -package stirling.software.proprietary.security; - -import java.io.IOException; -import java.security.cert.X509Certificate; -import java.security.interfaces.RSAPrivateKey; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; - -import com.coveo.saml.SamlClient; -import com.coveo.saml.SamlException; - -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.inject.Inject; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import stirling.software.common.configuration.AppConfig; -import stirling.software.common.model.ApplicationProperties; -import stirling.software.common.model.ApplicationProperties.Security.OAUTH2; -import stirling.software.common.model.ApplicationProperties.Security.SAML2; -import stirling.software.common.model.io.Resource; -import stirling.software.common.model.oauth2.KeycloakProvider; -import stirling.software.common.util.RegexPatternUtils; -import stirling.software.common.util.UrlUtils; -import stirling.software.proprietary.audit.AuditEventType; -import stirling.software.proprietary.audit.AuditLevel; -import stirling.software.proprietary.audit.Audited; -import stirling.software.proprietary.security.saml2.CertificateUtils; -import stirling.software.proprietary.security.service.JwtServiceInterface; -import stirling.software.proprietary.service.AiUserDataService; - -// TODO: Migration required - this class was a Spring Security -// SimpleUrlLogoutSuccessHandler wired into the Spring Security logout filter chain. -// Quarkus has no LogoutSuccessHandler equivalent. The logout endpoint must be rehosted -// (e.g. a JAX-RS resource or jakarta.servlet endpoint) that invokes onLogoutSuccess(...) -// after the Quarkus security/session logout has run. Configure HTTP auth/logout policies -// via quarkus.http.auth.* and quarkus-oidc (for OAuth2/OIDC logout). -@Slf4j -@ApplicationScoped -@RequiredArgsConstructor(onConstructor_ = {@Inject}) -public class CustomLogoutSuccessHandler { - - public static final String LOGOUT_PATH = "/login?logout=true"; - - private final ApplicationProperties.Security securityProperties; - - private final AppConfig appConfig; - - private final JwtServiceInterface jwtService; - - private final AiUserDataService aiUserDataService; - - // TODO: Migration required - Spring's AuthenticationTrustResolver - // (used to filter out the anonymous principal) has no direct Quarkus equivalent. - // Under Quarkus, an unauthenticated request yields an anonymous SecurityIdentity - // (SecurityIdentity#isAnonymous()); use that check in resolveUsername(...) instead. - - @Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC) - public void onLogoutSuccess( - HttpServletRequest request, HttpServletResponse response, Object authentication) - throws IOException { - - String username = resolveUsername(request, authentication); - if (username != null) { - aiUserDataService.purgeUserDocuments(username); - } - - if (!response.isCommitted()) { - if (authentication != null) { - // TODO: Migration required - the original code branched on the Spring - // Authentication implementation type to choose a logout redirect: - // Saml2Authentication -> getRedirect_saml2(...) - // OAuth2AuthenticationToken -> getRedirect_oauth2(...) - // UsernamePasswordAuthentication -> redirect to LOGOUT_PATH - // unknown -> log + redirect to LOGOUT_PATH - // Under Quarkus the authentication mechanism is identified differently - // (SecurityIdentity attributes / quarkus-oidc vs form auth, or the IdP - // recorded at login). Re-wire this dispatch to invoke getRedirect_saml2 / - // getRedirect_oauth2 once the Quarkus identity model is in place. Until - // then we fall through to the default login-page redirect to preserve - // safe behavior (a single redirect, never IdP logout with a null subject). - response.sendRedirect(LOGOUT_PATH); - } else { - if (jwtService != null) { - String token = jwtService.extractToken(request); - if (token != null && !token.isBlank()) { - response.sendRedirect(LOGOUT_PATH); - return; - } - } - // Redirect to login page after logout - String path = checkForErrors(request); - response.sendRedirect(path); - } - } - } - - /** - * Pick the right name to purge under. JWT cookie wins if present and parseable; we fall through - * to whatever the authentication handed us only when there's no cookie. The anonymous principal - * is filtered out so we don't purge under that pseudo-user. - */ - private String resolveUsername(HttpServletRequest request, Object authentication) { - if (jwtService != null) { - String fromCookie = jwtService.extractUsernameFromRequestAllowExpired(request); - if (fromCookie != null) { - return fromCookie; - } - } - // TODO: Migration required - replace the Spring AuthenticationTrustResolver - // anonymous check and Authentication#getName() with SecurityIdentity: - // if (identity == null || identity.isAnonymous()) return null; - // String name = identity.getPrincipal().getName(); - if (authentication == null) { - return null; - } - return null; - } - - // Redirect for SAML2 authentication logout - // TODO: Migration required - parameter was Spring Saml2Authentication; the SAML2 - // principal (CustomSaml2AuthenticatedPrincipal) must be recovered from the Quarkus - // identity once the SAML SP is rehosted on OpenSAML 5 (see SAML2 migration plan). - private void getRedirect_saml2( - HttpServletRequest request, HttpServletResponse response, Object samlAuthentication) - throws IOException { - - SAML2 samlConf = securityProperties.getSaml2(); - String registrationId = samlConf.getRegistrationId(); - - // TODO: Migration required - extract the SAML NameID from the Quarkus identity. - // Original: - // CustomSaml2AuthenticatedPrincipal principal = - // (CustomSaml2AuthenticatedPrincipal) samlAuthentication.getPrincipal(); - // String nameIdValue = principal.name(); - String nameIdValue = null; - - try { - // Read certificate from the resource - Resource certificateResource = samlConf.getSpCert(); - // TODO: Migration required - CertificateUtils still declares - // org.springframework.core.io.Resource parameters; once it is migrated to - // stirling.software.common.model.io.Resource these calls compile directly. - X509Certificate certificate = CertificateUtils.readCertificate(certificateResource); - - List certificates = new ArrayList<>(); - certificates.add(certificate); - - // Construct URLs required for SAML configuration - SamlClient samlClient = getSamlClient(registrationId, samlConf, certificates); - - // Read private key for service provider - Resource privateKeyResource = samlConf.getPrivateKey(); - RSAPrivateKey privateKey = CertificateUtils.readPrivateKey(privateKeyResource); - - // Set service provider keys for the SamlClient - samlClient.setSPKeys(certificate, privateKey); - - // Build relay state to return user to login page after IdP logout - String relayState = - UrlUtils.getOrigin(request) + request.getContextPath() + LOGOUT_PATH; - - // Redirect to identity provider for logout with relay state - samlClient.redirectToIdentityProvider(response, relayState, nameIdValue); - } catch (Exception e) { - log.error( - "Error retrieving logout URL from Provider {} for user {}", - samlConf.getProvider(), - nameIdValue, - e); - response.sendRedirect(LOGOUT_PATH); - } - } - - // Redirect for OAuth2 authentication logout - // TODO: Migration required - parameter was Spring OAuth2AuthenticationToken; under - // quarkus-oidc the authorized client registration id must be obtained from the OIDC - // configuration / SecurityIdentity rather than the token. - private void getRedirect_oauth2( - HttpServletRequest request, HttpServletResponse response, Object oAuthToken) - throws IOException { - String registrationId; - OAUTH2 oauth = securityProperties.getOauth2(); - String path = checkForErrors(request); - - String redirectUrl = UrlUtils.getOrigin(request) + "/login?" + path; - // TODO: Migration required - original: - // registrationId = oAuthToken.getAuthorizedClientRegistrationId(); - // Resolve the OIDC provider id from quarkus-oidc config / SecurityIdentity instead. - registrationId = ""; - - // Redirect based on OAuth2 provider - switch (registrationId.toLowerCase(Locale.ROOT)) { - case "keycloak" -> { - KeycloakProvider keycloak = oauth.getClient().getKeycloak(); - - boolean isKeycloak = !keycloak.getIssuer().isBlank(); - boolean isCustomOAuth = !oauth.getIssuer().isBlank(); - - String logoutUrl = redirectUrl; - - if (isKeycloak) { - logoutUrl = keycloak.getIssuer(); - } else if (isCustomOAuth) { - logoutUrl = oauth.getIssuer(); - } - if (isKeycloak || isCustomOAuth) { - logoutUrl += - "/protocol/openid-connect/logout" - + "?client_id=" - + oauth.getClientId() - + "&post_logout_redirect_uri=" - + response.encodeRedirectURL(redirectUrl); - log.info("Redirecting to Keycloak logout URL: {}", logoutUrl); - } else { - log.info( - "No redirect URL for {} available. Redirecting to default logout URL:" - + " {}", - registrationId, - logoutUrl); - } - response.sendRedirect(logoutUrl); - } - case "github", "google" -> { - log.info( - "No redirect URL for {} available. Redirecting to default logout URL: {}", - registrationId, - redirectUrl); - response.sendRedirect(redirectUrl); - } - default -> { - log.info("Redirecting to default logout URL: {}", redirectUrl); - response.sendRedirect(redirectUrl); - } - } - } - - private SamlClient getSamlClient( - String registrationId, SAML2 samlConf, List certificates) - throws SamlException { - String serverUrl = appConfig.getBackendUrl() + ":" + appConfig.getServerPort(); - - String relyingPartyIdentifier = - serverUrl + "/saml2/service-provider-metadata/" + registrationId; - - String assertionConsumerServiceUrl = serverUrl + "/login/saml2/sso/" + registrationId; - - String idpSLOUrl = samlConf.getIdpSingleLogoutUrl(); - - String idpIssuer = samlConf.getIdpIssuer(); - - // Create SamlClient instance for SAML logout - return new SamlClient( - relyingPartyIdentifier, - assertionConsumerServiceUrl, - idpSLOUrl, - idpIssuer, - certificates, - SamlClient.SamlIdpBinding.POST); - } - - /** - * Handles different error scenarios during logout. Will return a String containing - * the error request parameter. - * - * @param request the user's HttpServletRequest request. - * @return a String containing the error request parameter. - */ - private String checkForErrors(HttpServletRequest request) { - String errorMessage; - String path = "logout=true"; - - if (request.getParameter("oAuth2AuthenticationErrorWeb") != null) { - path = "errorOAuth=userAlreadyExistsWeb"; - } else if ((errorMessage = request.getParameter("errorOAuth")) != null) { - path = "errorOAuth=" + sanitizeInput(errorMessage); - } else if (request.getParameter("oAuth2AutoCreateDisabled") != null) { - path = "errorOAuth=oAuth2AutoCreateDisabled"; - } else if (request.getParameter("oAuth2AdminBlockedUser") != null) { - path = "errorOAuth=oAuth2AdminBlockedUser"; - } else if (request.getParameter("oAuth2RequiresLicense") != null) { - path = "errorOAuth=oAuth2RequiresLicense"; - } else if (request.getParameter("saml2RequiresLicense") != null) { - path = "errorOAuth=saml2RequiresLicense"; - } else if (request.getParameter("maxUsersReached") != null) { - path = "errorOAuth=maxUsersReached"; - } else if (request.getParameter("userIsDisabled") != null) { - path = "errorOAuth=userIsDisabled"; - } else if ((errorMessage = request.getParameter("error")) != null) { - path = "errorOAuth=" + sanitizeInput(errorMessage); - } else if (request.getParameter("badCredentials") != null) { - path = "errorOAuth=badCredentials"; - } - - return path; - } - - /** - * Sanitize input to avoid potential security vulnerabilities. Will return a sanitised - * String. - * - * @return a sanitised String - */ - private String sanitizeInput(String input) { - return RegexPatternUtils.getInstance() - .getInputSanitizePattern() - .matcher(input) - .replaceAll(""); - } -} 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 deleted file mode 100644 index e067f5b13a..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java +++ /dev/null @@ -1,112 +0,0 @@ -package stirling.software.proprietary.security.oauth2; - -import java.io.IOException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; - -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -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; - -// TODO: Migration required - this class previously extended Spring Security's -// SimpleUrlAuthenticationFailureHandler and was wired into the OAuth2 login flow as the -// failure handler. quarkus-oidc has no direct equivalent for a servlet -// AuthenticationFailureHandler. Under quarkus-oidc the failure path should be handled via -// quarkus.oidc.* config (e.g. quarkus.oidc.authentication.error-path) plus a -// jakarta.ws.rs.ext.ExceptionMapper / SecurityIdentityAugmentor or a redirect filter that -// inspects the OIDC error and applies the same redirect logic below. The redirect-building -// logic (Tauri handling, cookie clearing, query-param construction) is preserved here as a -// reusable bean; rewire the actual failure dispatch to call onAuthenticationFailure(...) once -// the quarkus-oidc wiring is in place. The original Spring exception types -// (BadCredentialsException / DisabledException / LockedException / OAuth2AuthenticationException -// + OAuth2Error) were used to branch on the failure cause and must be re-mapped to the -// quarkus-oidc equivalents (io.quarkus.oidc / io.quarkus.security exceptions). -@Slf4j -@ApplicationScoped -public class CustomOAuth2AuthenticationFailureHandler { - - @Audited(type = AuditEventType.USER_FAILED_LOGIN, level = AuditLevel.BASIC) - public void onAuthenticationFailure( - HttpServletRequest request, HttpServletResponse response, Exception exception) - throws IOException, ServletException { - - // TODO: Migration required - the original handler branched on Spring Security exception - // types to choose a redirect target: - // BadCredentialsException -> "/login?error=badCredentials" - // DisabledException -> "/logout?userIsDisabled=true" - // LockedException -> "/logout?error=locked" - // OAuth2AuthenticationException (with OAuth2Error.getErrorCode()) -> OAuth2 error flow - // Re-map these branches to the corresponding quarkus-oidc / io.quarkus.security failure - // causes. The OAuth2 error-code handling below is preserved but the error code can no - // longer be extracted from OAuth2Error and must be sourced from the OIDC failure context. - - String errorCode = null; - if ("Password must not be null".equals(errorCode)) { - errorCode = "userAlreadyExistsWeb"; - } - - log.error( - "OAuth2 Authentication error: {}", - errorCode != null ? errorCode : exception.getMessage(), - exception); - String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError"; - clearRedirectCookie(response); - boolean tauriState = TauriOAuthUtils.isTauriState(request); - String redirectUrl; - if (tauriState) { - String basePath = TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath()); - redirectUrl = basePath; - String stateParam = request.getParameter("state"); - if (stateParam != null && !stateParam.isBlank()) { - redirectUrl = appendQueryParam(redirectUrl, "state", stateParam); - // Extract and pass nonce for CSRF validation - String nonce = TauriOAuthUtils.extractNonceFromState(stateParam); - if (nonce != null) { - redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce); - } - } - redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue); - } else { - redirectUrl = buildFailureRedirectUrl(request, errorValue); - } - response.sendRedirect(redirectUrl); - } - - private String buildFailureRedirectUrl(HttpServletRequest request, String errorValue) { - String contextPath = request.getContextPath(); - String cookiePath = TauriOAuthUtils.extractRedirectPathFromCookie(request); - String redirectPath = - cookiePath != null ? cookiePath : TauriOAuthUtils.defaultCallbackPath(contextPath); - if (TauriOAuthUtils.isTauriState(request)) { - redirectPath = appendQueryParam(redirectPath, "tauri", "1"); - } - String resolvedPath = - redirectPath.startsWith("/") - ? TauriOAuthUtils.normalizeContextPath(contextPath) + redirectPath - : TauriOAuthUtils.normalizeContextPath(contextPath) + "/" + redirectPath; - return appendQueryParam(resolvedPath, "errorOAuth", errorValue); - } - - private void clearRedirectCookie(HttpServletResponse response) { - // Replaces Spring's ResponseCookie/HttpHeaders.SET_COOKIE with a plain servlet header. - String cookie = TauriOAuthUtils.SPA_REDIRECT_COOKIE + "=; Path=/; Max-Age=0; SameSite=Lax"; - response.addHeader("Set-Cookie", cookie); - } - - private String appendQueryParam(String path, String key, String value) { - if (path == null || path.isBlank()) { - return path; - } - String separator = path.contains("?") ? "&" : "?"; - String encodedKey = URLEncoder.encode(key, StandardCharsets.UTF_8); - String encodedValue = value == null ? "" : URLEncoder.encode(value, StandardCharsets.UTF_8); - return path + separator + encodedKey + "=" + encodedValue; - } -} 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 deleted file mode 100644 index 45a5be8f30..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationFailureHandler.java +++ /dev/null @@ -1,107 +0,0 @@ -package stirling.software.proprietary.security.saml2; - -import java.io.IOException; - -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.servlet.http.HttpServletRequest; -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; -import stirling.software.proprietary.security.oauth2.TauriOAuthUtils; - -// TODO: Migration required - This was a Spring Security SimpleUrlAuthenticationFailureHandler -// (@ConditionalOnProperty "security.saml2.enabled"). There is NO Quarkus SAML extension, so the -// SAML SP must be rehosted on a Jakarta @WebServlet using OpenSAML 5 (dnulnets/quarkus-saml -// pattern). When that servlet is in place, wire it to invoke onAuthenticationFailure(...) below -// on SAML authentication failures. The Spring security glue removed: -// - extends SimpleUrlAuthenticationFailureHandler / getRedirectStrategy().sendRedirect(...) -// -> replaced by HttpServletResponse.sendRedirect(...) -// - org.springframework.security.core.AuthenticationException -// -> replaced by a generic Exception parameter -// - org.springframework.security.saml2.{Saml2Error, Saml2AuthenticationException} -// -> re-derive the SAML error code from the OpenSAML 5 failure handling in the new servlet -// - org.springframework.security.authentication.ProviderNotFoundException -// -> map to the "not_authentication_provider_found" branch from the new servlet -// TODO: Migration required - the @ConditionalOnProperty(name = "security.saml2.enabled", -// havingValue = "true") build-time toggle was removed; this is a runtime property, so guard -// invocation at the call site (the SAML servlet) or via a runtime config check rather than a -// CDI/build-profile condition. -@Slf4j -@ApplicationScoped -public class CustomSaml2AuthenticationFailureHandler { - - @Audited(type = AuditEventType.USER_FAILED_LOGIN, level = AuditLevel.BASIC) - public void onAuthenticationFailure( - HttpServletRequest request, HttpServletResponse response, Exception exception) - throws IOException { - log.error("Authentication error", exception); - - // TODO: Migration required - the original branched on Spring's - // Saml2AuthenticationException (extracting Saml2Error.getErrorCode()) vs - // ProviderNotFoundException. With OpenSAML 5 on a Jakarta servlet, derive the SAML - // error code and the "no provider found" condition from the new failure-handling code - // and call the corresponding branch below. - String samlErrorCode = resolveSamlErrorCode(exception); - if (samlErrorCode != null) { - if (TauriSamlUtils.isTauriRelayState(request)) { - String redirectUrl = - TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath()); - String nonce = TauriSamlUtils.extractNonceFromRequest(request); - if (nonce != null) { - redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce); - } - redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", samlErrorCode); - response.sendRedirect(redirectUrl); - return; - } - response.sendRedirect("/login?errorOAuth=" + samlErrorCode); - } else if (isProviderNotFound(exception)) { - if (TauriSamlUtils.isTauriRelayState(request)) { - String redirectUrl = - TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath()); - String nonce = TauriSamlUtils.extractNonceFromRequest(request); - if (nonce != null) { - redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce); - } - redirectUrl = - appendQueryParam( - redirectUrl, "errorOAuth", "not_authentication_provider_found"); - response.sendRedirect(redirectUrl); - return; - } - response.sendRedirect("/login?errorOAuth=not_authentication_provider_found"); - } - } - - // TODO: Migration required - return the SAML error code when the failure originates from an - // OpenSAML 5 SAML response error, otherwise null. Previously this came from - // Saml2AuthenticationException.getSaml2Error().getErrorCode(). - private String resolveSamlErrorCode(Exception exception) { - return null; - } - - // TODO: Migration required - return true when no authentication provider was found. - // Previously this was (exception instanceof ProviderNotFoundException). - private boolean isProviderNotFound(Exception exception) { - return false; - } - - private String appendQueryParam(String path, String key, String value) { - if (path == null || path.isBlank()) { - return path; - } - String separator = path.contains("?") ? "&" : "?"; - String encodedKey = - java.net.URLEncoder.encode(key, java.nio.charset.StandardCharsets.UTF_8); - String encodedValue = - value == null - ? "" - : java.net.URLEncoder.encode( - value, java.nio.charset.StandardCharsets.UTF_8); - return path + separator + encodedKey + "=" + encodedValue; - } -} 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 deleted file mode 100644 index 4c960c31a3..0000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ /dev/null @@ -1,431 +0,0 @@ -package stirling.software.proprietary.security.saml2; - -import static stirling.software.proprietary.security.model.AuthenticationType.SAML2; - -import java.io.IOException; -import java.net.URI; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; -import java.sql.SQLException; -import java.util.Map; -import java.util.Optional; - -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; - -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import stirling.software.common.model.ApplicationProperties; -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.security.model.AuthenticationType; -import stirling.software.proprietary.security.oauth2.TauriOAuthUtils; -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.util.DesktopClientUtils; - -// TODO: Migration required - this class is Spring Security SAML2 success-handler glue and has no -// Quarkus equivalent. There is no Quarkus SAML extension; the SAML SP flow must be rehosted on a -// Jakarta @WebServlet using OpenSAML 5 (dnulnets/quarkus-saml pattern). The OpenSAML/business logic -// below (eligibility checks, SSO post-login, JWT issuance, context-aware redirect building) is -// preserved unchanged. The following Spring types were removed and need a Quarkus home: -// - extends SavedRequestAwareAuthenticationSuccessHandler: the "saved request" replay behavior -// (SPRING_SECURITY_SAVED_REQUEST session attribute + super.onAuthenticationSuccess) has no -// direct Quarkus analogue; reimplement original-destination replay in the new SAML SP servlet. -// - org.springframework.security.core.Authentication: the principal/authentication is now passed -// as Object so the OpenSAML principal can still be unwrapped via -// CustomSaml2AuthenticatedPrincipal. -// - org.springframework.security.authentication.LockedException: replaced by a plain -// IllegalStateException to signal a locked account; the new SP must map this to a redirect. -// - org.springframework.http.ResponseCookie / HttpHeaders: replaced with jakarta.servlet.Cookie. -// Also: JwtServiceInterface.generateToken(Authentication, claims) (collaborator) still takes a -// Spring -// Authentication; once that interface is migrated, restore the web-path token call that used it. -@AllArgsConstructor -@Slf4j -@ApplicationScoped -public class CustomSaml2AuthenticationSuccessHandler { - - private static final String SPA_REDIRECT_COOKIE = "stirling_redirect_path"; - private static final String DEFAULT_CALLBACK_PATH = "/auth/callback"; - - private LoginAttemptService loginAttemptService; - private ApplicationProperties.Security.SAML2 saml2Properties; - private UserService userService; - private final JwtServiceInterface jwtService; - private final stirling.software.proprietary.service.UserLicenseSettingsService - licenseSettingsService; - private final ApplicationProperties applicationProperties; - - @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) - public void onAuthenticationSuccess( - HttpServletRequest request, HttpServletResponse response, Object authentication) - throws ServletException, IOException { - - // TODO: Migration required - previously obtained via Authentication.getPrincipal(). The new - // SAML SP servlet must supply the OpenSAML principal (or the principal directly) here. - Object principal = authentication; - log.debug("Starting SAML2 authentication success handling"); - - if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2Principal) { - String username = saml2Principal.name(); - log.debug("Authenticated principal found for user: {}", username); - - boolean userExists = userService.usernameExistsIgnoreCase(username); - - // Check if user is eligible for SAML (grandfathered or system has ENTERPRISE license) - if (userExists) { - stirling.software.proprietary.security.model.User user = - userService.findByUsernameIgnoreCase(username).orElse(null); - - if (user != null && !licenseSettingsService.isSamlEligible(user)) { - // User is not grandfathered and no ENTERPRISE license - block SAML login - log.warn( - "SAML2 login blocked for existing user '{}' - not eligible (not grandfathered and no ENTERPRISE license)", - username); - String origin = resolveOrigin(request); - response.sendRedirect(origin + "/logout?saml2RequiresLicense=true"); - return; - } - } else if (!licenseSettingsService.isSamlEligible(null)) { - // No existing user and no ENTERPRISE license -> block auto creation - log.warn( - "SAML2 login blocked for new user '{}' - not eligible (no ENTERPRISE license for auto-creation)", - username); - String origin = resolveOrigin(request); - response.sendRedirect(origin + "/logout?saml2RequiresLicense=true"); - return; - } - - HttpSession session = request.getSession(false); - String contextPath = request.getContextPath(); - // TODO: Migration required - SPRING_SECURITY_SAVED_REQUEST was Spring Security's - // SavedRequest stored on the session. Quarkus has no SavedRequest type; the new SAML SP - // must persist and replay the original destination itself. Treated as absent for now. - Object savedRequest = null; - - log.debug( - "Session exists: {}, Saved request exists: {}", - session != null, - savedRequest != null); - - if (savedRequest != null) { - // TODO: Migration required - original-destination replay previously delegated to - // super.onAuthenticationSuccess(...) - // (SavedRequestAwareAuthenticationSuccessHandler). - // Reimplement saved-request redirect in the new SAML SP servlet, guarding static - // resources via RequestUriUtils.isStaticResource(contextPath, savedRedirectUrl). - log.debug("Saved request handling pending SAML SP migration"); - } else { - log.debug( - "Processing SAML2 authentication with autoCreateUser: {}", - saml2Properties.getAutoCreateUser()); - - if (loginAttemptService.isBlocked(username)) { - log.debug("User {} is blocked due to too many login attempts", username); - if (session != null) { - session.removeAttribute("SPRING_SECURITY_SAVED_REQUEST"); - } - // TODO: Migration required - was org.springframework.security.authentication - // .LockedException; the new SAML SP must translate this into a locked-account - // redirect/response. - throw new IllegalStateException( - "Your account has been locked due to too many failed login attempts."); - } - - boolean hasPassword = userExists && userService.hasPassword(username); - boolean isSsoUser = - userExists && userService.isSsoAuthenticationTypeByUsername(username); - boolean isSAML2User = - userExists && userService.isAuthenticationTypeByUsername(username, SAML2); - - log.debug( - "User status - Exists: {}, Has password: {}, Is SSO user: {}, Is SAML2 user: {}", - userExists, - hasPassword, - isSsoUser, - isSAML2User); - - if (userExists - && hasPassword - && !isSsoUser - && saml2Properties.getAutoCreateUser()) { - log.debug( - "User {} exists with password but is not an SSO user, redirecting to logout", - username); - String origin = resolveOrigin(request); - response.sendRedirect(origin + "/logout?oAuth2AuthenticationErrorWeb=true"); - return; - } - - try { - // Block new users only if: blockRegistration is true OR autoCreateUser is false - if (!userExists - && (saml2Properties.getBlockRegistration() - || !saml2Properties.getAutoCreateUser())) { - log.debug( - "Registration blocked for new user '{}' (blockRegistration: {}, autoCreateUser: {})", - username, - saml2Properties.getBlockRegistration(), - saml2Properties.getAutoCreateUser()); - String origin = resolveOrigin(request); - response.sendRedirect(origin + "/login?errorOAuth=oAuth2AdminBlockedUser"); - return; - } - if (!userExists && licenseSettingsService.wouldExceedLimit(1)) { - String origin = resolveOrigin(request); - response.sendRedirect(origin + "/logout?maxUsersReached=true"); - return; - } - - // Extract SSO provider information from SAML2 assertion - String ssoProviderId = saml2Principal.nameId(); - String ssoProvider = "saml2"; // fixme - - log.debug( - "Processing SSO post-login for user: {} (Provider: {}, ProviderId: {})", - username, - ssoProvider, - ssoProviderId); - - userService.processSSOPostLogin( - username, - ssoProviderId, - ssoProvider, - saml2Properties.getAutoCreateUser(), - SAML2); - log.debug("Successfully processed authentication for user: {}", username); - - // Generate JWT if v2 is enabled - if (jwtService.isJwtEnabled()) { - Map claims = Map.of("authType", AuthenticationType.SAML2); - - // Detect desktop client and issue longer-lived tokens - boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request); - String jwt; - if (isDesktopClient) { - // Desktop: Use configured desktop token expiry (default 30 days) - int desktopExpiryMinutes = - DesktopClientUtils.getDesktopTokenExpiryMinutes( - applicationProperties); - jwt = jwtService.generateToken(username, claims, desktopExpiryMinutes); - log.info( - "Issued DESKTOP SAML token for user '{}': expiry={}min ({}d)", - username, - desktopExpiryMinutes, - desktopExpiryMinutes / 1440); - } else { - // Web: Use default expiry. - // TODO: Migration required - originally - // jwtService.generateToken(authentication, claims) using the Spring - // Authentication. Switched to the username overload until - // JwtServiceInterface drops its Spring Authentication parameter. - jwt = jwtService.generateToken(username, claims); - log.debug("Issued WEB SAML token for user '{}'", username); - } - - // Build context-aware redirect URL based on the original request - String redirectUrl = - buildContextAwareRedirectUrl(request, response, contextPath, jwt); - - response.sendRedirect(redirectUrl); - } else { - // v1: redirect directly to home - response.sendRedirect(contextPath + "/"); - } - } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { - log.debug( - "Invalid username detected for user: {}, redirecting to logout", - username); - response.sendRedirect(contextPath + "/logout?invalidUsername=true"); - } - } - } else { - // TODO: Migration required - non-SAML2 principals were delegated to the Spring base - // SavedRequestAwareAuthenticationSuccessHandler. The new SAML SP servlet must decide - // how - // to handle non-SAML2 principals (this handler should only receive SAML2 ones). - log.debug("Non-SAML2 principal detected, no parent handler available after migration"); - } - } - - /** - * Builds a context-aware redirect URL based on the request's origin - * - * @param request The HTTP request - * @param contextPath The application context path - * @param jwt The JWT token to include - * @return The appropriate redirect URL - */ - private String buildContextAwareRedirectUrl( - HttpServletRequest request, - HttpServletResponse response, - String contextPath, - String jwt) { - String redirectPath = resolveRedirectPath(request, contextPath); - String origin = resolveOrigin(request); - clearRedirectCookie(response); - String url = origin + redirectPath + "#access_token=" + jwt; - - String nonce = TauriSamlUtils.extractNonceFromRequest(request); - if (nonce != null) { - url += - "&nonce=" - + java.net.URLEncoder.encode( - nonce, java.nio.charset.StandardCharsets.UTF_8); - } - return url; - } - - /** - * Resolve the origin (frontend URL) for redirects. First checks system.frontendUrl from config, - * then falls back to detecting from request headers. - */ - private String resolveOrigin(HttpServletRequest request) { - // First check if frontendUrl is configured - String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl(); - if (configuredFrontendUrl != null && !configuredFrontendUrl.trim().isEmpty()) { - return configuredFrontendUrl.trim(); - } - - // Fall back to auto-detection from request headers - return resolveForwardedOrigin(request) - .orElseGet( - () -> - resolveOriginFromReferer(request) - .orElseGet(() -> buildOriginFromRequest(request))); - } - - private String resolveRedirectPath(HttpServletRequest request, String contextPath) { - if (TauriSamlUtils.isTauriRelayState(request)) { - return TauriOAuthUtils.defaultTauriCallbackPath(contextPath); - } - return extractRedirectPathFromCookie(request) - .filter(path -> path.startsWith("/")) - .orElseGet(() -> defaultCallbackPath(contextPath)); - } - - private Optional extractRedirectPathFromCookie(HttpServletRequest request) { - Cookie[] cookies = request.getCookies(); - if (cookies == null) { - return Optional.empty(); - } - for (Cookie cookie : cookies) { - if (SPA_REDIRECT_COOKIE.equals(cookie.getName())) { - String value = URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8).trim(); - if (!value.isEmpty()) { - return Optional.of(value); - } - } - } - return Optional.empty(); - } - - private String defaultCallbackPath(String contextPath) { - if (contextPath == null - || contextPath.isBlank() - || "/".equals(contextPath) - || "\\".equals(contextPath)) { - return DEFAULT_CALLBACK_PATH; - } - return contextPath + DEFAULT_CALLBACK_PATH; - } - - private Optional resolveForwardedOrigin(HttpServletRequest request) { - String forwardedHostHeader = request.getHeader("X-Forwarded-Host"); - if (forwardedHostHeader == null || forwardedHostHeader.isBlank()) { - return Optional.empty(); - } - String host = forwardedHostHeader.split(",")[0].trim(); - if (host.isEmpty()) { - return Optional.empty(); - } - - String forwardedProtoHeader = request.getHeader("X-Forwarded-Proto"); - String proto = - (forwardedProtoHeader == null || forwardedProtoHeader.isBlank()) - ? request.getScheme() - : forwardedProtoHeader.split(",")[0].trim(); - - if (!host.contains(":")) { - String forwardedPort = request.getHeader("X-Forwarded-Port"); - if (forwardedPort != null - && !forwardedPort.isBlank() - && !isDefaultPort(proto, forwardedPort.trim())) { - host = host + ":" + forwardedPort.trim(); - } - } - return Optional.of(proto + "://" + host); - } - - private Optional resolveOriginFromReferer(HttpServletRequest request) { - String referer = request.getHeader("Referer"); - if (referer != null && !referer.isEmpty()) { - try { - URI refererUri = URI.create(referer); - String host = refererUri.getHost(); - if (host == null) { - return Optional.empty(); - } - String origin = refererUri.getScheme() + "://" + host; - int port = refererUri.getPort(); - if (port != -1 && port != 80 && port != 443) { - origin += ":" + port; - } - return Optional.of(origin); - } catch (IllegalArgumentException e) { - log.debug( - "Malformed referer URL: {}, falling back to request-based origin", referer); - } - } - return Optional.empty(); - } - - private String buildOriginFromRequest(HttpServletRequest request) { - String scheme = request.getScheme(); - String serverName = request.getServerName(); - int serverPort = request.getServerPort(); - - StringBuilder origin = new StringBuilder(); - origin.append(scheme).append("://").append(serverName); - - if ((!"http".equalsIgnoreCase(scheme) || serverPort != 80) - && (!"https".equalsIgnoreCase(scheme) || serverPort != 443)) { - origin.append(":").append(serverPort); - } - - return origin.toString(); - } - - private boolean isDefaultPort(String scheme, String port) { - if (port == null) { - return true; - } - try { - int parsedPort = Integer.parseInt(port); - return ("http".equalsIgnoreCase(scheme) && parsedPort == 80) - || ("https".equalsIgnoreCase(scheme) && parsedPort == 443); - } catch (NumberFormatException e) { - return false; - } - } - - private void clearRedirectCookie(HttpServletResponse response) { - // TODO: Migration required - was org.springframework.http.ResponseCookie with SameSite=Lax. - // jakarta.servlet.Cookie has no SameSite setter on this servlet API level; SameSite=Lax is - // dropped here. Set it via the new SAML SP servlet response or quarkus.http config if - // needed. - Cookie cookie = new Cookie(SPA_REDIRECT_COOKIE, ""); - cookie.setPath("/"); - cookie.setMaxAge(0); - response.addCookie(cookie); - } -} 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 8ce6a540b3..3bba3462b0 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 @@ -399,14 +399,18 @@ public class UserService implements UserServiceInterface { public void changePassword(User user, String newPassword) throws SQLException, UnsupportedProviderException { user.setPassword(passwordEncoder.encode(newPassword)); - userRepository.persist(user); + // Spring Data save() upserted; Panache persist() rejects an already-persisted (detached) + // entity ("Detached entity passed to persist"). The user was loaded in the request context, + // so re-attach via merge to update it. + userRepository.getEntityManager().merge(user); databaseService.exportDatabase(); } + @Transactional public void changeFirstUse(User user, boolean firstUse) throws SQLException, UnsupportedProviderException { user.setFirstLogin(firstUse); - userRepository.persist(user); + userRepository.getEntityManager().merge(user); databaseService.exportDatabase(); } 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 deleted file mode 100644 index 79d0b9ef53..0000000000 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java +++ /dev/null @@ -1,289 +0,0 @@ -package stirling.software.proprietary.security; - -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.io.IOException; - -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.junit.jupiter.MockitoExtension; -import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - -import stirling.software.common.model.ApplicationProperties; -import stirling.software.proprietary.security.service.JwtServiceInterface; - -@Disabled("TODO: Migration required - Spring Boot test framework not available in Quarkus") -@ExtendWith(MockitoExtension.class) -class CustomLogoutSuccessHandlerTest { - - @Mock private ApplicationProperties.Security securityProperties; - - @Mock private JwtServiceInterface jwtService; - - @InjectMocks private CustomLogoutSuccessHandler customLogoutSuccessHandler; - - @Test - void testSuccessfulLogout() throws IOException { - HttpServletRequest request = mock(HttpServletRequest.class); - HttpServletResponse response = mock(HttpServletResponse.class); - String token = "token"; - String logoutPath = "/login?logout=true"; - - when(response.isCommitted()).thenReturn(false); - when(jwtService.extractToken(request)).thenReturn(token); - when(request.getContextPath()).thenReturn(""); - when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath); - - customLogoutSuccessHandler.onLogoutSuccess(request, response, null); - - 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); - when(request.getContextPath()).thenReturn(""); - when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath); - - customLogoutSuccessHandler.onLogoutSuccess(request, response, null); - - verify(response).sendRedirect(logoutPath); - } - - @Test - void testSuccessfulLogoutViaOAuth2() throws IOException { - HttpServletRequest request = mock(HttpServletRequest.class); - HttpServletResponse response = mock(HttpServletResponse.class); - OAuth2AuthenticationToken oAuth2AuthenticationToken = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security.OAUTH2 oauth = - mock(ApplicationProperties.Security.OAUTH2.class); - - when(response.isCommitted()).thenReturn(false); - when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null); - when(request.getParameter("errorOAuth")).thenReturn(null); - when(request.getScheme()).thenReturn("http"); - when(request.getServerName()).thenReturn("localhost"); - when(request.getServerPort()).thenReturn(8080); - when(request.getContextPath()).thenReturn(""); - when(securityProperties.getOauth2()).thenReturn(oauth); - when(oAuth2AuthenticationToken.getAuthorizedClientRegistrationId()).thenReturn("test"); - - customLogoutSuccessHandler.onLogoutSuccess(request, response, oAuth2AuthenticationToken); - - verify(response).sendRedirect("http://localhost:8080/login?logout=true"); - } - - @Test - void testUserIsDisabledRedirect() throws IOException { - String error = "userIsDisabled"; - String url = "http://localhost:8080"; - HttpServletRequest request = mock(HttpServletRequest.class); - HttpServletResponse response = mock(HttpServletResponse.class); - OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security.OAUTH2 oauth = - mock(ApplicationProperties.Security.OAUTH2.class); - - when(response.isCommitted()).thenReturn(false); - when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null); - when(request.getParameter("errorOAuth")).thenReturn(null); - when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null); - when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null); - when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null); - when(request.getParameter("saml2RequiresLicense")).thenReturn(null); - when(request.getParameter("maxUsersReached")).thenReturn(null); - when(request.getParameter(error)).thenReturn("true"); - when(request.getScheme()).thenReturn("http"); - when(request.getServerName()).thenReturn("localhost"); - when(request.getServerPort()).thenReturn(8080); - when(request.getContextPath()).thenReturn(""); - when(securityProperties.getOauth2()).thenReturn(oauth); - when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test"); - - customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); - - verify(response).sendRedirect(url + "/login?errorOAuth=" + error); - } - - @Test - void testUserAlreadyExistsWebRedirect() throws IOException { - String error = "oAuth2AuthenticationErrorWeb"; - String errorPath = "userAlreadyExistsWeb"; - String url = "http://localhost:8080"; - HttpServletRequest request = mock(HttpServletRequest.class); - HttpServletResponse response = mock(HttpServletResponse.class); - OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security.OAUTH2 oauth = - mock(ApplicationProperties.Security.OAUTH2.class); - - when(response.isCommitted()).thenReturn(false); - when(request.getParameter(error)).thenReturn("true"); - when(request.getScheme()).thenReturn("http"); - when(request.getServerName()).thenReturn("localhost"); - when(request.getServerPort()).thenReturn(8080); - when(request.getContextPath()).thenReturn(""); - when(securityProperties.getOauth2()).thenReturn(oauth); - when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test"); - - customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); - - verify(response).sendRedirect(url + "/login?errorOAuth=" + errorPath); - } - - @Test - void testErrorOAuthRedirect() throws IOException { - String error = "testError"; - String url = "http://localhost:8080"; - HttpServletRequest request = mock(HttpServletRequest.class); - HttpServletResponse response = mock(HttpServletResponse.class); - OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security.OAUTH2 oauth = - mock(ApplicationProperties.Security.OAUTH2.class); - - when(response.isCommitted()).thenReturn(false); - when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null); - when(request.getParameter("errorOAuth")).thenReturn("!!!" + error + "!!!"); - when(request.getScheme()).thenReturn("http"); - when(request.getServerName()).thenReturn("localhost"); - when(request.getServerPort()).thenReturn(8080); - when(request.getContextPath()).thenReturn(""); - when(securityProperties.getOauth2()).thenReturn(oauth); - when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test"); - - customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); - - verify(response).sendRedirect(url + "/login?errorOAuth=" + error); - } - - @Test - void testOAuth2AutoCreateDisabled() throws IOException { - String error = "oAuth2AutoCreateDisabled"; - String url = "http://localhost:8080"; - HttpServletRequest request = mock(HttpServletRequest.class); - HttpServletResponse response = mock(HttpServletResponse.class); - OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security.OAUTH2 oauth = - mock(ApplicationProperties.Security.OAUTH2.class); - - when(response.isCommitted()).thenReturn(false); - when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null); - when(request.getParameter("errorOAuth")).thenReturn(null); - when(request.getParameter(error)).thenReturn("true"); - when(request.getContextPath()).thenReturn(url); - when(request.getScheme()).thenReturn("http"); - when(request.getServerName()).thenReturn("localhost"); - when(request.getServerPort()).thenReturn(8080); - when(request.getContextPath()).thenReturn(""); - when(securityProperties.getOauth2()).thenReturn(oauth); - when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test"); - - customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); - - verify(response).sendRedirect(url + "/login?errorOAuth=" + error); - } - - @Test - void testOAuth2Error() throws IOException { - String error = "test"; - String url = "http://localhost:8080"; - HttpServletRequest request = mock(HttpServletRequest.class); - HttpServletResponse response = mock(HttpServletResponse.class); - OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security.OAUTH2 oauth = - mock(ApplicationProperties.Security.OAUTH2.class); - - when(response.isCommitted()).thenReturn(false); - when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null); - when(request.getParameter("errorOAuth")).thenReturn(null); - when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null); - when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null); - when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null); - when(request.getParameter("saml2RequiresLicense")).thenReturn(null); - when(request.getParameter("maxUsersReached")).thenReturn(null); - when(request.getParameter("userIsDisabled")).thenReturn(null); - when(request.getParameter("error")).thenReturn("!@$!@£" + error + "£$%^*$"); - when(request.getScheme()).thenReturn("http"); - when(request.getServerName()).thenReturn("localhost"); - when(request.getServerPort()).thenReturn(8080); - when(request.getContextPath()).thenReturn(""); - when(securityProperties.getOauth2()).thenReturn(oauth); - when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test"); - - customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); - - verify(response).sendRedirect(url + "/login?errorOAuth=" + error); - } - - @Test - void testOAuth2BadCredentialsError() throws IOException { - String error = "badCredentials"; - String url = "http://localhost:8080"; - HttpServletRequest request = mock(HttpServletRequest.class); - HttpServletResponse response = mock(HttpServletResponse.class); - OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security.OAUTH2 oauth = - mock(ApplicationProperties.Security.OAUTH2.class); - - when(response.isCommitted()).thenReturn(false); - when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null); - when(request.getParameter("errorOAuth")).thenReturn(null); - when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null); - when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null); - when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null); - when(request.getParameter("saml2RequiresLicense")).thenReturn(null); - when(request.getParameter("maxUsersReached")).thenReturn(null); - when(request.getParameter("userIsDisabled")).thenReturn(null); - when(request.getParameter("error")).thenReturn(null); - when(request.getParameter(error)).thenReturn("true"); - when(request.getScheme()).thenReturn("http"); - when(request.getServerName()).thenReturn("localhost"); - when(request.getServerPort()).thenReturn(8080); - when(request.getContextPath()).thenReturn(""); - when(securityProperties.getOauth2()).thenReturn(oauth); - when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test"); - - customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); - - verify(response).sendRedirect(url + "/login?errorOAuth=" + error); - } - - @Test - void testOAuth2AdminBlockedUser() throws IOException { - String error = "oAuth2AdminBlockedUser"; - String url = "http://localhost:8080"; - HttpServletRequest request = mock(HttpServletRequest.class); - HttpServletResponse response = mock(HttpServletResponse.class); - OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security.OAUTH2 oauth = - mock(ApplicationProperties.Security.OAUTH2.class); - - when(response.isCommitted()).thenReturn(false); - when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null); - when(request.getParameter("errorOAuth")).thenReturn(null); - when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null); - when(request.getParameter(error)).thenReturn("true"); - when(request.getScheme()).thenReturn("http"); - when(request.getServerName()).thenReturn("localhost"); - when(request.getServerPort()).thenReturn(8080); - when(request.getContextPath()).thenReturn(""); - when(securityProperties.getOauth2()).thenReturn(oauth); - when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test"); - - customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); - - verify(response).sendRedirect(url + "/login?errorOAuth=" + error); - } -} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandlerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandlerTest.java deleted file mode 100644 index 23758e5217..0000000000 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandlerTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package stirling.software.proprietary.security.oauth2; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.OAuth2Error; - -@Disabled("TODO: Migration required - Spring Boot test framework not available in Quarkus") -class CustomOAuth2AuthenticationFailureHandlerTest { - - @Test - void redirectsToTauriCallbackWhenStateMarked() throws Exception { - CustomOAuth2AuthenticationFailureHandler handler = - new CustomOAuth2AuthenticationFailureHandler(); - MockHttpServletRequest request = new MockHttpServletRequest(); - request.setContextPath(""); - request.setParameter("state", "tauri:abc"); - MockHttpServletResponse response = new MockHttpServletResponse(); - - handler.onAuthenticationFailure( - request, - response, - new OAuth2AuthenticationException(new OAuth2Error("access_denied"))); - - assertEquals( - "/auth/callback/tauri?state=tauri%3Aabc&errorOAuth=access_denied", - response.getRedirectedUrl()); - } - - @Test - void redirectsToDefaultCallbackWithoutTauriState() throws Exception { - CustomOAuth2AuthenticationFailureHandler handler = - new CustomOAuth2AuthenticationFailureHandler(); - MockHttpServletRequest request = new MockHttpServletRequest(); - request.setContextPath(""); - MockHttpServletResponse response = new MockHttpServletResponse(); - - handler.onAuthenticationFailure( - request, - response, - new OAuth2AuthenticationException(new OAuth2Error("access_denied"))); - - assertEquals("/auth/callback?errorOAuth=access_denied", response.getRedirectedUrl()); - } -} diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index 89913af09a..ea3853d987 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -50,25 +50,19 @@ RUN STIRLING_FLAVOR=${STIRLING_FLAVOR} \ -x spotlessApply -x spotlessCheck -x test -x sonarqube \ --no-daemon -# Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:b27ca47660a8fa837e47a8533b9b1a3a430295cf29ca28d91af4fd121572dc29 AS jar-extract -WORKDIR /tmp -COPY --from=app-build /app/app/core/build/libs/*.jar app.jar -RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers - - -# Stage 3: Final runtime image on top of pre-built base +# Stage 2: Final runtime image on top of pre-built base FROM ${BASE_IMAGE} ARG VERSION_TAG WORKDIR /app -# Application layers -COPY --link --from=jar-extract --chown=1000:1000 /layers/dependencies/ /app/ -COPY --link --from=jar-extract --chown=1000:1000 /layers/spring-boot-loader/ /app/ -COPY --link --from=jar-extract --chown=1000:1000 /layers/snapshot-dependencies/ /app/ -COPY --link --from=jar-extract --chown=1000:1000 /layers/application/ /app/ +# Quarkus uber runner-jar (self-contained, all dependencies inside). init-without-ocr.sh launches +# it with `java -jar /app/app.jar`. Replaces the former Spring Boot layered-jar extraction +# (java -Djarmode=tools extract --layers + spring-boot-loader layers), which the Quarkus uber-jar +# does not support. +COPY --link --from=app-build --chown=1000:1000 \ + /app/app/core/build/stirling-pdf-*-runner.jar /app/app.jar COPY --link --from=app-build --chown=1000:1000 \ /app/build/libs/restart-helper.jar /restart-helper.jar diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index f9754b4721..ce006c6502 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -46,25 +46,19 @@ RUN DISABLE_ADDITIONAL_FEATURES=false \ -x spotlessApply -x spotlessCheck -x test -x sonarqube \ --no-daemon -# Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble AS jar-extract -WORKDIR /tmp -COPY --from=app-build /app/app/core/build/libs/*.jar app.jar -RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers - - -# Stage 3: Final runtime image on top of pre-built base +# Stage 2: Final runtime image on top of pre-built base FROM ${BASE_IMAGE} ARG VERSION_TAG WORKDIR /app -# Application layers -COPY --link --from=jar-extract --chown=1000:1000 /layers/dependencies/ /app/ -COPY --link --from=jar-extract --chown=1000:1000 /layers/spring-boot-loader/ /app/ -COPY --link --from=jar-extract --chown=1000:1000 /layers/snapshot-dependencies/ /app/ -COPY --link --from=jar-extract --chown=1000:1000 /layers/application/ /app/ +# Quarkus uber runner-jar (self-contained, all dependencies inside). init-without-ocr.sh launches +# it via `java -jar /app/app.jar`. Replaces the former Spring Boot layered-jar extraction +# (java -Djarmode=tools extract --layers + spring-boot-loader layers), which the Quarkus uber-jar +# does not support; build/libs/*.jar is only the thin (non-runnable) jar. +COPY --link --from=app-build --chown=1000:1000 \ + /app/app/core/build/stirling-pdf-*-runner.jar /app/app.jar COPY --link --from=app-build --chown=1000:1000 \ /app/build/libs/restart-helper.jar /restart-helper.jar diff --git a/docker/embedded/Dockerfile.ultra-lite b/docker/embedded/Dockerfile.ultra-lite index d091d7b3fa..e97cad0e2c 100644 --- a/docker/embedded/Dockerfile.ultra-lite +++ b/docker/embedded/Dockerfile.ultra-lite @@ -110,9 +110,11 @@ COPY --chown=1000:1000 scripts/init-without-ocr.sh /scripts/init-without-ocr.sh COPY --chown=1000:1000 scripts/installFonts.sh /scripts/installFonts.sh COPY --chown=1000:1000 scripts/stirling-diagnostics.sh /scripts/stirling-diagnostics.sh -# Copy built JARs from build stage +# Copy the Quarkus uber runner-jar (self-contained). build/libs/*.jar is the thin jar (app classes +# only, not runnable); the runnable artifact is build/-runner.jar. init-without-ocr.sh runs +# it via `java -jar /app.jar`. COPY --from=build --chown=1000:1000 \ - /app/app/core/build/libs/*.jar /app.jar + /app/app/core/build/stirling-pdf-*-runner.jar /app.jar COPY --from=build --chown=1000:1000 \ /app/build/libs/restart-helper.jar /restart-helper.jar diff --git a/scripts/db-migration/run-migration-test.sh b/scripts/db-migration/run-migration-test.sh index 953f58712f..142e0d0db6 100644 --- a/scripts/db-migration/run-migration-test.sh +++ b/scripts/db-migration/run-migration-test.sh @@ -32,9 +32,8 @@ find_jar() { [[ -f "$STIRLING_JAR" ]] || fail "STIRLING_JAR='$STIRLING_JAR' not found" candidate="$STIRLING_JAR" else - candidate=$(find "$REPO_ROOT/app/core/build/libs" -maxdepth 1 -name 'Stirling-PDF*.jar' -o -name 'stirling-pdf*.jar' 2>/dev/null \ - | grep -vE '(-plain|-sources)\.jar$' | head -n 1 || true) - [[ -n "$candidate" ]] || fail "No JAR under app/core/build/libs - run './gradlew :stirling-pdf:bootJar' first" + candidate=$(find "$REPO_ROOT/app/core/build" -maxdepth 1 -name '*-runner.jar' 2>/dev/null | head -n 1 || true) + [[ -n "$candidate" ]] || fail "No *-runner.jar under app/core/build - run './gradlew :stirling-pdf:quarkusBuild' first" fi # Resolve to an absolute path: test_fixture pushd's into a temp workdir # before launching java, so a relative path here would dangle. @@ -86,13 +85,17 @@ test_fixture() { # to cwd, and we want to make sure we hit the fixture's configs/ and not # whatever happens to live at the runner's working directory. pushd "$workdir" >/dev/null - java -Xmx1g -jar "$jar" \ - "--server.port=$port" \ - "--spring.datasource.url=jdbc:h2:file:./configs/stirling-pdf-DB-2.3.232;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=TRUE;MODE=PostgreSQL" \ - "--spring.jpa.show-sql=false" \ - "--logging.level.root=WARN" \ - "--logging.level.stirling=INFO" \ - "--logging.level.org.hibernate.tool.schema=INFO" \ + # Quarkus reads config from -D system properties (which must precede -jar), + # not Spring's post-jar --key=value args. Translated 1:1 from the former + # Spring properties; system properties override application.properties. + java -Xmx1g \ + "-Dquarkus.http.port=$port" \ + "-Dquarkus.datasource.jdbc.url=jdbc:h2:file:./configs/stirling-pdf-DB-2.3.232;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=TRUE;MODE=PostgreSQL" \ + "-Dquarkus.hibernate-orm.log.sql=false" \ + "-Dquarkus.log.level=WARN" \ + "-Dquarkus.log.category.stirling.level=INFO" \ + '-Dquarkus.log.category."org.hibernate.tool.schema".level=INFO' \ + -jar "$jar" \ > "$log_file" 2>&1 & local pid=$! popd >/dev/null diff --git a/scripts/init-without-ocr.sh b/scripts/init-without-ocr.sh index c55dc1e6d4..8dc1040e61 100755 --- a/scripts/init-without-ocr.sh +++ b/scripts/init-without-ocr.sh @@ -953,13 +953,12 @@ JAVA_CMD=( if [ -f "/app.jar" ]; then JAVA_CMD+=("-jar" "/app.jar") elif [ -f "/app/app.jar" ]; then - # Spring Boot 4 layered JAR structure (exploded via extract --layers). - # Use -cp (not -jar) so the classpath matches the AOT cache exactly. - JAVA_CMD+=("-cp" "/app/app.jar:/app/lib/*" "stirling.software.SPDF.SPDFApplication") + # Quarkus uber runner-jar (self-contained, all dependencies inside). Launched via -jar; + # the manifest Main-Class bootstraps the Quarkus application (@QuarkusMain SPDFApplication). + JAVA_CMD+=("-jar" "/app/app.jar") else - # Legacy fallback for Spring Boot 3 layered layout - export JAVA_MAIN_CLASS=org.springframework.boot.loader.launch.JarLauncher - JAVA_CMD+=("org.springframework.boot.loader.launch.JarLauncher") + log "ERROR: no application jar found at /app.jar or /app/app.jar" + exit 1 fi if [ "$CURRENT_USER" = "$RUNTIME_USER" ]; then @@ -1032,15 +1031,11 @@ if [ "$AOT_GENERATE_BACKGROUND" = true ]; then while [ "$_attempt" -le "$_max_attempts" ]; do log "AOT: Background cache generation attempt ${_attempt}/${_max_attempts}..." _gen_rc=0 - if [ -f /app/app.jar ] && [ -d /app/lib ]; then - generate_aot_cache "$AOT_CACHE" \ - -cp "/app/app.jar:/app/lib/*" stirling.software.SPDF.SPDFApplication || _gen_rc=$? + if [ -f /app/app.jar ]; then + # Quarkus uber runner-jar (self-contained); mirror the runtime -jar launch. + generate_aot_cache "$AOT_CACHE" -jar /app/app.jar || _gen_rc=$? elif [ -f /app.jar ]; then generate_aot_cache "$AOT_CACHE" -jar /app.jar || _gen_rc=$? - elif [ -d /app/BOOT-INF ]; then - # Spring Boot exploded layer layout, mirror the exact JAVA_CMD classpath - generate_aot_cache "$AOT_CACHE" \ - -cp /app org.springframework.boot.loader.launch.JarLauncher || _gen_rc=$? else log "AOT: Cannot determine JAR layout; skipping cache generation." exit 0